From abb27f08f2e9b05e4ebb6b545cece2ce6e0b3172 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 18:44:01 -0400 Subject: [PATCH 01/56] Raise the editor floor to 1190x680, derived from the deck's declared width budget, and make row membership a property of the group --- src/core/instrument/CLAUDE.md | 2 +- src/core/instrument/ui/deck_groups.cpp | 23 +++++ src/core/instrument/ui/deck_groups.h | 10 ++ src/core/instrument/ui/knob_deck.h | 18 +++- src/core/instrument/ui/sample_bands.h | 7 +- tests/test_deck_groups.cpp | 122 +++++++++++++++++++++---- 6 files changed, 159 insertions(+), 23 deletions(-) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 272c14a..d682f10 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -314,7 +314,7 @@ anything for a trigger shape. - `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search. - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain. -- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. **The cell/knob/label sizes and `sample_bands`' editor floor move as a pair** — wider cells need a wider floor width or the deck wraps to a fourth row. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has fourteen pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots. +- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, the ceiling, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots. - `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups` says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` / diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 28de21f..a1401cf 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -130,6 +130,29 @@ std::vector sampleDeckGroups(PlayMode playMode) { return out; } +DeckRow deckRowFor(DeckGroupId group) { + // Every enumerator listed and no default, on the same gate isLiveDeckParam below relies on. + switch (group) { + case kGroupPitch: + case kGroupFilter: + case kGroupVelocity: + case kGroupVoice: + return DeckRow::Sound; + case kGroupPitchEnv: + case kGroupFilterEnv: + case kGroupAmpEnv: + return DeckRow::Contour; + case kGroupMaster: + return DeckRow::Spanning; + } + // Unreachable for a valid enumerator, and Spanning rather than Sound ON PURPOSE: the + // -Wswitch gate is compiler-dependent, so on a toolchain that does not raise it a dropped + // case arm falls here instead. Sound is what a new group most plausibly IS, which would + // make the fall-through invisible; Spanning is the one row nothing may silently join, so + // the tests' partition count catches it. + return DeckRow::Spanning; +} + CurveTarget curveTargetFor(int controlId) { switch (static_cast(controlId)) { case DeckParam::kAmpVelCurve: return CurveTarget::kAmp; diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index f338102..6a76cbf 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -101,6 +101,16 @@ enum DeckGroupId { kGroupMaster, }; +// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice +// IS, Contour is how it moves over time, Spanning is what happens after the mixer. +enum class DeckRow { Sound, Contour, Spanning }; + +// Which row a group belongs to. Membership is a property of the GROUP; width is a property of +// its descriptor — separating them is what lets the row law be settled while the descriptors +// are still moving. Total over DeckGroupId by an exhaustive switch with no default, so a group +// added without a row cannot silently become Sound. +DeckRow deckRowFor(DeckGroupId group); + // Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE // one place a control id resolves to a curve target — paint (draw a curve thumbnail, not a // dial) and hit-test (open a popup, not start a drag) both read this predicate rather than diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index f13b995..44fd38f 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -23,8 +23,9 @@ namespace reasampler::instrument::ui { // Fixed deck metrics, exposed so the shell and tests agree. The cell/knob/label sizes were -// raised together for legibility at high pixel densities; the editor's floor width -// (sample_bands) is what absorbs the wider cells, so the two move as a pair. +// raised together for legibility at high pixel densities. The deck's cell metrics AND its +// group/row composition BOTH drive sample_bands' kEditorMinWidth; none of the three may move +// alone. inline constexpr int kDeckCellW = 60; // one knob cell inline constexpr int kDeckCellH = 74; inline constexpr int kDeckKnobSize = 40; // knob diameter inside the cell @@ -46,6 +47,19 @@ inline constexpr int kDeckInnerDialSize = 20; inline constexpr int kDeckGroupH = kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY; +// --- The deck's width budget at the editor's floor ------------------------------------ +// DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a +// group inventory that overruns one is what fails. sample_bands' kEditorMinWidth is derived +// from the first two — kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2*kPad — and the +// identity is asserted in test_deck_groups.cpp rather than coded, so the allocator keeps no +// include edge to this header. +inline constexpr int kDeckRowBlockW = 1020; // the block both categorical rows justify inside +inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck, outside the block +// The hard ceiling the FLOOR may not exceed; the window itself still grows freely above it. +// The gap between it and kEditorMinWidth is the whole width budget for the life of this +// layout — see instrument-control-surface.md §1.6 before spending any of it. +inline constexpr int kEditorCeilingWidth = 1280; + // A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent. struct DeckToggleDesc { int id = -1; // shell control id returned by the hit-test; -1 = no toggle diff --git a/src/core/instrument/ui/sample_bands.h b/src/core/instrument/ui/sample_bands.h index 3210520..564c0ae 100644 --- a/src/core/instrument/ui/sample_bands.h +++ b/src/core/instrument/ui/sample_bands.h @@ -16,7 +16,12 @@ inline constexpr int kPad = 8; // exactly this, and there is no scroll, so anything smaller pushes the deck band off the // window bottom (computeSampleBands' waveform-floor-wins degrade). Growing is fine — the // waveform band is the elastic one. Both the enforced minimum and the opening rect read this. -inline constexpr int kEditorMinWidth = 980; +// +// The width is a LITERAL here on purpose, though it is derived from knob_deck's width budget: +// this allocator is deliberately independent of the deck (it takes deckHeight as a parameter +// for exactly that reason), so the derivation is asserted in test_deck_groups.cpp — the one +// place that already includes both headers — rather than coded as an include edge. +inline constexpr int kEditorMinWidth = 1190; inline constexpr int kEditorMinHeight = 680; // Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip, diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 2c4c30b..5069705 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -3,7 +3,8 @@ // descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), // the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve // cells and its placement immediately left of VOICE, the wrapped deck height at the editor's -// floor width and its fit inside the floor window, the pinned Gate widths and row assignment, +// floor width and its fit inside the floor window, the pinned Gate group widths, the editor +// floor derived from the deck's width budget and each group's categorical row, // that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round // trip restores the layout exactly, the hit-test reaching the new filter controls, the bipolar knob // law's inverse pair, the commit-tier routing — which controls are live, and which drags take @@ -238,13 +239,14 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() { static void testWrappedDeckHeightAtTheEditorFloorWidth() { const std::vector g = sampleDeckGroups(PlayMode::Gate); - // At the floor (== default) 980 the deck takes three rows: PITCH + PITCH ENV + FILTER fill - // the first (950 of the 964 available — fourteen px of headroom, so one more FILTER cell - // would wrap the group and reflow everything under it), FILTER ENV + AMP + VELOCITY the - // second, VOICE + MASTER the third. Two rows cannot hold the eight groups in ANY order at - // this width: 1978 px of group plus 72 px of gaps against a 1928 px two-row capacity. - CHECK(deckRowCount(g, kAvailAtMinWidth) == 3); - CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap); + // An UPPER BOUND, not an equality. The greedy whole-group wrap is still what decides row + // membership until the reflow replaces it with the categorical partition, and at this width + // it happens to pack two ragged rows with the wrong composition. Bounding it is a real + // regression canary — a third row would cost the waveform 112 px again — without turning a + // wrap outcome into a claim. + const int rows = deckRowCount(g, kAvailAtMinWidth); + CHECK(rows <= 2); + CHECK(deckHeight(g, kAvailAtMinWidth) == rows * kDeckGroupH + (rows - 1) * kDeckRowGap); // Whole groups only, never split: every group's box lies inside the available width or is // the first of its row. @@ -265,8 +267,13 @@ static void testDeckFitsInsideTheEnforcedMinimumWindow() { const std::vector g = sampleDeckGroups(mode); const int h = deckHeight(g, kAvailAtMinWidth); const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); - CHECK(deckRowCount(g, kAvailAtMinWidth) == 3); // either face, three rows at the floor + CHECK(deckRowCount(g, kAvailAtMinWidth) <= 2); // either face; see the bound above CHECK(b.decks.height == h); + // The raised floor hands the waveform the reflow's 112 px two waves early: at two rows + // the deck band is 216 and the waveform 358, against 328/246 before. Bounded rather + // than pinned for the same reason the row count is. + CHECK(b.decks.height <= 2 * kDeckGroupH + kDeckRowGap); + CHECK(b.waveform.height >= 358); // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the // deck down until the waveform hits its floor, so any deck too tall to fit stops // landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the @@ -276,6 +283,75 @@ static void testDeckFitsInsideTheEnforcedMinimumWindow() { } } +// The floor is a DERIVED number, and this is the one place the derivation is written down — +// sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is +// the only one that includes both. +static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() { + CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth); + // The budget: what is left between the derived floor and the hard ceiling, and it is spent + // once. A cell costs 60 of it. + CHECK(kEditorCeilingWidth - kEditorMinWidth == 90); + // The reflow's 112 px goes entirely to the waveform, so the height does not move. + CHECK(kEditorMinHeight == 680); +} + +static void testEveryDeckGroupBelongsToExactlyOneRow() { + CHECK(deckRowFor(kGroupPitch) == DeckRow::Sound); + CHECK(deckRowFor(kGroupFilter) == DeckRow::Sound); + CHECK(deckRowFor(kGroupVelocity) == DeckRow::Sound); + CHECK(deckRowFor(kGroupVoice) == DeckRow::Sound); + CHECK(deckRowFor(kGroupPitchEnv) == DeckRow::Contour); + CHECK(deckRowFor(kGroupFilterEnv) == DeckRow::Contour); + CHECK(deckRowFor(kGroupAmpEnv) == DeckRow::Contour); + CHECK(deckRowFor(kGroupMaster) == DeckRow::Spanning); + + // Totality against the descriptor list the deck actually carries, not just against the + // enum: a group that shipped without a row would land here as a miscount. + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + int sound = 0, contour = 0, spanning = 0; + for (const DeckGroupDesc& d : sampleDeckGroups(mode)) { + switch (deckRowFor(static_cast(d.id))) { + case DeckRow::Sound: ++sound; break; + case DeckRow::Contour: ++contour; break; + case DeckRow::Spanning: ++spanning; break; + } + } + CHECK(sound == 4 && contour == 3 && spanning == 1); + } +} + +// What the budget can already be measured against. The contour row fits today and MASTER has +// not touched its reserve; the SOUND row does not fit yet and must not be forced to — it is +// 1030 against the 1020 block, and the 50 px deficit is exactly what two later descriptor +// changes buy: PITCH becoming PITCH/RATE (+42) and FILTER's Band|Notch moving from the knob +// row to the caption corner (−92), netting 980. The fit is asserted when they land, not here. +static void testTheContourRowAndTheSpanningDeckFitTheBudget() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + int contourWidth = 0, contourGroups = 0, spanningWidth = 0; + for (const DeckGroupDesc& d : g) { + const DeckRow row = deckRowFor(static_cast(d.id)); + if (row == DeckRow::Contour) { + contourWidth += deckGroupWidth(d); + ++contourGroups; + } else if (row == DeckRow::Spanning) { + spanningWidth += deckGroupWidth(d); + } + } + // 252 + 312 + 312. Mode-stable because FILTER ENV's and AMP's reserve slots hold them + // at 312 in Trigger as well as Gate. + CHECK(contourGroups == 3); + CHECK(contourWidth == 876); + CHECK(contourWidth <= kDeckRowBlockW); + // Slack enough that neither of the row's two gutters falls under the minimum. + CHECK(kDeckRowBlockW - contourWidth >= (contourGroups - 1) * kDeckGroupGap); + // MASTER is 72 today against a 142 reserve: the double-height interior it grows into is + // budgeted for, not yet spent. + CHECK(spanningWidth == 72); + CHECK(spanningWidth <= kDeckSpanningW); + } +} + static void testHitTestResolvesTheNewFilterControls() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); @@ -568,15 +644,17 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { // The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned // once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here. -// Gate is the common face and it already packs correctly: pin its group widths and row -// assignment at the floor so a later edit anywhere in the deck cannot reflow it silently. -// (Measured from the shipped descriptors, not copied out of a failing run.) -static void testGateModeWidthsAndRowAssignmentAreUnchanged() { +// Gate is the common face and its group widths are what the width budget is spent against: +// pin them at the floor so a later edit anywhere in the deck cannot move one silently. +// (Measured from the shipped descriptors, not copied out of a failing run.) The WRAP row a +// group lands on is deliberately NOT pinned — that is the interim greedy pack the reflow +// replaces, and deckRowFor is where row membership is asserted. +static void testGateModeGroupWidthsAreUnchanged() { const std::vector g = sampleDeckGroups(PlayMode::Gate); - const struct { int id; int width; int row; } want[] = { - {kGroupPitch, 150, 0}, {kGroupPitchEnv, 252, 0}, {kGroupFilter, 524, 0}, - {kGroupFilterEnv, 312, 1}, {kGroupAmpEnv, 312, 1}, {kGroupVelocity, 192, 1}, - {kGroupVoice, 164, 2}, {kGroupMaster, 72, 2}, + const struct { int id; int width; } want[] = { + {kGroupPitch, 150}, {kGroupPitchEnv, 252}, {kGroupFilter, 524}, + {kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192}, + {kGroupVoice, 164}, {kGroupMaster, 72}, }; CHECK(g.size() == sizeof(want) / sizeof(want[0])); const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); @@ -584,7 +662,10 @@ static void testGateModeWidthsAndRowAssignmentAreUnchanged() { CHECK(dl.groups[i].id == want[i].id); CHECK(deckGroupWidth(g[i]) == want[i].width); CHECK(dl.groups[i].box.width == want[i].width); - CHECK(dl.groups[i].box.y == want[i].row * (kDeckGroupH + kDeckRowGap)); + // Every box lands on a row line, and no lower than the second — the same two-row + // bound the deck height carries. + CHECK(dl.groups[i].box.y % (kDeckGroupH + kDeckRowGap) == 0); + CHECK(dl.groups[i].box.y <= kDeckGroupH + kDeckRowGap); // Gate carries no reserves, so its cells are the deck's base size. for (const DeckCellLayout& c : dl.groups[i].cells) CHECK(c.cell.width == kDeckCellW); } @@ -669,8 +750,11 @@ int main() { testWrappedDeckHeightAtTheEditorFloorWidth(); testDeckFitsInsideTheEnforcedMinimumWindow(); testNoFaceLeavesSlackWhereItsDroppedControlsWere(); - testGateModeWidthsAndRowAssignmentAreUnchanged(); + testGateModeGroupWidthsAreUnchanged(); testGateSplineGateRoundTripsToTheSameLayout(); + testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); + testEveryDeckGroupBelongsToExactlyOneRow(); + testTheContourRowAndTheSpanningDeckFitTheBudget(); testHitTestResolvesTheNewFilterControls(); testBipolarKnobLawRoundTripsAndIsExactAtCentre(); if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); From 69e2f1d3e34ff6ca1f864c1550e30fd3ff99fe1a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:05:45 -0400 Subject: [PATCH 02/56] Promote switch-exhaustiveness diagnostic to an error on pure libraries (MSVC + GCC/Clang) MSVC's C4062 is off by default and GCC/Clang's -Wswitch only warns without -Werror; this repo sets no -Wall/-Werror anywhere. /we4062 and -Werror=switch now cover both, scoped to pure libraries only. --- cmake/reasampler_targets.cmake | 11 +++++++++++ src/core/instrument/ui/deck_groups.cpp | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/cmake/reasampler_targets.cmake b/cmake/reasampler_targets.cmake index 74d9075..dfffd1a 100644 --- a/cmake/reasampler_targets.cmake +++ b/cmake/reasampler_targets.cmake @@ -12,6 +12,17 @@ function(reasampler_pure_library name) if(ARG_LINK) target_link_libraries(${name} ${ARG_LINK}) endif() + # A default-less switch missing an enumerator: MSVC's C4062 is off by its /W1 default; + # GCC/Clang's -Wswitch is on by default but only warns without -Werror, and this repo + # sets no -Wall/-Werror/-W4/-WX anywhere. Promoted to an error only here, on our own + # pure libraries, so a deliberately default-less switch (e.g. isLiveDeckParam, + # deck_groups.cpp) is a compile error on every toolchain. NOT C4061 (fires even with + # a default: present) — that would light up every defensive switch in the tree. + if(MSVC) + target_compile_options(${name} PRIVATE /we4062) + elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(${name} PRIVATE -Werror=switch) + endif() endfunction() # Test naming is exceptionless: target _tests is built from tests/test_.cpp diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 28de21f..1485e13 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -199,9 +199,10 @@ bool isLiveDeckParam(DeckParam id) { case DeckParam::kFilterTrigAttackCurve: case DeckParam::kFilterTrigDecayCurve: return true; - // Listed rather than defaulted so a newly added control is a COMPILE error here (the - // -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level) - // instead of silently defaulting to non-live. Reasons live in the header. + // Listed rather than defaulted so a newly added control is a COMPILE error here on + // every toolchain — /we4062 on MSVC, -Werror=switch on GCC/Clang, both set on this + // library alone in cmake/reasampler_targets.cmake — instead of silently defaulting + // to non-live. Reasons live in the header. case DeckParam::kPlayMode: case DeckParam::kPitchEngine: case DeckParam::kTrigLength: From ae54ca812831fa39a214aa679074b2c726f30067 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:05:48 -0400 Subject: [PATCH 03/56] Re-derive the floor-size test fixtures from the constants and move the ceiling to sample_bands.h --- src/core/instrument/CLAUDE.md | 4 +- src/core/instrument/ui/knob_deck.h | 8 ++-- src/core/instrument/ui/sample_bands.h | 7 ++++ tests/test_keyboard_strip.cpp | 59 ++++++++++++++++---------- tests/test_sample_bands.cpp | 60 ++++++++++++++++++++++++++- 5 files changed, 109 insertions(+), 29 deletions(-) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index d682f10..f135747 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -304,7 +304,7 @@ anything for a trigger shape. ### `ui/` - `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own. -- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack. +- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack. - `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset. - `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means. - `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins. @@ -314,7 +314,7 @@ anything for a trigger shape. - `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search. - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain. -- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, the ceiling, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots. +- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots. - `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups` says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` / diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index 44fd38f..c5c6787 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -55,10 +55,10 @@ inline constexpr int kDeckGroupH = // include edge to this header. inline constexpr int kDeckRowBlockW = 1020; // the block both categorical rows justify inside inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck, outside the block -// The hard ceiling the FLOOR may not exceed; the window itself still grows freely above it. -// The gap between it and kEditorMinWidth is the whole width budget for the life of this -// layout — see instrument-control-surface.md §1.6 before spending any of it. -inline constexpr int kEditorCeilingWidth = 1280; +// The hard ceiling the floor may not exceed lives beside the floor itself, in sample_bands.h's +// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 90px, +// the whole width budget for the life of this layout (asserted in test_deck_groups.cpp) — see +// instrument-control-surface.md §1.6 before spending any of it. // A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent. struct DeckToggleDesc { diff --git a/src/core/instrument/ui/sample_bands.h b/src/core/instrument/ui/sample_bands.h index 564c0ae..410e981 100644 --- a/src/core/instrument/ui/sample_bands.h +++ b/src/core/instrument/ui/sample_bands.h @@ -24,6 +24,13 @@ inline constexpr int kPad = 8; inline constexpr int kEditorMinWidth = 1190; inline constexpr int kEditorMinHeight = 680; +// The hard ceiling the floor above may not exceed; the window itself still grows freely above +// it. A window fact, sibling of kEditorMinWidth/kEditorMinHeight, not a deck one — moved here +// from knob_deck.h for that reason. The gap to the floor (today: 90px) is the deck's whole +// width budget, spent once; the identity is asserted in test_deck_groups.cpp, the one place +// that already includes both this header and knob_deck.h. +inline constexpr int kEditorCeilingWidth = 1280; + // Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip, // preview, velocity knob, channel toggle). sample_chrome partitions it. inline constexpr int kTitleHeight = 26; diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp index d0f4afe..7fa2e36 100644 --- a/tests/test_keyboard_strip.cpp +++ b/tests/test_keyboard_strip.cpp @@ -8,7 +8,8 @@ // octave boundaries and the 0..127 extremes; keyRect tiling and black-over-white overlap; // keyAtPoint resolving black-over-white by zone and missing off-band; the root affordance's // hit-to-marker round trip; resolveDragNote clamping a wandering pointer; noteName under the -// C4 (MIDI 60) DAW convention; and the gutter pinned at the shipped default window size. +// C4 (MIDI 60) DAW convention; the gutter at the sawtooth's maximum residue; and the gutter +// pinned at the shipped default window size (derived from kEditorMinWidth/kEditorMinHeight). // // Client-pixel-only guarantee: every width swept below is a CLIENT-pixel width. Nothing in // the instrument implements IPlugViewContentScaleSupport, so if a host scales the plugin @@ -280,28 +281,18 @@ static void testNoteNamesFollowTheC4Convention() { CHECK(noteName(500) == "G9"); } -// --- the gutter at the shipped default window size ----------------------------- +// --- the gutter at the sawtooth's maximum residue ------------------------------- // The rule-based sweep above pins `margins == w % 75` and `leftMargin == margins/2` at every -// width, but pins no concrete number — Daniel is making a visual call on the specific gutter -// at the shipped default, and neither kPad nor the 840 default is covered by another test -// firing if either ever changes. The strip is a sawtooth with period kStripWhiteKeyCount (75) -// px of window width, and the shipped 840 default lands on residue 74 — the cycle's maximum: -// one pixel of resize (840->841) collapses both gutters to zero and grows every white key -// from 10px to 11px. -static void testGutterAtTheShippedDefaultWindowSize() { - constexpr int kShippedDefaultWindowW = 840; // editor_session.cpp's ViewRect default - constexpr int kShippedDefaultWindowH = 620; // editor_session.cpp's ViewRect default - // Derive rootStrip's width the same way the shell does, through the real allocator + - // chrome layout, rather than re-deriving the inset formula — so a change to either one - // fails this test instead of silently moving the shipped gutter out from under it. - const SampleBands bands = - computeSampleBands(kShippedDefaultWindowW, kShippedDefaultWindowH, 0); - const ChromeRects chrome = chromeRects(bands.chrome, /*knobSize=*/24); - const int stripW = chrome.rootStrip.width; - CHECK(stripW == 824); - - const StripLayout L = layoutStrip(stripW, 30); +// width, but pins no concrete number. The strip is a sawtooth with period kStripWhiteKeyCount +// (75) px of window width; 840 is an arbitrary but independently useful sample because it +// lands on residue 74 — the cycle's maximum: one pixel of resize (840->841) collapses both +// gutters to zero and grows every white key from 10px to 11px. Kept as a synthetic worst-case +// sample; NOT tied to any shipped window size (see testGutterAtTheShippedDefaultWindowSize +// below for that). +static void testGutterAtTheSawtoothMaximumResidueWidth() { + constexpr int kSawtoothMaxResidueStripW = 824; // an arbitrary width landing on residue 74 + const StripLayout L = layoutStrip(kSawtoothMaxResidueStripW, 30); CHECK(L.whiteWidth == 10); const int margins = L.band.width - L.keys.width; CHECK(margins == 74); @@ -309,6 +300,31 @@ static void testGutterAtTheShippedDefaultWindowSize() { CHECK(leftMargin == 37); } +// --- the gutter at the shipped default window size ----------------------------- + +// Daniel is making a visual call on the specific gutter at the shipped default; neither kPad +// nor the floor is covered by another test firing if either ever changes. Derived from +// kEditorMinWidth/kEditorMinHeight (editor_session.cpp's ViewRect default IS the floor) rather +// than a hardcoded window size, so a floor change fails HERE instead of silently moving the +// shipped gutter out from under it. The numbers below are today's floor (1190x680); re-derive +// them by hand if the floor ever moves. +static void testGutterAtTheShippedDefaultWindowSize() { + // Derive rootStrip's width the same way the shell does, through the real allocator + + // chrome layout, rather than re-deriving the inset formula. + const SampleBands bands = computeSampleBands(kEditorMinWidth, kEditorMinHeight, 0); + const ChromeRects chrome = chromeRects(bands.chrome, /*knobSize=*/24); + const int stripW = chrome.rootStrip.width; + CHECK(stripW == kEditorMinWidth - 2 * kPad); + CHECK(stripW == 1174); + + const StripLayout L = layoutStrip(stripW, 30); + CHECK(L.whiteWidth == 15); + const int margins = L.band.width - L.keys.width; + CHECK(margins == 49); + const int leftMargin = L.keys.x - L.band.x; + CHECK(leftMargin == 24); +} + int main() { testLayoutFillsTheBandAndCentresTheKeys(); testDegenerateSizesYieldNoKeys(); @@ -325,6 +341,7 @@ int main() { testHitTestingAKeyMarksThatSameKey(); testDragTracksThePointerAndClampsWhenItWanders(); testNoteNamesFollowTheC4Convention(); + testGutterAtTheSawtoothMaximumResidueWidth(); testGutterAtTheShippedDefaultWindowSize(); if (g_fail == 0) { diff --git a/tests/test_sample_bands.cpp b/tests/test_sample_bands.cpp index 4bb286f..4015d84 100644 --- a/tests/test_sample_bands.cpp +++ b/tests/test_sample_bands.cpp @@ -5,8 +5,10 @@ // three-band vertical inventory (chrome over waveform over decks, no overlap, no // inversion) asserted as pure geometry with no paint call; the waveform band's two-lane // floor and the bands-clip-rather-than-squeeze rule on a short window; the deck band's -// bottom anchor and its exact requested height; and the lane split (mono = one full-band -// lane, stereo = two lanes with the seam gap between them). +// bottom anchor and its exact requested height; the same stack/anchor/degrade properties +// re-anchored AT the allocator's own kEditorMinWidth/kEditorMinHeight floor rather than only +// below it; and the lane split (mono = one full-band lane, stereo = two lanes with the seam +// gap between them). #include "../src/core/instrument/ui/sample_bands.h" @@ -104,6 +106,56 @@ static void testDegenerateWindowYieldsNoInvertedRects() { CHECK(tiny.decks.right() >= tiny.decks.x); } +// --- the allocator at its own floor (kEditorMinWidth x kEditorMinHeight) ------- + +// The allocator's own client-area floor is a fact IT owns (kEditorMinWidth/kEditorMinHeight +// above); the fixtures above validate the general shape entirely below that floor (840x620, +// 840x160). These anchor the same properties AT the floor itself, so a floor move that broke +// the stack there would have nothing else in this file to catch it. + +static void testBandsStackWithoutOverlapAtTheEditorFloor() { + const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, 120); + CHECK(b.chrome.y == 0); + CHECK(b.chrome.width == kEditorMinWidth); + CHECK(b.chrome.height == kTitleHeight + kChromeRowHeight); + CHECK(b.waveform.y >= b.chrome.bottom()); + CHECK(b.decks.y >= b.waveform.bottom()); + CHECK(b.waveform.x == kPad && b.waveform.right() == kEditorMinWidth - kPad); + CHECK(b.decks.x == kPad && b.decks.right() == kEditorMinWidth - kPad); +} + +static void testDeckBandIsBottomAnchoredAtTheEditorFloor() { + constexpr int deckH = 120; + const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, deckH); + CHECK(b.decks.height == deckH); + CHECK(b.decks.bottom() == kEditorMinHeight - kPad); +} + +// At a representative two-row deck height (216px — the ceiling test_deck_groups.cpp bounds +// the wrapped deck to), the waveform gets exactly what the floor's own height leaves it: an +// equality, not a bound, so a floor-height change that quietly ate into the waveform's slack +// would fail here rather than only widen/narrow a `>=`. +static void testWaveformGetsExactlyTheFloorsRemainingHeightAtATwoRowDeck() { + constexpr int twoRowDeckH = 216; + const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, twoRowDeckH); + const int expected = kEditorMinHeight - kPad - twoRowDeckH - 2 * kBandGap - + (kTitleHeight + kChromeRowHeight); + CHECK(b.waveform.height == expected); + CHECK(b.waveform.height == 358); + CHECK(b.waveform.height >= kWaveformMinHeight); +} + +// The floor is not immune to the degrade path: a deck grown too tall for the floor's OWN +// height still hits the floor-wins rule instead of squeezing the waveform below its usable +// minimum — the sub-floor fixtures above cover the general rule; this is the same rule +// exercised at the allocator's own minimum width. +static void testAnOversizedDeckAtTheFloorStillDegradesGracefully() { + const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, 500); + CHECK(b.waveform.height == kWaveformMinHeight); + CHECK(b.decks.y >= b.waveform.bottom()); + CHECK(b.decks.bottom() > kEditorMinHeight); // clipped below the floor's own window +} + // --- the waveform band's lanes ------------------------------------------------ static void testMonoUsesOneFullBandLane() { @@ -149,6 +201,10 @@ int main() { testWaveformNeverShrinksBelowTheTwoLaneFloor(); testTwoLaneFloorHoldsTwoUsableLanes(); testDegenerateWindowYieldsNoInvertedRects(); + testBandsStackWithoutOverlapAtTheEditorFloor(); + testDeckBandIsBottomAnchoredAtTheEditorFloor(); + testWaveformGetsExactlyTheFloorsRemainingHeightAtATwoRowDeck(); + testAnOversizedDeckAtTheFloorStillDegradesGracefully(); testMonoUsesOneFullBandLane(); testStereoSplitsIntoTwoLanesWithTheSeamGap(); testStereoOddRemainderGoesToTheUpperLane(); From 3eb72d01c4ae549439b613ca55f325fadfccedc6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:09:19 -0400 Subject: [PATCH 04/56] One taper, one modifier law: extract param_taper, raise the stage ceiling to 10 s, and make the AHDSR schematic axis the taper itself --- src/core/instrument/CLAUDE.md | 15 +- src/core/instrument/ui/CMakeLists.txt | 21 +- src/core/instrument/ui/deck_values.cpp | 233 +++++++++++++--- src/core/instrument/ui/deck_values.h | 32 ++- src/core/instrument/ui/envelope_edit.cpp | 92 ++++--- src/core/instrument/ui/envelope_edit.h | 9 +- src/core/instrument/ui/envelope_overlay.cpp | 14 +- src/core/instrument/ui/envelope_overlay.h | 19 +- src/core/instrument/ui/param_slider.cpp | 6 +- src/core/instrument/ui/param_slider.h | 12 +- src/core/instrument/ui/param_taper.cpp | 82 ++++++ src/core/instrument/ui/param_taper.h | 84 ++++++ src/shell/instrument/CLAUDE.md | 4 +- src/shell/instrument/editor_input_curve.cpp | 6 +- src/shell/instrument/editor_input_deck.cpp | 17 +- .../instrument/editor_input_waveform.cpp | 23 +- src/shell/instrument/editor_internal.h | 10 + src/shell/instrument/reasampler_editor.h | 6 + tests/test_deck_values.cpp | 172 +++++++++++- tests/test_envelope_edit.cpp | 163 +++++++++-- tests/test_envelope_overlay.cpp | 91 ++++-- tests/test_param_slider.cpp | 30 +- tests/test_param_taper.cpp | 260 ++++++++++++++++++ 23 files changed, 1208 insertions(+), 193 deletions(-) create mode 100644 src/core/instrument/ui/param_taper.cpp create mode 100644 src/core/instrument/ui/param_taper.h create mode 100644 tests/test_param_taper.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 272c14a..cc82441 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -312,15 +312,20 @@ anything for a trigger shape. - **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one. - `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing. - `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search. -- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. +- `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required. +- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain. - `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. **The cell/knob/label sizes and `sample_bands`' editor floor move as a pair** — wider cells need a wider floor width or the deck wraps to a fourth row. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has fourteen pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots. - `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups` says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` / - `setDeckParam` (the normalized ↔ stored-seconds/fraction/position maps and their clamps), - `resetDeckParam` (the double-click reset — the defaults are READ off a default-constructed - `PlaySeconds`, so there is no second table of defaults to drift), and `formatEnvTimeMs`, the + `setDeckParam` (the normalized ↔ stored-seconds/fraction/position binding and its clamps, over + `param_taper`'s maps), `resetDeckParam` (the double-click reset — the defaults are READ off a + default-constructed `PlaySeconds`, so there is no second table of defaults to drift, and the + value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be + "simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit + table, and where each control's full scale enters — a whole DISPLAYED percent is a different + norm step at 0..100 %, 0..200 % and ±100 %), and `formatEnvTimeMs`, the ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so two stage times are comparable at a glance. A display-unit decision only — nothing about the stored representation changes. Links the header-only `play_seconds`, deliberately not @@ -336,7 +341,7 @@ anything for a trigger shape. ## Gotchas -- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample. +- **An AHDSR's overlay x-axis is schematic, not PCM-aligned, and it is not linear in seconds either** — it does NOT line up with the waveform under it, and each of its four equal stage slots is filled by `param_taper`'s own norm, so a node's position within its slot IS its knob's needle position. Two stages therefore cannot be compared by eye at a 10:1 ratio; the ms labels carry the number. Only a sustain-less AHD's x-axis is wall-clock/PCM-aligned and linear. Content-fit auto-scale and a minimum drawn stage width were both considered and REJECTED — the first moves the axis under the hand, the second decouples the drawn position from the value and breaks the drag inverse. - **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent. - **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn. - **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 831144e..6138e9e 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -34,11 +34,17 @@ reasampler_pure_library(browser_scroll LINK PUBLIC capture_browser sample_chrome) reasampler_test(browser_scroll LINK browser_scroll) -reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry) +reasampler_pure_library(param_slider + SOURCES param_slider.cpp + LINK PUBLIC editor_geometry param_taper) reasampler_test(param_slider LINK param_slider) -reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law) -reasampler_test(envelope_overlay LINK envelope_overlay) +reasampler_pure_library(envelope_overlay + SOURCES envelope_overlay.cpp + LINK PUBLIC editor_geometry curve_law param_taper) +# sample_bands is linked for the test only: the tapered-axis legibility assertion is judged at the +# editor's own floor width, read from the allocator rather than copied as a number. +reasampler_test(envelope_overlay LINK envelope_overlay sample_bands) reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay) reasampler_test(envelope_edit LINK envelope_edit) @@ -76,7 +82,7 @@ reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands) # the filter's MorphLaw — an enum, so no filter symbol is linked. reasampler_pure_library(deck_values SOURCES deck_values.cpp - LINK PUBLIC deck_groups play_seconds envelope_overlay) + LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain) reasampler_test(deck_values LINK deck_values) # The bake Hold knob's value domain. Links the ladder alone — it computes no geometry, so it @@ -88,3 +94,10 @@ reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_g # velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and # proving that takes a curve of each domain mapped through the one curveBox. reasampler_test(curve_popup LINK curve_popup velocity_curve) + +# The ONE norm<->value taper and modifier vocabulary every variable control shares. Declared +# last, but it sits at the BOTTOM of this directory's dependency order: param_slider, +# envelope_overlay and deck_values all read it — which is exactly why it could not stay inside +# deck_values, which sits above envelope_overlay. +reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_law) +reasampler_test(param_taper LINK param_taper) diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index 97c58c4..a391fc5 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -3,9 +3,11 @@ #include "core/instrument/ui/deck_values.h" #include +#include #include #include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value) +#include "core/instrument/engine/master_gain.h" // the dB taper the whole-dB snap reads #include "core/util/clamp01.h" #include "core/util/curve_law.h" // the ONE curve-exponent domain @@ -14,12 +16,6 @@ namespace reasampler::instrument::ui { using engine::filter::MorphLaw; using util::clamp01; -namespace { - -double secToNorm(double seconds) { return clamp01(seconds / kEnvTimeMaxSeconds); } -double normToSec(double norm) { return clamp01(norm) * kEnvTimeMaxSeconds; } - -} // namespace double deckParamNorm(DeckParam id, const PlaySeconds& play) { switch (id) { @@ -28,31 +24,32 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) { case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0; case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0; case DeckParam::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; - case DeckParam::kAttack: return secToNorm(play.adsr.attackSeconds); - case DeckParam::kHold: return secToNorm(play.adsr.holdSeconds); - case DeckParam::kDecay: return secToNorm(play.adsr.decaySeconds); + case DeckParam::kAttack: return timeNormFromSeconds(play.adsr.attackSeconds); + case DeckParam::kHold: return timeNormFromSeconds(play.adsr.holdSeconds); + case DeckParam::kDecay: return timeNormFromSeconds(play.adsr.decaySeconds); case DeckParam::kSustain: return clamp01(play.adsr.sustainLevel); - case DeckParam::kRelease: return secToNorm(play.adsr.releaseSeconds); + case DeckParam::kRelease: return timeNormFromSeconds(play.adsr.releaseSeconds); case DeckParam::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve); case DeckParam::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve); case DeckParam::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve); case DeckParam::kTrigLength: return clamp01(play.trigger.lengthFraction); - case DeckParam::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds); + case DeckParam::kTrigAttack: return timeNormFromSeconds(play.trigAhd.attackSeconds); case DeckParam::kTrigHold: return clamp01(play.trigAhd.holdFraction); - case DeckParam::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds); + case DeckParam::kTrigDecay: return timeNormFromSeconds(play.trigAhd.decaySeconds); case DeckParam::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve); case DeckParam::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve); case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 1.0 : 0.0; - case DeckParam::kPitchEnvAttack: return secToNorm(play.pitchEnv.shape.attackSeconds); + case DeckParam::kPitchEnvAttack: + return timeNormFromSeconds(play.pitchEnv.shape.attackSeconds); case DeckParam::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction); - case DeckParam::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds); + case DeckParam::kPitchEnvDecay: + return timeNormFromSeconds(play.pitchEnv.shape.decaySeconds); case DeckParam::kPitchEnvAttackCurve: return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve); case DeckParam::kPitchEnvDecayCurve: return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve); case DeckParam::kPitchEnvDepth: - // Signed depth centred at 0.5 (0.5 == 0 semitones). - return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); + return depthNormFromSemitones(play.pitchEnv.peakSemitones, kPitchDepthMaxSemis); // Filter. The four tone controls ARE the module's normalized positions — stored and // shown as-is, so the knob travel is exactly filter_params' own law. case DeckParam::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0; @@ -65,20 +62,23 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) { case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount); case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount); case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax); - case DeckParam::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds); - case DeckParam::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds); - case DeckParam::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds); + case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds); + case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds); + case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds); case DeckParam::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel); - case DeckParam::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds); + case DeckParam::kFilterEnvRelease: + return timeNormFromSeconds(play.filter.env.releaseSeconds); case DeckParam::kFilterEnvAttackCurve: return util::knobNormFromCurve(play.filter.env.attackCurve); case DeckParam::kFilterEnvDecayCurve: return util::knobNormFromCurve(play.filter.env.decayCurve); case DeckParam::kFilterEnvReleaseCurve: return util::knobNormFromCurve(play.filter.env.releaseCurve); - case DeckParam::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds); + case DeckParam::kFilterTrigAttack: + return timeNormFromSeconds(play.filter.trigEnv.attackSeconds); case DeckParam::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction); - case DeckParam::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds); + case DeckParam::kFilterTrigDecay: + return timeNormFromSeconds(play.filter.trigEnv.decaySeconds); case DeckParam::kFilterTrigAttackCurve: return util::knobNormFromCurve(play.filter.trigEnv.attackCurve); case DeckParam::kFilterTrigDecayCurve: @@ -110,11 +110,11 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { case DeckParam::kPitchEngine: play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; break; - case DeckParam::kAttack: play.adsr.attackSeconds = normToSec(value); break; - case DeckParam::kHold: play.adsr.holdSeconds = normToSec(value); break; - case DeckParam::kDecay: play.adsr.decaySeconds = normToSec(value); break; + case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break; + case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break; + case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break; case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break; - case DeckParam::kRelease: play.adsr.releaseSeconds = normToSec(value); break; + case DeckParam::kRelease: play.adsr.releaseSeconds = timeSecondsFromNorm(value); break; case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break; case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break; case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break; @@ -123,26 +123,26 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { // nothing. play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); break; - case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break; + case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = timeSecondsFromNorm(value); break; case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break; - case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break; + case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = timeSecondsFromNorm(value); break; case DeckParam::kTrigAttackCurve: play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break; case DeckParam::kTrigDecayCurve: play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break; case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break; case DeckParam::kPitchEnvAttack: - play.pitchEnv.shape.attackSeconds = normToSec(value); break; + play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break; case DeckParam::kPitchEnvHold: play.pitchEnv.shape.holdFraction = clamp01(value); break; case DeckParam::kPitchEnvDecay: - play.pitchEnv.shape.decaySeconds = normToSec(value); break; + play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break; case DeckParam::kPitchEnvAttackCurve: play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break; case DeckParam::kPitchEnvDecayCurve: play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break; case DeckParam::kPitchEnvDepth: - play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; + play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); break; case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break; case DeckParam::kFilterLaw: @@ -162,15 +162,15 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { case DeckParam::kFilterKeyTrack: play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break; case DeckParam::kFilterEnvAttack: - play.filter.env.attackSeconds = normToSec(value); break; + play.filter.env.attackSeconds = timeSecondsFromNorm(value); break; case DeckParam::kFilterEnvHold: - play.filter.env.holdSeconds = normToSec(value); break; + play.filter.env.holdSeconds = timeSecondsFromNorm(value); break; case DeckParam::kFilterEnvDecay: - play.filter.env.decaySeconds = normToSec(value); break; + play.filter.env.decaySeconds = timeSecondsFromNorm(value); break; case DeckParam::kFilterEnvSustain: play.filter.env.sustainLevel = clamp01(value); break; case DeckParam::kFilterEnvRelease: - play.filter.env.releaseSeconds = normToSec(value); break; + play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break; case DeckParam::kFilterEnvAttackCurve: play.filter.env.attackCurve = util::curveFromKnobNorm(value); break; case DeckParam::kFilterEnvDecayCurve: @@ -178,11 +178,11 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { case DeckParam::kFilterEnvReleaseCurve: play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break; case DeckParam::kFilterTrigAttack: - play.filter.trigEnv.attackSeconds = normToSec(value); break; + play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break; case DeckParam::kFilterTrigHold: play.filter.trigEnv.holdFraction = clamp01(value); break; case DeckParam::kFilterTrigDecay: - play.filter.trigEnv.decaySeconds = normToSec(value); break; + play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break; case DeckParam::kFilterTrigAttackCurve: play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break; case DeckParam::kFilterTrigDecayCurve: @@ -197,9 +197,164 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { enforceGateUnavailableWhileDrawn(play); } +namespace { + +// The ADDRESS of the one stored field a knob id owns. deckParamNorm and setDeckParam carry each +// id's MAP — which taper, which clamp; this carries only its LOCATION, which is the whole +// mechanism of the taper-free reset. A toggle, radio or curve cell has no reset gesture and +// resolves to null. +double* deckDoubleField(DeckParam id, PlaySeconds& p) { + switch (id) { + case DeckParam::kAttack: return &p.adsr.attackSeconds; + case DeckParam::kHold: return &p.adsr.holdSeconds; + case DeckParam::kDecay: return &p.adsr.decaySeconds; + case DeckParam::kSustain: return &p.adsr.sustainLevel; + case DeckParam::kRelease: return &p.adsr.releaseSeconds; + case DeckParam::kAttackCurve: return &p.adsr.attackCurve; + case DeckParam::kDecayCurve: return &p.adsr.decayCurve; + case DeckParam::kReleaseCurve: return &p.adsr.releaseCurve; + case DeckParam::kTrigLength: return &p.trigger.lengthFraction; + case DeckParam::kTrigAttack: return &p.trigAhd.attackSeconds; + case DeckParam::kTrigHold: return &p.trigAhd.holdFraction; + case DeckParam::kTrigDecay: return &p.trigAhd.decaySeconds; + case DeckParam::kTrigAttackCurve: return &p.trigAhd.attackCurve; + case DeckParam::kTrigDecayCurve: return &p.trigAhd.decayCurve; + case DeckParam::kPitchEnvAttack: return &p.pitchEnv.shape.attackSeconds; + case DeckParam::kPitchEnvHold: return &p.pitchEnv.shape.holdFraction; + case DeckParam::kPitchEnvDecay: return &p.pitchEnv.shape.decaySeconds; + case DeckParam::kPitchEnvAttackCurve: return &p.pitchEnv.shape.attackCurve; + case DeckParam::kPitchEnvDecayCurve: return &p.pitchEnv.shape.decayCurve; + case DeckParam::kPitchEnvDepth: return &p.pitchEnv.peakSemitones; + case DeckParam::kFilterModAmt: return &p.filter.modAmount; + case DeckParam::kFilterVel: return &p.filter.velAmount; + case DeckParam::kFilterKeyTrack: return &p.filter.keyTrack; + case DeckParam::kFilterEnvAttack: return &p.filter.env.attackSeconds; + case DeckParam::kFilterEnvHold: return &p.filter.env.holdSeconds; + case DeckParam::kFilterEnvDecay: return &p.filter.env.decaySeconds; + case DeckParam::kFilterEnvSustain: return &p.filter.env.sustainLevel; + case DeckParam::kFilterEnvRelease: return &p.filter.env.releaseSeconds; + case DeckParam::kFilterEnvAttackCurve: return &p.filter.env.attackCurve; + case DeckParam::kFilterEnvDecayCurve: return &p.filter.env.decayCurve; + case DeckParam::kFilterEnvReleaseCurve: return &p.filter.env.releaseCurve; + case DeckParam::kFilterTrigAttack: return &p.filter.trigEnv.attackSeconds; + case DeckParam::kFilterTrigHold: return &p.filter.trigEnv.holdFraction; + case DeckParam::kFilterTrigDecay: return &p.filter.trigEnv.decaySeconds; + case DeckParam::kFilterTrigAttackCurve: return &p.filter.trigEnv.attackCurve; + case DeckParam::kFilterTrigDecayCurve: return &p.filter.trigEnv.decayCurve; + default: return nullptr; + } +} + +// The filter's four tone controls store their NORMALIZED position, as floats, and their law is +// wire-frozen — hence a second resolver rather than a widened first one. +float* deckFloatField(DeckParam id, PlaySeconds& p) { + switch (id) { + case DeckParam::kFilterMorph: return &p.filter.settings.morphNorm; + case DeckParam::kFilterCutoff: return &p.filter.settings.cutoffNorm; + case DeckParam::kFilterQ: return &p.filter.settings.resonanceNorm; + case DeckParam::kFilterDrive: return &p.filter.settings.driveNorm; + default: return nullptr; + } +} + +} // namespace + void resetDeckParam(DeckParam id, PlaySeconds& play) { - const PlaySeconds defaults; - setDeckParam(id, play, deckParamNorm(id, defaults), 0); + PlaySeconds defaults; + if (double* dst = deckDoubleField(id, play)) { + *dst = *deckDoubleField(id, defaults); + return; + } + if (float* dst = deckFloatField(id, play)) *dst = *deckFloatField(id, defaults); +} + +UnitCategory deckParamUnit(DeckParam id) { + switch (id) { + case DeckParam::kAttack: + case DeckParam::kHold: + case DeckParam::kDecay: + case DeckParam::kRelease: + case DeckParam::kTrigAttack: + case DeckParam::kTrigDecay: + case DeckParam::kPitchEnvAttack: + case DeckParam::kPitchEnvDecay: + case DeckParam::kFilterEnvAttack: + case DeckParam::kFilterEnvHold: + case DeckParam::kFilterEnvDecay: + case DeckParam::kFilterEnvRelease: + case DeckParam::kFilterTrigAttack: + case DeckParam::kFilterTrigDecay: + return UnitCategory::Milliseconds; + case DeckParam::kPitchEnvDepth: + return UnitCategory::Semitones; + // The filter's four tone controls read out in Hz / Q / drive depth but snap in whole + // percent of the normalized position they STORE — display and snap are independent axes. + case DeckParam::kSustain: + case DeckParam::kTrigLength: + case DeckParam::kTrigHold: + case DeckParam::kPitchEnvHold: + case DeckParam::kKeyTrack: + case DeckParam::kFilterKeyTrack: + case DeckParam::kFilterMorph: + case DeckParam::kFilterCutoff: + case DeckParam::kFilterQ: + case DeckParam::kFilterDrive: + case DeckParam::kFilterModAmt: + case DeckParam::kFilterVel: + case DeckParam::kFilterEnvSustain: + case DeckParam::kFilterTrigHold: + return UnitCategory::Percent; + case DeckParam::kAttackCurve: + case DeckParam::kDecayCurve: + case DeckParam::kReleaseCurve: + case DeckParam::kTrigAttackCurve: + case DeckParam::kTrigDecayCurve: + case DeckParam::kPitchEnvAttackCurve: + case DeckParam::kPitchEnvDecayCurve: + case DeckParam::kFilterEnvAttackCurve: + case DeckParam::kFilterEnvDecayCurve: + case DeckParam::kFilterEnvReleaseCurve: + case DeckParam::kFilterTrigAttackCurve: + case DeckParam::kFilterTrigDecayCurve: + return UnitCategory::Exponent; + case DeckParam::kMasterGain: + return UnitCategory::Decibels; + default: + // Toggles, radios, the curve-popup cells, and the already-integer voice count. + return UnitCategory::None; + } +} + +double snapDeckParamNorm(DeckParam id, double norm) { + switch (deckParamUnit(id)) { + case UnitCategory::Milliseconds: + return timeNormFromSeconds(snapSecondsToWholeMs(timeSecondsFromNorm(norm))); + case UnitCategory::Semitones: + return depthNormFromSemitones( + snapSemitonesToWhole(depthSemitonesFromNorm(norm, kPitchDepthMaxSemis)), + kPitchDepthMaxSemis); + case UnitCategory::Exponent: + return util::knobNormFromCurve(snapExponentToWhole(util::curveFromKnobNorm(norm))); + case UnitCategory::Decibels: + return engine::masterGainNormFromDb( + std::nearbyint(engine::masterGainDbFromNorm(norm))); + case UnitCategory::Percent: + switch (id) { + case DeckParam::kFilterModAmt: + case DeckParam::kFilterVel: + return deckNormFromBipolar( + snapFractionToWholePercent(deckBipolarFromNorm(norm))); + case DeckParam::kKeyTrack: + case DeckParam::kFilterKeyTrack: + return clamp01( + snapFractionToWholePercent(clamp01(norm) * kKeyTrackMax) / kKeyTrackMax); + default: + return clamp01(snapFractionToWholePercent(clamp01(norm))); + } + case UnitCategory::None: + break; + } + return norm; } void formatEnvTimeMs(double seconds, char* buf, std::size_t len) { diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index 8512c46..8c23804 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -11,14 +11,15 @@ #include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target) #include "core/instrument/ui/deck_groups.h" // DeckParam #include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds +#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers namespace reasampler::instrument::ui { using map::PlaySeconds; // Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly what the -// parameter set stores. READ from the overlay's schematic scale rather than restated: the AHDSR -// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree. +// parameter set stores. An ALIAS of the overlay's schematic domain, which is itself an alias of +// the taper's; param_taper.h owns why the number has one home. inline constexpr double kEnvTimeMaxSeconds = kGateStageMaxSeconds; // Pitch depth throw: +/-kVelocityPitchRangeSemitones, centred. The one throw the pitch @@ -28,10 +29,11 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones; // Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls. inline constexpr double kKeyTrackMax = 2.0; -// The normalized [0,1] a control shows: seconds over the ceiling, levels and fractions as-is, -// signed depths centred at 0.5, curve exponents over their logarithmic travel. Controls backed -// by per-instance state rather than the parameter set (voice count, master gain, the pitch -// key-track scalar, preview velocity) are not here — the shell reads those from the processor. +// The normalized [0,1] a control shows: stage times through the shared time taper, levels and +// fractions as-is, signed depths through the centre-expanded depth taper, curve exponents over +// their logarithmic travel. Controls backed by per-instance state rather than the parameter set +// (voice count, master gain, the pitch key-track scalar, preview velocity) are not here — the +// shell reads those from the processor. double deckParamNorm(DeckParam id, const PlaySeconds& play); // Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1). @@ -39,13 +41,23 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play); void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment); // Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no -// second table of defaults to drift from the real one. It arrives via the norm round trip, so -// landing EXACTLY on a stage time (0.003 s attack, 0.060 s release) depends on -// kEnvTimeMaxSeconds being a power of two — x/2^n*2^n is lossless, an arbitrary ceiling is not. -// Move that ceiling off a power of two and a reset lands a mantissa bit off its own default. +// second table of defaults to drift from the real one, and the value is COPIED rather than +// round-tripped through norm -> value. That bypass is MANDATORY: a reset must land on the stored +// default bit for bit, and no round trip through a log taper over a non-power-of-two ceiling can +// promise that for every control. Never "simplify" it back into a round trip. // For knob-valued controls — a toggle has no reset gesture. void resetDeckParam(DeckParam id, PlaySeconds& play); +// THE snap-unit table: which whole unit Shift snaps each control to. Includes the deck's +// processor-side ids (voice count, master gain), which have no entry in the two functions above +// because their VALUE lives outside the parameter set — the unit does not. +UnitCategory deckParamUnit(DeckParam id); + +// Applies that snap to a control's normalized value. Snapping happens in the DISPLAYED unit, so +// this is where each control's full scale enters: 0..100 %, 0..200 % and +/-100 % all snap to a +// whole displayed percent and therefore take different norm steps. +double snapDeckParamNorm(DeckParam id, double norm); + // A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in // one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short // attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator. diff --git a/src/core/instrument/ui/envelope_edit.cpp b/src/core/instrument/ui/envelope_edit.cpp index 01cece9..dc5e2f0 100644 --- a/src/core/instrument/ui/envelope_edit.cpp +++ b/src/core/instrument/ui/envelope_edit.cpp @@ -23,11 +23,25 @@ double secondsPerPixel(const Rect& area, double totalSeconds) { return totalSeconds / static_cast(w); } -// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a -// dragged handle tracks the cursor 1:1. -double gateSecondsPerPixel(const Rect& area) { - const double pps = gatePxPerSecond(area); - return pps > 0.0 ? 1.0 / pps : 0.0; +// The exact inverse of gatePolyline's tapered stage placement: a stage's drawn offset inside its +// slot is slot * timeNormFromSeconds(t), so a pixel delta moves the NORM by dx/slot — never the +// seconds by a fixed rate. Reading the same taper the draw does is what makes a dragged handle +// track the cursor at both ends of the range instead of only near the ceiling. +double gateStageFromPixels(double grabSeconds, const Rect& area, double dxPixels) { + const double slot = gateStageSlotPx(area); + if (slot <= 0.0) return grabSeconds; + return timeSecondsFromNorm(timeNormFromSeconds(grabSeconds) + dxPixels / slot); +} + +// Shift's snaps, applied to the resolved param before its clamp so the domain edge always wins. +double snappedSeconds(double seconds, const DragModifiers& m) { + return m.shift ? snapSecondsToWholeMs(seconds) : seconds; +} +double snappedFraction(double fraction, const DragModifiers& m) { + return m.shift ? snapFractionToWholePercent(fraction) : fraction; +} +double snappedExponent(double exponent, const DragModifiers& m) { + return m.shift ? snapExponentToWhole(exponent) : exponent; } // Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]). @@ -94,7 +108,7 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) { // A knot drag: the grab-time mid-level shifted by the pixel delta, read back through // curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law). double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent, - const Rect& area, int dyPixels) { + const Rect& area, double dyPixels) { const SegmentLevels seg = segmentLevels(grabEnv, knot); if (!seg.ok) return grabExponent; const double span = seg.end - seg.start; @@ -104,7 +118,7 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab // level travel — a segment thinner than that is visually a no-op drag anyway. if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent; const double grabLevel = seg.start + span * curveMidLevel(grabExponent); - const double newLevel = grabLevel - static_cast(dyPixels) * levelPerPixel(area); + const double newLevel = grabLevel - dyPixels * levelPerPixel(area); return curveFromMidLevel((newLevel - seg.start) / span); } @@ -150,15 +164,19 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area, double totalSeconds, const EnvClampBounds& bounds, - int dxPixels, int dyPixels) { + int dxPixels, int dyPixels, const DragModifiers& mods) { StageEnvelope out = grabEnv; if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out; const Rect& rect = area.rect; const double secPerPx = secondsPerPixel(rect, totalSeconds); if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion - const double dSec = static_cast(dxPixels) * secPerPx; - const double gateDSec = static_cast(dxPixels) * gateSecondsPerPixel(rect); + // Fine drag scales the PIXEL delta, so it composes with every axis below (the tapered + // schematic, the 1:1 wall clock, the level and the exponent) without a second rule. + const double scale = fineDrag(mods) ? kFineDragScale : 1.0; + const double dx = static_cast(dxPixels) * scale; + const double dy = static_cast(dyPixels) * scale; + const double dSec = dx * secPerPx; if (grabEnv.kind == EnvKind::Ahdsr) { switch (node) { @@ -166,38 +184,43 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const // ARE the monotonic-in-time guarantee (a segment can never go negative, so a node // can never cross a neighbour) — the [0, max] clamp is the whole constraint. case EnvNode::AttackEnd: - out.attackSeconds = - std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); + out.attackSeconds = std::clamp( + snappedSeconds(gateStageFromPixels(grabEnv.attackSeconds, rect, dx), mods), 0.0, + bounds.maxAttackSeconds); break; case EnvNode::HoldEnd: - out.holdSeconds = - std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); + out.holdSeconds = std::clamp( + snappedSeconds(gateStageFromPixels(grabEnv.holdSeconds, rect, dx), mods), 0.0, + bounds.maxHoldSeconds); break; case EnvNode::DecayEnd: { // X sets decay time, Y sets sustain level (drag down = higher y = lower level). - out.decaySeconds = - std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); - const double dLevel = -static_cast(dyPixels) * levelPerPixel(rect); - out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0); + out.decaySeconds = std::clamp( + snappedSeconds(gateStageFromPixels(grabEnv.decaySeconds, rect, dx), mods), 0.0, + bounds.maxDecaySeconds); + const double dLevel = -dy * levelPerPixel(rect); + out.sustainLevel = + std::clamp(snappedFraction(grabEnv.sustainLevel + dLevel, mods), 0.0, 1.0); break; } case EnvNode::ReleaseStart: // The release runs from this node to the anchored right edge, so dragging LEFT // (negative dx) lengthens it — the delta enters with the opposite sign. - out.releaseSeconds = - std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds); + out.releaseSeconds = std::clamp( + snappedSeconds(gateStageFromPixels(grabEnv.releaseSeconds, rect, -dx), mods), 0.0, + bounds.maxReleaseSeconds); break; case EnvNode::AttackCurve: - out.attackCurve = - curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels); + out.attackCurve = snappedExponent( + curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dy), mods); break; case EnvNode::DecayCurve: - out.decayCurve = - curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels); + out.decayCurve = snappedExponent( + curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dy), mods); break; case EnvNode::ReleaseCurve: - out.releaseCurve = - curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels); + out.releaseCurve = snappedExponent( + curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dy), mods); break; default: break; @@ -209,8 +232,8 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const const AhdSplit s = splitAhdSeconds(grabEnv); switch (node) { case EnvNode::AttackEnd: - out.attackSeconds = - std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds); + out.attackSeconds = std::clamp(snappedSeconds(grabEnv.attackSeconds + dSec, mods), 0.0, + bounds.maxAttackSeconds); break; case EnvNode::HoldEnd: { // Hold is a fraction of what attack and decay left, so the node's pixel motion @@ -218,7 +241,7 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const // nothing the drag could express. const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay; if (rem <= 0.0) break; - out.holdFraction = clamp01((s.hold + dSec) / rem); + out.holdFraction = clamp01(snappedFraction((s.hold + dSec) / rem, mods)); break; } case EnvNode::DecayEnd: { @@ -231,17 +254,18 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const // unchanged rather than divided by zero. const double denom = 1.0 - clamp01(grabEnv.holdFraction); if (denom > 1e-9) { - out.decaySeconds = - std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds); + out.decaySeconds = std::clamp(snappedSeconds(grabEnv.decaySeconds + dSec / denom, mods), + 0.0, bounds.maxDecaySeconds); } break; } case EnvNode::AttackCurve: - out.attackCurve = - curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels); + out.attackCurve = snappedExponent( + curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dy), mods); break; case EnvNode::DecayCurve: - out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels); + out.decayCurve = snappedExponent( + curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dy), mods); break; default: break; diff --git a/src/core/instrument/ui/envelope_edit.h b/src/core/instrument/ui/envelope_edit.h index b36bd05..61f15b1 100644 --- a/src/core/instrument/ui/envelope_edit.h +++ b/src/core/instrument/ui/envelope_edit.h @@ -51,15 +51,18 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to // Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time // (the shell snapshots it on button-down so the delta is absolute, not accumulated); // `dxPixels`/`dyPixels` is the pixel delta since grab. -// * X delta -> the node's time param, at the same scale the forward map drew it, clamped to -// [0, per-param max]. +// * X delta -> the node's time param, through the same map the forward draw used — the tapered +// slot on an AHDSR, 1:1 wall clock on an AHD — clamped to [0, per-param max]. // * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve // exponent. Ignored for time-only nodes. +// * `mods` carries the shared interaction law (param_taper.h): Ctrl scales the pixel delta, +// Shift snaps the resolved param to a whole unit of its own category before the clamp. The +// shell RE-ANCHORS on every modifier transition, so `mods` is constant across one delta. // * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns // `grabEnv` unchanged. // Only the dragged node's param(s) change. Pure. StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area, double totalSeconds, const EnvClampBounds& bounds, - int dxPixels, int dyPixels); + int dxPixels, int dyPixels, const DragModifiers& mods = {}); } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/envelope_overlay.cpp b/src/core/instrument/ui/envelope_overlay.cpp index a611249..4648aa8 100644 --- a/src/core/instrument/ui/envelope_overlay.cpp +++ b/src/core/instrument/ui/envelope_overlay.cpp @@ -23,7 +23,7 @@ int timeToX(const Rect& area, double totalSeconds, double t) { return area.x + static_cast(px + 0.5); } -double gatePxPerSecond(const Rect& area) { +double gateStageSlotPx(const Rect& area) { const int w = std::max(0, area.width); if (w <= 0) return 0.0; // The four timed stages share the canvas minus their four separation bases and the last @@ -31,7 +31,7 @@ double gatePxPerSecond(const Rect& area) { // puts the plateau's end one separation short of the right edge rather than a fixed // fraction of the way across. const double usable = std::max(1.0, static_cast(w - 1 - 4 * kGateNodeSepPx)); - return usable / (4.0 * kGateStageMaxSeconds); + return usable / 4.0; } int levelToY(const Rect& area, double level) { @@ -114,17 +114,17 @@ std::vector gatePolyline(const StageEnvelope& env, const Rect& area) const int W = std::max(1, area.width); const double sep = static_cast(kGateNodeSepPx); - const double pps = gatePxPerSecond(area); + const double slot = gateStageSlotPx(area); const double xMax = static_cast(W - 1); // The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart — // the sustain->release join, and the node the user drags — sits a release-length to its // left. Everything the release does not take is the sustain plateau, so a zero release // leaves the plateau running to within one separation of the edge. - double xAttack = sep + a * pps; - double xHold = xAttack + sep + h * pps; - double xDecay = xHold + sep + d * pps; - double xPlateau = xMax - sep - r * pps; + double xAttack = sep + slot * timeNormFromSeconds(a); + double xHold = xAttack + sep + slot * timeNormFromSeconds(h); + double xDecay = xHold + sep + slot * timeNormFromSeconds(d); + double xPlateau = xMax - sep - slot * timeNormFromSeconds(r); const double xRelease = xMax; // Keep every node separated when the four stages together would overrun the canvas: the diff --git a/src/core/instrument/ui/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h index 474784e..2cc1f0d 100644 --- a/src/core/instrument/ui/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -9,6 +9,7 @@ #include #include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom +#include "core/instrument/ui/param_taper.h" // kStageTimeMaxSeconds + the stage-time taper #include "core/util/curve_law.h" // the ONE per-segment curve law namespace reasampler::instrument::ui { @@ -80,14 +81,18 @@ struct EnvVertex { inline constexpr int kGateNodeSepPx = 8; // The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each -// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at -// the canvas edge (at which point the sustain plateau has shrunk to nothing). -inline constexpr double kGateStageMaxSeconds = 2.0; +// span at most this. An ALIAS of the taper's own domain end, not a second constant: a maxed knob +// lands exactly at the canvas edge (at which point the sustain plateau has shrunk to nothing) +// only while the two agree. +inline constexpr double kGateStageMaxSeconds = kStageTimeMaxSeconds; -// Pixels per second of the AHDSR schematic, independent of the sample's actual duration. -// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks -// the cursor 1:1. -double gatePxPerSecond(const Rect& area); +// Width of ONE of the AHDSR schematic's four equal stage slots, independent of the sample's +// actual duration. A stage of `t` seconds fills slot * timeNormFromSeconds(t) pixels of it — the +// axis IS the knob's taper, so a node's position within its slot is that knob's needle position +// drawn a second way. That is what keeps a 3 ms attack legible at a 10 s ceiling (linear in +// seconds put it under a pixel) and what lets envelope_edit's inverse stay the EXACT inverse of +// this draw. Only the AHDSR schematic is tapered; an AHD stays 1:1 wall-clock. +double gateStageSlotPx(const Rect& area); // Maps a staged envelope to polyline vertices inside `area` over a sample of `totalSeconds` // duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the diff --git a/src/core/instrument/ui/param_slider.cpp b/src/core/instrument/ui/param_slider.cpp index 0c50889..feb1ce5 100644 --- a/src/core/instrument/ui/param_slider.cpp +++ b/src/core/instrument/ui/param_slider.cpp @@ -135,11 +135,13 @@ KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double v knob.centerY - knob.radius * std::cos(rad)}; } -double knobDragValue(double startValue, int dyPixels, int dragRangePixels) { +double knobDragValue(double startValue, int dyPixels, const DragModifiers& mods, + int dragRangePixels) { const double start = clamp01(startValue); if (dragRangePixels <= 0) return start; + const double dy = static_cast(dyPixels) * (fineDrag(mods) ? kFineDragScale : 1.0); // Screen y grows downward: an upward drag (negative dy) increases the value. - return clamp01(start - static_cast(dyPixels) / dragRangePixels); + return clamp01(start - dy / dragRangePixels); } int controlAtPoint(const std::vector& rows, int x, int y) { diff --git a/src/core/instrument/ui/param_slider.h b/src/core/instrument/ui/param_slider.h index df9e14d..9652989 100644 --- a/src/core/instrument/ui/param_slider.h +++ b/src/core/instrument/ui/param_slider.h @@ -15,6 +15,7 @@ #include #include "core/instrument/ui/editor_geometry.h" // Rect, contains +#include "core/instrument/ui/param_taper.h" // DragModifiers (the shared interaction law) namespace reasampler::instrument::ui { @@ -129,8 +130,15 @@ KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double v // Maps a vertical drag onto a knob value: `startValue` is the value at drag start, // `dyPixels` the pointer's y displacement (down = positive). Up increases, down -// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range. -double knobDragValue(double startValue, int dyPixels, +// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range. Ctrl in +// `mods` scales the rate (param_taper.h); Shift's snap is NOT applied here — the whole +// unit it snaps to is a property of the control's unit category, which this module does +// not know, so the caller applies it to the returned norm. +// +// The drag is grab-anchored ABSOLUTE, which is why the caller must re-anchor `startValue` +// and its own grab y on every modifier transition: rescaling an accumulated delta in place +// would jump the value by (1 - kFineDragScale) x whatever had accumulated. +double knobDragValue(double startValue, int dyPixels, const DragModifiers& mods = {}, int dragRangePixels = kKnobDragRangePixels); // Control a point lands on, given laid-out `rows`. Returns the control id whose diff --git a/src/core/instrument/ui/param_taper.cpp b/src/core/instrument/ui/param_taper.cpp new file mode 100644 index 0000000..647d589 --- /dev/null +++ b/src/core/instrument/ui/param_taper.cpp @@ -0,0 +1,82 @@ +// param_taper.cpp — see param_taper.h. Pure value math; no host types. + +#include "core/instrument/ui/param_taper.h" + +#include + +namespace reasampler::instrument::ui { + +namespace { + +// The output quanta (header: EXACT PREIMAGE). Powers of TEN on purpose: nearbyint(v*S)/S is the +// correctly-rounded double of k/S, which is the same double a decimal literal of k/S parses to — +// so a default written as 0.003 or 0.060 lands on the grid exactly. A power-of-two quantum would +// not have that property against decimal literals. +constexpr double kSecondsPerQuantum = 1e9; // 1 ns +constexpr double kSemitonesPerQuantum = 1e6; // 1 micro-semitone + +double resolveTo(double value, double perUnit) { return std::nearbyint(value * perUnit) / perUnit; } + +// The shifted-log offsets. Both are FITTED AGAINST THE CEILING above them, which is why the +// ceiling could not be raised in a later track: doing the two apart means fitting twice. +constexpr double kTimeOffsetSeconds = 0.003; // -> 10 ms at 0.181, 100 ms at 0.436 +constexpr double kDepthOffsetSemitones = 3.0; // -> +/-7 st at 0.548 of each half-travel + +double timeSpan() { return std::log1p(kStageTimeMaxSeconds / kTimeOffsetSeconds); } + +double depthSpan(double maxSemitones) { + return std::log1p(maxSemitones / kDepthOffsetSemitones); +} + +} // namespace + +double timeNormFromSeconds(double seconds) { + if (!(seconds > 0.0)) return 0.0; // also catches NaN + if (seconds >= kStageTimeMaxSeconds) return 1.0; + return std::log1p(seconds / kTimeOffsetSeconds) / timeSpan(); +} + +double timeSecondsFromNorm(double norm) { + if (!(norm > 0.0)) return 0.0; + if (norm >= 1.0) return kStageTimeMaxSeconds; + return resolveTo(kTimeOffsetSeconds * std::expm1(norm * timeSpan()), kSecondsPerQuantum); +} + +double depthNormFromSemitones(double semitones, double maxSemitones) { + if (!(maxSemitones > 0.0)) return 0.5; + if (semitones == 0.0) return 0.5; // the centre is EXACT, so a knob parked there persists + const double mag = std::fabs(semitones); // no depth at all + if (!(mag < maxSemitones)) return semitones > 0.0 ? 1.0 : 0.0; + const double u = std::log1p(mag / kDepthOffsetSemitones) / depthSpan(maxSemitones); + return semitones > 0.0 ? 0.5 + 0.5 * u : 0.5 - 0.5 * u; +} + +double depthSemitonesFromNorm(double norm, double maxSemitones) { + if (!(maxSemitones > 0.0)) return 0.0; + if (!(norm > 0.0)) return -maxSemitones; // also catches NaN + if (norm >= 1.0) return maxSemitones; + if (norm == 0.5) return 0.0; + const double u = std::fabs(norm - 0.5) * 2.0; + const double mag = resolveTo(kDepthOffsetSemitones * std::expm1(u * depthSpan(maxSemitones)), + kSemitonesPerQuantum); + return norm > 0.5 ? mag : -mag; +} + +double snapSecondsToWholeMs(double seconds) { + if (!(seconds > 0.0)) return 0.0; + return std::nearbyint(seconds * 1000.0) / 1000.0; +} + +double snapFractionToWholePercent(double fraction) { + return std::nearbyint(fraction * 100.0) / 100.0; +} + +double snapSemitonesToWhole(double semitones) { return std::nearbyint(semitones); } + +// Rounding lands on 1..10; anything under half a unit clamps to the domain floor rather than to +// zero, which is not an exponent. 1.0, the linear neutral, is therefore one snap from centre. +double snapExponentToWhole(double exponent) { + return util::clampCurve(std::nearbyint(util::clampCurve(exponent))); +} + +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/param_taper.h b/src/core/instrument/ui/param_taper.h new file mode 100644 index 0000000..248e3fe --- /dev/null +++ b/src/core/instrument/ui/param_taper.h @@ -0,0 +1,84 @@ +// param_taper.h — THE norm <-> value tapers every variable control shares, plus the modifier +// vocabulary its drag surfaces read. Extracted from deck_values because three consumers in two +// dependency layers read it — the knob's needle, the AHDSR overlay's schematic axis and its drag +// inverse, and the VST3 host's normalization — and three functions that agree today is a defect. + +#pragma once + +#include "core/util/curve_law.h" // the exponent domain the whole-number snap clamps into + +namespace reasampler::instrument::ui { + +// --- the interaction law's modifiers ------------------------------------------------------- + +// Ctrl divides the drag rate by 20. Shift+Ctrl is SHIFT, Ctrl ignored: with the output quantized +// to whole units a finer drag yields the same sequence of values, so that is an identity rather +// than a compromise — do not "fix" it into a compounded scale. +inline constexpr double kFineDragScale = 0.05; + +struct DragModifiers { + bool shift = false; // snap to whole units of the control's displayed unit + bool ctrl = false; // fine drag + + bool operator==(const DragModifiers& o) const { return shift == o.shift && ctrl == o.ctrl; } + bool operator!=(const DragModifiers& o) const { return !(*this == o); } +}; + +inline bool fineDrag(const DragModifiers& m) { return m.ctrl && !m.shift; } + +// Which whole unit Shift snaps a control to. Derived from the control's UNIT rather than from a +// per-widget list, so a control added later inherits the law by naming its category. +enum class UnitCategory { + None, // discrete, already-integer, or non-scalar controls — Shift changes nothing + Milliseconds, + Semitones, + Percent, + Exponent, + Decibels, +}; + +// --- the two tapers ------------------------------------------------------------------------ +// +// EXACT PREIMAGE, and why it is structural rather than lucky. A host's reset-to-default arrives +// as toPlain(defaultNorm) with no editor-side bypass available, so every default must satisfy +// toPlain(toNormalized(d)) == d BITWISE. No transcendental map delivers that at an arbitrary +// interior point — the image of toPlain is sparser there than the doubles around it — so both +// maps below resolve their output onto a fixed decimal quantum. That turns the guarantee into +// "every value on the quantum grid round-trips exactly" instead of a libm coincidence that a +// compiler upgrade could take away. Both quanta sit four or more orders below the finest +// reachable drag step, so nothing observable is quantized. The converse, +// toNormalized(toPlain(n)) == n at arbitrary n, is NOT required and must not be demanded: no log +// map satisfies it in double, and requiring it would rule out the shape the range needs. + +// The stage-time domain's upper end — the value at norm 1, and the one home of that number: +// envelope_overlay's kGateStageMaxSeconds and deck_values' kEnvTimeMaxSeconds are both aliases +// of it, so the schematic's canvas edge and the knob's ceiling cannot drift apart. Once the +// instrument reports VST3 parameters this endpoint is a frozen host normalization — moving it +// re-interprets every automation point already recorded in projects we do not own. +inline constexpr double kStageTimeMaxSeconds = 10.0; + +// Shifted-log: exactly 0 s at norm 0, exactly kStageTimeMaxSeconds at norm 1, monotone +// throughout, low end expanded so 10 ms sits at ~0.18 of the travel and 100 ms at ~0.44. A pure +// log cannot include zero and zero is a required value, which is what the offset buys. +double timeNormFromSeconds(double seconds); +double timeSecondsFromNorm(double norm); + +// Signed depth, symmetric about norm 0.5: exactly 0 semitones at centre, exactly +/-maxSemitones +// at the ends, monotone, with the musically useful middle expanded so +/-7 st reaches ~0.55 of +// each half-travel. The throw is a PARAMETER — the +/-24 st depth constant has its own home in +// the engine's value layer and is not restated here. +double depthNormFromSemitones(double semitones, double maxSemitones); +double depthSemitonesFromNorm(double norm, double maxSemitones); + +// --- Shift's whole-unit snaps, in the VALUE domain ------------------------------------------- +// +// Stated over values rather than norms because "whole unit" means whole unit of what the control +// DISPLAYS: two controls sharing a category can have different full scales, so the norm step is +// the caller's business and the unit is this module's. + +double snapSecondsToWholeMs(double seconds); +double snapFractionToWholePercent(double fraction); // 1.0 == 100 % +double snapSemitonesToWhole(double semitones); +double snapExponentToWhole(double exponent); // clamped into curve_law's own domain + +} // namespace reasampler::instrument::ui diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 555529b..f9d6ed3 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -10,7 +10,7 @@ two small identity/helper headers this directory owns outright The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`, `sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, -`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, +`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, `deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and `core/wire` and is documented there — this directory consumes it but does not own it. @@ -110,7 +110,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. - `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. -- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. +- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)* ## Gotchas diff --git a/src/shell/instrument/editor_input_curve.cpp b/src/shell/instrument/editor_input_curve.cpp index 02908fa..31ee47c 100644 --- a/src/shell/instrument/editor_input_curve.cpp +++ b/src/shell/instrument/editor_input_curve.cpp @@ -47,9 +47,9 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) { // Alt-click delete is retired (the spec's right-click supersedes it — one grammar, no // migration on either side): every gesture here routes through the shared resolver. - const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0; - const SplineEdit edit = resolveSplineEdit( - editedCurve(), box, ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft, x, y); + const SplineGesture gesture = + dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft; + const SplineEdit edit = resolveSplineEdit(editedCurve(), box, gesture, x, y); if (edit.kind == SplineEditKind::kToggleHard) { if (editedCurve().toggleHard(static_cast(edit.index))) commitAndReload(); return; diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index ae487c6..cbdc930 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -6,6 +6,7 @@ #ifdef _WIN32 +#include "core/instrument/ui/deck_values.h" // snapDeckParamNorm (Shift's whole-unit table) #include "core/instrument/ui/knob_deck.h" // hitTestDeck / layoutDeck #include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag) #include "shell/instrument/editor_internal.h" @@ -97,6 +98,7 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { dragParamId_ = inner ? static_cast(curve) : hit.id; dragInnerCellId_ = inner ? hit.id : -1; dragKnobStartValue_ = deckControlNorm(dragParamId_); + dragMods_ = dragModifiers(); // Processor-side knobs (voice count / master gain) are transient live writes with no // parameter-set mutation, so they need no rollback snapshot. dragStartParams_ = params_; @@ -150,7 +152,20 @@ void ReaSamplerEditor::dragDeck(int x, int y) { // value at grab (up = increase), so the value tracks relative motion and never jumps on // grab. Live feedback; parameter-set commits land on WM_LBUTTONUP. (void)x; - applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_)); + const DragModifiers mods = dragModifiers(); + if (mods != dragMods_) { + // Re-anchor (see dragMods_). Reading the anchor back off the control also means a Shift + // RELEASE re-anchors from the SNAPPED value, so the knob does not spring back. + dragKnobStartValue_ = deckControlNorm(dragParamId_); + dragStartY_ = y; + dragMods_ = mods; + } + double norm = knobDragValue(dragKnobStartValue_, y - dragStartY_, mods); + // The preview-velocity sentinel (-2) and the discrete controls have no whole unit to snap to. + if (mods.shift && dragParamId_ >= 0) { + norm = snapDeckParamNorm(static_cast(dragParamId_), norm); + } + applyDeckKnob(dragParamId_, norm); // A live control is delivered on every move, not only on release — that is the whole // point: the note already sounding tracks the hand on the knob. if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive(); diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 2de7461..01d2673 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -35,9 +35,8 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { const DeckEnableState gates = deckEnableState(); const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates); - const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0 - ? SplineGesture::kControlLeft - : SplineGesture::kLeft; + const SplineGesture gesture = + dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft; // The staged envelope's draggable node and the drawn contour's node are mutually exclusive // (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode), @@ -107,6 +106,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { envNode_ = envNodeHit.node; dragStartX_ = x; dragStartY_ = y; + dragMods_ = dragModifiers(); dragStartEnv_ = env; dragSampleFrames_ = frames; dragStartParams_ = params_; @@ -213,9 +213,20 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { const double rate = liveSampleRate(); if (frames <= 0 || rate <= 0.0) return; const double totalSeconds = static_cast(frames) / rate; - const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay, - totalSeconds, envClampBounds(), dx, - y - dragStartY_); + const DragModifiers mods = dragModifiers(); + if (mods != dragMods_) { + // Re-anchor (see dragMods_): the node's CURRENT params and the cursor's current + // position become the origin, so the flip changes only the rate. Re-packing the + // grab envelope is what makes that true for the delta this resolver measures. + dragStartEnv_ = + packEnvelope(overlayEnv_, params_.play, frames, params_.startPoint.value_or(0)); + dragStartX_ = x; + dragStartY_ = y; + dragMods_ = mods; + } + const StageEnvelope edited = + resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds, envClampBounds(), + x - dragStartX_, y - dragStartY_, mods); unpackEnvelope(overlayEnv_, edited, params_.play); if (dragCommitsLive(DragKind::kEnvNode)) commitLive(); invalidate(); // live feedback; commit on WM_LBUTTONUP diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h index 814353c..d6caa15 100644 --- a/src/shell/instrument/editor_internal.h +++ b/src/shell/instrument/editor_internal.h @@ -15,6 +15,7 @@ #include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel) #include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type) #include "core/instrument/ui/keyboard_strip.h" // noteName (the one note-naming source) +#include "core/instrument/ui/param_taper.h" // DragModifiers (the shared interaction law) #ifdef _WIN32 #include "wdltypes.h" @@ -70,6 +71,15 @@ inline std::string sampleLabel(const std::vector& #ifdef _WIN32 +// THE modifier read, for every drag surface and every gesture resolver. One helper so the editor +// cannot grow a second modifier grammar. GetKeyState rather than WM_MOUSEMOVE's wParam because a +// modifier can be pressed or released with the mouse standing still, and the re-anchor has to see +// that on the next move it does get. +inline instrument::ui::DragModifiers dragModifiers() { + return instrument::ui::DragModifiers{(GetKeyState(VK_SHIFT) & 0x8000) != 0, + (GetKeyState(VK_CONTROL) & 0x8000) != 0}; +} + // The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against // `KitBox` (component_geometry). This is the single boundary that bridges them so every // draw routes through the shared kit (theme roles + draw_kit). diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 635068e..df050eb 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -519,6 +519,12 @@ private: // delta from this anchor, so a grab never jumps the value. double dragKnobStartValue_ = 0.0; + // The modifier state the in-flight drag is anchored to. Every transition of it — press OR + // release — RE-ANCHORS the drag: current value and current cursor become the new origin, so + // the value is continuous across the flip and only the rate changes. Without that, rescaling + // an accumulated absolute delta in place jumps by (1 - kFineDragScale) x the accumulation. + instrument::ui::DragModifiers dragMods_{}; + // Which velocity curve the popup is editing; kNone = closed. Never persisted. Every writer // of kNone must also cancel a live curve-node drag (closeCurvePopup does both) — an Esc // mid-drag that closed the popup without cancelling the drag used to leave editedCurve()'s diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index 1a0963f..fc85595 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -5,6 +5,9 @@ #include "../src/core/instrument/ui/deck_values.h" +#include "../src/core/instrument/engine/master_gain.h" + +#include #include #include #include @@ -22,13 +25,31 @@ static std::string msLabel(double seconds) { return std::string(buf); } -// Every domain the binding maps: a stage time over the seconds ceiling, a level, a fraction, +// The stage-time ceiling has TWO names — the overlay's schematic domain and the knob's — and they +// must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed. +static void testTheTwoCeilingNamesAreOneNumber() { + CHECK(kEnvTimeMaxSeconds == kGateStageMaxSeconds); + CHECK(kEnvTimeMaxSeconds == kStageTimeMaxSeconds); + CHECK(kEnvTimeMaxSeconds == 10.0); +} + +// Every domain the binding maps: a stage time through the shared taper, a level, a fraction, // a normalized filter position, a bipolar depth, and a curve exponent over its log travel. static void testNormRoundTripsThroughEveryValueDomain() { PlaySeconds p; setDeckParam(DeckParam::kAttack, p, 0.25, 0); - CHECK(p.adsr.attackSeconds == 0.25 * kEnvTimeMaxSeconds); - CHECK(deckParamNorm(DeckParam::kAttack, p) == 0.25); + CHECK(p.adsr.attackSeconds == timeSecondsFromNorm(0.25)); + // The VALUE round trip is what has to be exact (param_taper.h); the needle returning to the + // very same norm double is explicitly NOT required of a log map. The residual is bounded by + // the taper's output quantum read back through the map — under 1e-7 of the travel across the + // whole domain, which is four orders below one drag pixel. + CHECK(std::fabs(deckParamNorm(DeckParam::kAttack, p) - 0.25) < 1e-7); + // The raised ceiling costs the low end nothing: a several-second stage is reachable by hand, + // AND everything under 100 ms still gets more than 40 % of the knob's travel to itself. + setDeckParam(DeckParam::kDecay, p, 0.95, 0); + CHECK(p.adsr.decaySeconds > 5.0 && p.adsr.decaySeconds < kEnvTimeMaxSeconds); + setDeckParam(DeckParam::kDecay, p, 0.42, 0); + CHECK(p.adsr.decaySeconds < 0.100); setDeckParam(DeckParam::kSustain, p, 0.4, 0); CHECK(p.adsr.sustainLevel == 0.4); @@ -122,9 +143,8 @@ static void testInnerResetLandsOnTheExactLinearNeutral() { } // A reset lands on the field's own stored default, EXACTLY — the defaults are read off a fresh -// PlaySeconds and arrive through the norm round trip, so the two stage times whose defaults are -// neither 0 nor 1 are the cases that actually exercise that exactness (see resetDeckParam's -// note on what the seconds ceiling has to be for it to hold). +// PlaySeconds and COPIED rather than round-tripped, which is what makes the two stage times whose +// defaults are neither 0 nor 1 land bit for bit at a non-power-of-two ceiling. static void testResetLandsOnTheStoredDefaultOfEachControl() { const PlaySeconds defaults; PlaySeconds p; @@ -152,6 +172,138 @@ static void testResetLandsOnTheStoredDefaultOfEachControl() { CHECK(p.adsr.releaseSeconds == defaults.adsr.releaseSeconds); } +// EVERY knob resets to its own stored default, not just the six dual-ring pairs above. Swept +// over the whole control-id space so a control added later cannot quietly miss the reset table: +// perturb, reset, and require the control to read exactly what a fresh PlaySeconds reads. +static void testEveryKnobIdResetsToItsDefault() { + const PlaySeconds defaults; + for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { + const DeckParam id = static_cast(i); + if (deckParamUnit(id) == UnitCategory::None) continue; // no reset gesture + if (id == DeckParam::kMasterGain || id == DeckParam::kKeyTrack) continue; // not in PlaySeconds + PlaySeconds p; + setDeckParam(id, p, 0.37, 0); + setDeckParam(id, p, 0.83, 0); // two writes: one of the two is off every default + CHECK(deckParamNorm(id, p) != deckParamNorm(id, defaults)); + resetDeckParam(id, p); + CHECK(deckParamNorm(id, p) == deckParamNorm(id, defaults)); + } +} + +// THE exact-preimage criterion, per unit category, against a default-constructed PlaySeconds and +// against master gain's unity. A host's reset-to-default arrives as toPlain(defaultNorm) with no +// bypass available, so this is the assertion the reset bypass CANNOT stand in for. +static void testEveryDefaultHasAnExactNormalizedPreimage() { + const PlaySeconds d; + const struct { DeckParam id; double stored; } msKnobs[] = { + {DeckParam::kAttack, d.adsr.attackSeconds}, + {DeckParam::kHold, d.adsr.holdSeconds}, + {DeckParam::kDecay, d.adsr.decaySeconds}, + {DeckParam::kRelease, d.adsr.releaseSeconds}, + {DeckParam::kTrigAttack, d.trigAhd.attackSeconds}, + {DeckParam::kTrigDecay, d.trigAhd.decaySeconds}, + {DeckParam::kPitchEnvAttack, d.pitchEnv.shape.attackSeconds}, + {DeckParam::kPitchEnvDecay, d.pitchEnv.shape.decaySeconds}, + {DeckParam::kFilterEnvAttack, d.filter.env.attackSeconds}, + {DeckParam::kFilterEnvHold, d.filter.env.holdSeconds}, + {DeckParam::kFilterEnvDecay, d.filter.env.decaySeconds}, + {DeckParam::kFilterEnvRelease, d.filter.env.releaseSeconds}, + {DeckParam::kFilterTrigAttack, d.filter.trigEnv.attackSeconds}, + {DeckParam::kFilterTrigDecay, d.filter.trigEnv.decaySeconds}, + }; + for (const auto& k : msKnobs) { + CHECK(timeSecondsFromNorm(deckParamNorm(k.id, d)) == k.stored); + } + // The two whose defaults are neither 0 nor the ceiling are the ones that can actually fail. + CHECK(d.adsr.attackSeconds == 0.003 && d.adsr.releaseSeconds == 0.060); + + CHECK(depthSemitonesFromNorm(deckParamNorm(DeckParam::kPitchEnvDepth, d), + kPitchDepthMaxSemis) == d.pitchEnv.peakSemitones); + CHECK(deckParamNorm(DeckParam::kSustain, d) == d.adsr.sustainLevel); + CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction); + CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction); + CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount); + CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) == + d.adsr.attackCurve); + // Master gain's unity: the case where a hair off is an audible gain error rather than a + // cosmetic one. Its taper is engine/master_gain's — consumed here, not defined here. + CHECK(instrument::engine::masterGainLinearFromNorm(instrument::engine::masterGainNormFromLinear(1.0)) == 1.0); +} + +// Shift's snap unit is a property of the control's UNIT and lands on a whole unit of what the +// control DISPLAYS — which is why three controls sharing the Percent category take three +// different norm steps. +static void testShiftSnapsToAWholeUnitOfTheDisplayedValue() { + // Milliseconds: the snapped norm reads back as an exact whole millisecond. + const double ms = timeSecondsFromNorm(snapDeckParamNorm(DeckParam::kAttack, + timeNormFromSeconds(0.03472))); + CHECK(ms == 0.035); + // Semitones. + CHECK(depthSemitonesFromNorm( + snapDeckParamNorm(DeckParam::kPitchEnvDepth, + depthNormFromSemitones(6.6, kPitchDepthMaxSemis)), + kPitchDepthMaxSemis) == 7.0); + // Percent, 0..100 %: the norm IS the fraction. + CHECK(snapDeckParamNorm(DeckParam::kSustain, 0.4162) == 0.42); + // Percent, 0..200 %: a whole DISPLAYED percent is half a norm percent. + CHECK(snapDeckParamNorm(DeckParam::kFilterKeyTrack, 0.4162) == 0.4150); + // Percent, +/-100 %: likewise, measured on the bipolar value. + CHECK(snapDeckParamNorm(DeckParam::kFilterVel, deckNormFromBipolar(-0.4162)) == + deckNormFromBipolar(-0.42)); + // Exponent: whole numbers, which puts the linear neutral one snap from centre. Compared as + // the norm the snap RETURNS — the exponent's own log travel is not an exact round trip. + CHECK(snapDeckParamNorm(DeckParam::kAttackCurve, util::knobNormFromCurve(2.6)) == + util::knobNormFromCurve(3.0)); + CHECK(snapDeckParamNorm(DeckParam::kAttackCurve, util::knobNormFromCurve(1.4)) == + util::knobNormFromCurve(util::kCurveNeutral)); + // Decibels, likewise compared as the returned norm. + CHECK(snapDeckParamNorm(DeckParam::kMasterGain, + instrument::engine::masterGainNormFromDb(-6.4)) == + instrument::engine::masterGainNormFromDb(-6.0)); + // Already-integer and discrete controls are untouched. + CHECK(snapDeckParamNorm(DeckParam::kVoiceCount, 0.4162) == 0.4162); + CHECK(snapDeckParamNorm(DeckParam::kPlayMode, 0.4162) == 0.4162); + CHECK(deckParamUnit(DeckParam::kVoiceCount) == UnitCategory::None); + CHECK(deckParamUnit(DeckParam::kAmpVelCurve) == UnitCategory::None); +} + +// The taper and the raised ceiling are persistence-neutral BY CONSTRUCTION: the binding only +// READS the stored seconds, so a value dialled under the old 2 s ceiling reloads bit-identical +// and simply sits somewhere else on the knob. Nothing on the load path rewrites it. +static void testAValueStoredUnderTheOldCeilingIsReadNotRewritten() { + PlaySeconds p; + p.adsr.decaySeconds = 1.75; // reachable by hand at the retired 2 s ceiling + p.adsr.releaseSeconds = 2.0; + const double normDecay = deckParamNorm(DeckParam::kDecay, p); + CHECK(p.adsr.decaySeconds == 1.75); // reading the norm mutated nothing + CHECK(p.adsr.releaseSeconds == 2.0); + CHECK(normDecay > 0.0 && normDecay < 1.0); // still on the knob, just at a new angle + CHECK(deckParamNorm(DeckParam::kRelease, p) > normDecay); + // And it survives the norm the knob would hand back, so a no-op touch of the control does + // not quantize a legacy value away. + setDeckParam(DeckParam::kDecay, p, normDecay, 0); + CHECK(p.adsr.decaySeconds == 1.75); +} + +// The filter's four tone controls are wire-frozen in the payload: their stored value IS their +// normalized position, and nothing in the taper pass may re-map it. Their snap is display-side +// only, which is what this separates. +static void testTheFilterFourKeepTheirIdentityTaper() { + PlaySeconds p; + const double positions[] = {0.0, 0.125, 0.5, 0.73, 1.0}; + for (double n : positions) { + setDeckParam(DeckParam::kFilterCutoff, p, n, 0); + setDeckParam(DeckParam::kFilterQ, p, n, 0); + setDeckParam(DeckParam::kFilterMorph, p, n, 0); + setDeckParam(DeckParam::kFilterDrive, p, n, 0); + CHECK(p.filter.settings.cutoffNorm == static_cast(n)); + CHECK(p.filter.settings.resonanceNorm == static_cast(n)); + CHECK(p.filter.settings.morphNorm == static_cast(n)); + CHECK(p.filter.settings.driveNorm == static_cast(n)); + CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == static_cast(static_cast(n))); + } +} + // One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a // decimal rather than reading as a bare zero, and a multi-second one stays in ms rather than // switching units mid-deck. @@ -162,7 +314,7 @@ static void testTimeConstantsAlwaysReadInMilliseconds() { CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading CHECK(msLabel(0.25) == "250 ms"); CHECK(msLabel(1.5) == "1500 ms"); // multi-second, still ms - CHECK(msLabel(kEnvTimeMaxSeconds) == "2000 ms"); + CHECK(msLabel(kEnvTimeMaxSeconds) == "10000 ms"); // The 10 ms hinge belongs to the integer form, not the decimal one. CHECK(msLabel(0.01) == "10 ms"); CHECK(msLabel(0.0099) == "9.9 ms"); @@ -175,10 +327,16 @@ static void testTimeConstantsAlwaysReadInMilliseconds() { } int main() { + testTheTwoCeilingNamesAreOneNumber(); testNormRoundTripsThroughEveryValueDomain(); testResetTouchesOnlyItsOwnRingOnADualRingKnob(); testInnerResetLandsOnTheExactLinearNeutral(); testResetLandsOnTheStoredDefaultOfEachControl(); + testEveryKnobIdResetsToItsDefault(); + testEveryDefaultHasAnExactNormalizedPreimage(); + testShiftSnapsToAWholeUnitOfTheDisplayedValue(); + testAValueStoredUnderTheOldCeilingIsReadNotRewritten(); + testTheFilterFourKeepTheirIdentityTaper(); testTimeConstantsAlwaysReadInMilliseconds(); if (g_fail) { std::printf("%d FAILURE(S)\n", g_fail); diff --git a/tests/test_envelope_edit.cpp b/tests/test_envelope_edit.cpp index add5ff9..6effc07 100644 --- a/tests/test_envelope_edit.cpp +++ b/tests/test_envelope_edit.cpp @@ -5,11 +5,12 @@ // // Covers: nodeAtPoint (every drawn handle grabbable, the anchored ReleaseEnd and the Origin // never grabbed, other-kind nodes rejected, misses outside the radius, a dead coincident AHD -// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag -// (AHDSR stage times at the schematic scale, the sustain level on Y, the release dragged from -// its START with the inverted sign, the caller's clamp domain, AHD stage times at the 1:1 -// scale, the hold FRACTION); curve-knot drags (the exponent domain, its endpoints, and the -// round trip through the shared law that keeps knot and dial on one value); degenerate no-ops. +// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag (AHDSR stage nodes +// tracking the cursor across the TAPERED schematic and being its exact inverse, the sustain level +// on Y, the release dragged from its START with the inverted sign, the caller's clamp domain, AHD +// stage times at the 1:1 scale, the hold FRACTION); curve-knot drags (the exponent domain, its +// endpoints, and the round trip through the shared law that keeps knot and dial on one value); +// the interaction law (Ctrl's rate on every axis, Shift's per-category snap); degenerate no-ops. #include "../src/core/instrument/ui/envelope_edit.h" @@ -28,12 +29,14 @@ static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; } static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100 static constexpr double kTotal = 4.0; +// The shell's own domain (editor_controls' envClampBounds), so a drag here is clamped exactly +// where a knob is. static EnvClampBounds bounds() { EnvClampBounds b; - b.maxAttackSeconds = 2.0; - b.maxHoldSeconds = 2.0; - b.maxDecaySeconds = 2.0; - b.maxReleaseSeconds = 2.0; + b.maxAttackSeconds = kGateStageMaxSeconds; + b.maxHoldSeconds = kGateStageMaxSeconds; + b.maxDecaySeconds = kGateStageMaxSeconds; + b.maxReleaseSeconds = kGateStageMaxSeconds; return b; } @@ -160,23 +163,64 @@ static void testMissOutsideTheRadius() { // --- AHDSR drags --------------------------------------------------------------- -static void testAhdsrStageTimesTrackTheSchematicScale() { +// The x position of node `n` as the FORWARD map draws it — the only thing a tapered-axis drag can +// be measured against, since there is no longer a fixed seconds-per-pixel rate to restate. +static int drawnX(const StageEnvelope& e, EnvNode n) { + EnvVertex v; + return findNode(buildEnvelopePolyline(e, overlayOf(wideArea()), kTotal), n, v) ? v.x : -1; +} + +// The schematic axis IS the knob's taper, so what a stage node tracks is the CURSOR — at both +// ends of the range, which a fixed-rate inverse could not manage once the axis stopped being +// linear in seconds. Swept across four decades of stage time for exactly that reason. +static void testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange() { const Rect a = wideArea(); + const double startTimes[] = {0.0, 0.003, 0.25, 2.0}; + for (double t : startTimes) { + StageEnvelope e = ahdsrEnv(); + e.attackSeconds = t; + const StageEnvelope moved = + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 40, 0); + CHECK(std::abs((drawnX(moved, EnvNode::AttackEnd) - drawnX(e, EnvNode::AttackEnd)) - 40) + <= 1); + CHECK(moved.attackSeconds > t); + CHECK(moved.holdSeconds == e.holdSeconds); // only the dragged param moves + } + // Hold and decay ride the same axis, in both directions. const StageEnvelope e = ahdsrEnv(); - const double secPerPx = 1.0 / gatePxPerSecond(a); - - const StageEnvelope attack = - resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 50, 0); - CHECK(std::fabs(attack.attackSeconds - (e.attackSeconds + 50 * secPerPx)) < 1e-9); - CHECK(attack.holdSeconds == e.holdSeconds); // only the dragged param moves - const StageEnvelope hold = resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -20, 0); - CHECK(std::fabs(hold.holdSeconds - (e.holdSeconds - 20 * secPerPx)) < 1e-9); - + CHECK(std::abs((drawnX(hold, EnvNode::HoldEnd) - drawnX(e, EnvNode::HoldEnd)) + 20) <= 1); + CHECK(hold.holdSeconds < e.holdSeconds); const StageEnvelope decay = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 30, 0); - CHECK(std::fabs(decay.decaySeconds - (e.decaySeconds + 30 * secPerPx)) < 1e-9); + CHECK(std::abs((drawnX(decay, EnvNode::DecayEnd) - drawnX(e, EnvNode::DecayEnd)) - 30) <= 1); + CHECK(decay.decaySeconds > e.decaySeconds); +} + +// The one-model rule, at the tapered axis: a node dragged to a pixel and the knob's value at that +// pixel are ONE number, so the inverse has to be EXACT and not merely close. A zero-delta drag +// reproduces the grab value bit for bit, and a drag out and straight back lands where it started. +static void testDrawAndDragAreExactInverses() { + const Rect a = wideArea(); + // Four decades of stage time, stopping short of the clamp: a drag that saturates at the + // domain end deliberately does NOT come back (testStageTimesClampToTheKnobDomain owns that). + const double startTimes[] = {0.0, 0.003, 0.060, 1.0}; + for (double t : startTimes) { + StageEnvelope e = ahdsrEnv(); + e.attackSeconds = t; + CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 0, 0) + .attackSeconds == t); + const StageEnvelope out = + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 30, 0); + const StageEnvelope back = + resolveNodeDrag(out, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), -30, 0); + // The DRAWN node returns to the exact pixel it left, which is the property the one-model + // rule actually needs; the underlying seconds return to within the taper's own quantum + // read back through the map, which is proportional to the value. + CHECK(drawnX(back, EnvNode::AttackEnd) == drawnX(e, EnvNode::AttackEnd)); + CHECK(std::fabs(back.attackSeconds - t) < 1e-6 * (t + 0.01)); + } } // The release is dragged from its TOP node and its end is anchored to the canvas edge, so @@ -185,13 +229,15 @@ static void testAhdsrStageTimesTrackTheSchematicScale() { static void testReleaseDragsFromItsStartWithInvertedSign() { const Rect a = wideArea(); const StageEnvelope e = ahdsrEnv(); - const double secPerPx = 1.0 / gatePxPerSecond(a); const StageEnvelope longer = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), -40, 0); - CHECK(std::fabs(longer.releaseSeconds - (e.releaseSeconds + 40 * secPerPx)) < 1e-9); + CHECK(longer.releaseSeconds > e.releaseSeconds); const StageEnvelope shorter = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), 40, 0); CHECK(shorter.releaseSeconds < e.releaseSeconds); + // The node still tracks the cursor, inverted sign notwithstanding. + CHECK(std::abs((drawnX(longer, EnvNode::ReleaseStart) - + drawnX(e, EnvNode::ReleaseStart)) + 40) <= 1); } static void testSustainLevelOnTheDecayNodesYAxis() { @@ -327,6 +373,73 @@ static void testKnotOnANearLevelSegmentIsANoOp() { CHECK(out.decayCurve == 2.5); } +// --- the interaction law on the overlay ---------------------------------------- + +// Ctrl scales the PIXEL delta, so it composes with every axis — the tapered schematic, the 1:1 +// wall clock, the level and the exponent — instead of each getting its own rule. +static void testCtrlScalesEveryAxisOfANodeDrag() { + const Rect a = wideArea(); + const StageEnvelope e = ahdsrEnv(); + const DragModifiers fine{false, true}; + const int coarse = 10; + const int equivalent = static_cast(coarse / kFineDragScale); // 200 fine px == 10 coarse + CHECK(std::fabs( + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), equivalent, + 0, fine).attackSeconds - + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), coarse, 0) + .attackSeconds) < 1e-9); + CHECK(std::fabs( + resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, + equivalent, fine).sustainLevel - + resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, coarse) + .sustainLevel) < 1e-9); + // A zero delta is identical under either rate — the state the shell's re-anchor establishes + // at every modifier transition, and why the value cannot jump across one. + CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 0, 0, fine) + .attackSeconds == e.attackSeconds); +} + +// Shift reaches the overlay because node, knot and knob are surfaces onto ONE model: a snap +// available on the knob and not on the node would be exactly the divergence that rule forbids. +// Each axis is asserted against the snap of ITS OWN category applied to the free drag's result — +// a node that routed a level through the millisecond snap, or snapped before the axis map rather +// than after it, fails here. The snaps themselves are param_taper's own tests. +static void testShiftSnapsEachAxisToItsOwnWholeUnit() { + const Rect a = wideArea(); + const StageEnvelope e = ahdsrEnv(); + const DragModifiers shift{true, false}; + + const StageEnvelope freeMs = + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 37, 0); + const StageEnvelope snapMs = + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 37, 0, shift); + CHECK(snapMs.attackSeconds == snapSecondsToWholeMs(freeMs.attackSeconds)); + CHECK(snapMs.attackSeconds != freeMs.attackSeconds); // the drag really did move to the grid + CHECK(std::fabs(snapMs.attackSeconds - freeMs.attackSeconds) <= 0.0005 + 1e-12); + + const StageEnvelope freeLevel = + resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -13); + const StageEnvelope snapLevel = + resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -13, shift); + CHECK(snapLevel.sustainLevel == snapFractionToWholePercent(freeLevel.sustainLevel)); + CHECK(std::fabs(snapLevel.sustainLevel - freeLevel.sustainLevel) <= 0.005 + 1e-12); + + const StageEnvelope freeKnot = + resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 9); + const StageEnvelope snapKnot = + resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 9, shift); + CHECK(snapKnot.attackCurve == snapExponentToWhole(freeKnot.attackCurve)); + CHECK(snapKnot.attackCurve != freeKnot.attackCurve); + + // An AHD's Hold node edits a FRACTION, so its whole unit is a percent, not a millisecond. + const StageEnvelope freeFrac = + resolveNodeDrag(ahdEnv(), EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 37, 0); + const StageEnvelope snapFrac = resolveNodeDrag(ahdEnv(), EnvNode::HoldEnd, overlayOf(a), + kTotal, bounds(), 37, 0, shift); + CHECK(snapFrac.holdFraction == snapFractionToWholePercent(freeFrac.holdFraction)); + CHECK(snapFrac.holdFraction != freeFrac.holdFraction); +} + // --- degenerate ---------------------------------------------------------------- static void testDegenerateInputsAreNoOps() { @@ -347,7 +460,8 @@ int main() { testFunctionalCoincidentDecayEndStaysGrabbable(); testMissOutsideTheRadius(); - testAhdsrStageTimesTrackTheSchematicScale(); + testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange(); + testDrawAndDragAreExactInverses(); testReleaseDragsFromItsStartWithInvertedSign(); testSustainLevelOnTheDecayNodesYAxis(); testStageTimesClampToTheKnobDomain(); @@ -355,6 +469,9 @@ int main() { testAhdStageTimesTrackTheWallClockScale(); testAhdHoldNodeEditsTheFraction(); + testCtrlScalesEveryAxisOfANodeDrag(); + testShiftSnapsEachAxisToItsOwnWholeUnit(); + testKnotDragMovesTheExponentWithinItsDomain(); testKnotAndModelCannotDiverge(); testKnotOnALevelSegmentIsANoOp(); diff --git a/tests/test_envelope_overlay.cpp b/tests/test_envelope_overlay.cpp index b16b323..6c527f2 100644 --- a/tests/test_envelope_overlay.cpp +++ b/tests/test_envelope_overlay.cpp @@ -4,15 +4,18 @@ // RIGHT-ANCHORED release, and the sustain-less AHD laid 1:1 over the waveform's time axis. // // Covers: timeToX / levelToY (linear maps, edge clamps, past-end clamped to right-1, no 32-bit -// overflow on huge times, degenerate area/duration); gatePxPerSecond; the AHDSR polyline (node -// order, levels, release anchored at the right edge, the sustain plateau reaching the edge at -// zero release, per-segment separation at the tier-0 defaults, overrun compression, every -// vertex in-bounds); splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the -// AHD polyline (1:1 with the time axis, origin offset); curve knots (present only on sloped -// non-zero segments, height following the exponent); the degenerate flat baseline. +// overflow on huge times, degenerate area/duration); gateStageSlotPx; the AHDSR polyline (node +// order, levels, the TAPERED stage placement and its legibility at both ends of the range, +// release anchored at the right edge, the sustain plateau reaching the edge at zero release, +// per-segment separation at the tier-0 defaults, overrun compression, every vertex in-bounds); +// splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the AHD polyline (1:1 with +// the time axis, origin offset); curve knots (present only on sloped non-zero segments, height +// following the exponent); the degenerate flat baseline. #include "../src/core/instrument/ui/envelope_overlay.h" +#include "../src/core/instrument/ui/sample_bands.h" // the editor floor the legibility test uses + #include #include #include @@ -94,20 +97,19 @@ static void testDegenerateAreaAndDuration() { CHECK(timeToX(Rect{}, 2.0, 1.0) == 0); CHECK(timeToX(wideArea(), 0.0, 1.0) == wideArea().x); CHECK(levelToY(Rect{}, 0.5) == 0); - CHECK(gatePxPerSecond(Rect{}) == 0.0); + CHECK(gateStageSlotPx(Rect{}) == 0.0); } -// The literal PARAM-DOMAIN scale, independent of any sample duration: usable px = canvas width -// minus the last column minus 4 node-separation bases, spread over 4 x kGateStageMaxSeconds. -// This is what makes a dragged handle track the cursor 1:1 (envelope_edit's own inverse reads -// this same function) — a scale regression here is exactly what a relational-only check misses. -static void testGatePxPerSecond() { - // 967 / 8 px/s, pinned as a literal — restating the formula with the same named constants - // would let a change to kGateNodeSepPx or kGateStageMaxSeconds move both sides and pass - // silently. - CHECK(gatePxPerSecond(wideArea()) == 120.875); - CHECK(gatePxPerSecond(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0 - CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0 +// The literal slot width, independent of any sample duration: usable px = canvas width minus the +// last column minus 4 node-separation bases, split four ways. A stage then occupies its own +// TAPERED fraction of that slot, which is what makes a dragged handle track the cursor at both +// ends of the range — a scale regression here is exactly what a relational-only check misses. +static void testGateStageSlotPx() { + // 967 / 4 px, pinned as a literal — restating the formula with the same named constants + // would let a change to kGateNodeSepPx move both sides and pass silently. + CHECK(gateStageSlotPx(wideArea()) == 241.75); + CHECK(gateStageSlotPx(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0 + CHECK(gateStageSlotPx(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0 } // --- the AHDSR schematic ------------------------------------------------------ @@ -135,24 +137,52 @@ static void testAhdsrNodeOrderAndLevels() { CHECK(v.x == a.right() - 1); // ANCHORED, whatever the release is } -// The literal per-node x placement, hand-derived from the documented formula (pps = 120.875 -// px/s per testGatePxPerSecond; each timed stage is prefixed by the kGateNodeSepPx=8 base): -// attack .2s -> raw 8+24.175=32.175 -> px 32; hold .1s -> raw 32.175+8+12.0875=52.2625 -> px 52; -// decay .3s -> raw 52.2625+8+36.2625=96.525 -> px 97; plateau -> raw 999-8-48.35=942.65 -> px -// 943; release end pinned at the last column, 999. A literal regression pin — no relational or -// bounds-only check catches a formula-shape change the way an exact pixel count does. +// The literal per-node x placement, hand-derived from the documented formula (slot = 241.75 px +// per testGateStageSlotPx; each timed stage is prefixed by the kGateNodeSepPx=8 base and occupies +// slot x timeNormFromSeconds(t) of its own slot; L = ln(1 + 10/0.003) = 8.112028): +// attack .25s -> norm ln(84.3333)/L = 0.546677 -> 8 + 132.159 = 140.159 -> px 140 +// hold .05s -> norm ln(17.6667)/L = 0.354007 -> 140.159 + 8 + 85.581 = 233.740 -> px 234 +// decay .5s -> norm ln(167.667)/L = 0.631385 -> 233.740 + 8 + 152.637 = 394.377 -> px 394 +// plateau 1s -> norm ln(334.333)/L = 0.716493 -> 999 - 8 - 173.211 = 817.789 -> px 818 +// release end pinned at the last column, 999. +// A literal regression pin — no relational or bounds-only check catches a formula-shape change +// the way an exact pixel count does. static void testAhdsrSchematicPlacement() { const Rect a = wideArea(); const std::vector poly = - buildEnvelopePolyline(ahdsr(0.2, 0.1, 0.3, 0.5, 0.4), overlayOf(a), 4.0); + buildEnvelopePolyline(ahdsr(0.25, 0.05, 0.5, 0.5, 1.0), overlayOf(a), 4.0); EnvVertex v; - CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 32); - CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 52); - CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 97); - CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 943); + CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 140); + CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 234); + CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 394); + CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 818); CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 999); } +// The legibility the tapered axis exists for, at BOTH ends of the raised range. Linear-in-seconds +// put the 3 ms default attack 0.07 px from the origin at a 10 s ceiling — indistinguishable from +// zero and impossible to grab. Asserted at the editor's own floor width, not a comfortable one. +static void testTaperedAxisKeepsBothEndsOfTheRangeLegible() { + const Rect floorArea = Rect::ltrb(0, 0, kEditorMinWidth - 2 * kPad, 100); + const std::vector poly = + buildEnvelopePolyline(ahdsr(0.003, 0.0, 0.0, 1.0, 0.060), overlayOf(floorArea), 4.0); + EnvVertex origin, attack; + CHECK(findNode(poly, EnvNode::Origin, origin)); + CHECK(findNode(poly, EnvNode::AttackEnd, attack)); + // Well clear of the grab radius, so the default attack is a real handle rather than a node + // sitting on the origin. + CHECK(attack.x - origin.x >= 20); + + // And a maxed stage still lands its end node at its slot's edge: the taper's norm-1 end and + // the schematic's canvas edge are the same place, which is the anchor the policy rests on. + const std::vector maxed = buildEnvelopePolyline( + ahdsr(kGateStageMaxSeconds, 0.0, 0.0, 1.0, 0.0), overlayOf(floorArea), 4.0); + EnvVertex maxAttack; + CHECK(findNode(maxed, EnvNode::AttackEnd, maxAttack)); + const double slot = gateStageSlotPx(floorArea); + CHECK(maxAttack.x == floorArea.x + static_cast(kGateNodeSepPx + slot + 0.5)); +} + // The AHDSR schematic is scaled by the PARAM domain, NOT the capture length: the same params // produce the SAME polyline whether totalSeconds is 0.3 or 10 (gatePolyline doesn't even take // totalSeconds — only the sustain-less AHD's x-axis is wall-clock/PCM-aligned). @@ -409,10 +439,11 @@ int main() { testTimeToXClampsBothEnds(); testLevelToY(); testDegenerateAreaAndDuration(); - testGatePxPerSecond(); + testGateStageSlotPx(); testAhdsrNodeOrderAndLevels(); testAhdsrSchematicPlacement(); + testTaperedAxisKeepsBothEndsOfTheRangeLegible(); testGateLayoutIndependentOfSampleDuration(); testZeroReleasePutsTheSustainPlateauAtTheRightEdge(); testReleaseGrowsLeftwardFromTheAnchor(); diff --git a/tests/test_param_slider.cpp b/tests/test_param_slider.cpp index 48dfaed..0a39f19 100644 --- a/tests/test_param_slider.cpp +++ b/tests/test_param_slider.cpp @@ -245,22 +245,35 @@ static void testKnobNeedlePointOnCircle() { static void testKnobDragUpIncreases() { // Up (negative dy) increases, down decreases, scaled by the drag range. - CHECK(approx(knobDragValue(0.5, -32, 128), 0.75)); - CHECK(approx(knobDragValue(0.5, +32, 128), 0.25)); + CHECK(approx(knobDragValue(0.5, -32, {}, 128), 0.75)); + CHECK(approx(knobDragValue(0.5, +32, {}, 128), 0.25)); // A full-range upward drag from 0 lands exactly at 1. - CHECK(approx(knobDragValue(0.0, -128, 128), 1.0)); + CHECK(approx(knobDragValue(0.0, -128, {}, 128), 1.0)); // Default sensitivity applies when the range is omitted. CHECK(approx(knobDragValue(0.0, -kKnobDragRangePixels), 1.0)); } static void testKnobDragClamps() { - CHECK(approx(knobDragValue(0.9, -64, 128), 1.0)); // over-drag up clamps at 1 - CHECK(approx(knobDragValue(0.1, +64, 128), 0.0)); // over-drag down clamps at 0 + CHECK(approx(knobDragValue(0.9, -64, {}, 128), 1.0)); // over-drag up clamps at 1 + CHECK(approx(knobDragValue(0.1, +64, {}, 128), 0.0)); // over-drag down clamps at 0 // The start value itself is clamped before the delta applies. - CHECK(approx(knobDragValue(1.5, 0, 128), 1.0)); - CHECK(approx(knobDragValue(-0.5, 0, 128), 0.0)); + CHECK(approx(knobDragValue(1.5, 0, {}, 128), 1.0)); + CHECK(approx(knobDragValue(-0.5, 0, {}, 128), 0.0)); // A degenerate drag range yields the clamped start value. - CHECK(approx(knobDragValue(0.7, -50, 0), 0.7)); + CHECK(approx(knobDragValue(0.7, -50, {}, 0), 0.7)); +} + +// Ctrl scales the drag rate; Shift+Ctrl is Shift, so the rate goes back to coarse. The snap +// itself is not this module's — only the rate is. +static void testCtrlScalesTheDragRateAndShiftOverridesIt() { + const DragModifiers fine{false, true}; + const DragModifiers both{true, true}; + CHECK(approx(knobDragValue(0.5, -32, fine, 128), 0.5 + 0.25 * kFineDragScale)); + CHECK(approx(knobDragValue(0.5, -32, both, 128), 0.75)); + CHECK(approx(knobDragValue(0.5, -32, DragModifiers{true, false}, 128), 0.75)); + // Continuity across a transition is the CALLER's re-anchor, not this function's: at a + // zero delta both rates agree, which is exactly the state a re-anchor establishes. + CHECK(knobDragValue(0.42, 0, fine, 128) == knobDragValue(0.42, 0, {}, 128)); } // --- controlAtPoint routing --------------------------------------------------- @@ -321,6 +334,7 @@ int main() { testKnobNeedlePointOnCircle(); testKnobDragUpIncreases(); testKnobDragClamps(); + testCtrlScalesTheDragRateAndShiftOverridesIt(); testControlAtPointRoutes(); testControlAtPointMisses(); diff --git a/tests/test_param_taper.cpp b/tests/test_param_taper.cpp new file mode 100644 index 0000000..49dab04 --- /dev/null +++ b/tests/test_param_taper.cpp @@ -0,0 +1,260 @@ +// Standalone tests for reasampler::instrument::ui::param_taper — no VST3, no REAPER, no +// framework. The taper is the one map the knob's needle, the AHDSR schematic axis and (later) the +// host's normalization all read, so what is asserted here is what all three obey. +// +// Covers: the modifier truth table (Shift beats Ctrl); the stage-time taper (exact endpoints, +// monotone, the two landmark bands, and the EXACT-PREIMAGE guarantee swept over the whole +// quantum grid rather than sampled at the defaults); the depth taper (exact centre and ends, +// exact symmetry, the +/-7 st landmark, whole-semitone preimages); and the four whole-unit snaps. + +#include "../src/core/instrument/ui/param_taper.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::ui; + +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 constexpr double kDepth = 24.0; // the pitch-depth throw the deck passes in today + +// --- modifiers ----------------------------------------------------------------------------- + +// Shift+Ctrl is SHIFT: with the output quantized to whole units a finer drag produces the same +// sequence, so Ctrl is ignored there. Asserted rather than left to a comment because the +// "obvious fix" is to compound the two. +static void testShiftBeatsCtrlForTheFineDragRate() { + CHECK(!fineDrag(DragModifiers{false, false})); + CHECK(fineDrag(DragModifiers{false, true})); + CHECK(!fineDrag(DragModifiers{true, false})); + CHECK(!fineDrag(DragModifiers{true, true})); + CHECK((DragModifiers{true, false} != DragModifiers{false, false})); + CHECK((DragModifiers{true, true} == DragModifiers{true, true})); +} + +// --- the stage-time taper ------------------------------------------------------------------ + +// Zero is a REQUIRED value a pure log cannot express, and the ceiling has to be reachable by +// hand — both endpoints are exact, not merely close. +static void testStageTimeEndpointsAreExact() { + CHECK(timeSecondsFromNorm(0.0) == 0.0); + CHECK(timeSecondsFromNorm(1.0) == kStageTimeMaxSeconds); + CHECK(timeNormFromSeconds(0.0) == 0.0); + CHECK(timeNormFromSeconds(kStageTimeMaxSeconds) == 1.0); + // Out of domain clamps rather than extrapolating. + CHECK(timeSecondsFromNorm(-1.0) == 0.0); + CHECK(timeSecondsFromNorm(2.0) == kStageTimeMaxSeconds); + CHECK(timeNormFromSeconds(-1.0) == 0.0); + CHECK(timeNormFromSeconds(1e9) == 1.0); +} + +// The ceiling this phase raised it to. Pinned as a literal: this endpoint becomes a frozen host +// normalization, so a silent change to it is exactly what a test has to refuse. +static void testStageTimeCeilingIsTenSeconds() { + CHECK(kStageTimeMaxSeconds == 10.0); +} + +// The two landmarks the taper is fitted to, at the NEW ceiling. They are what make the low end +// dialable at a 10 s range, and they are also the overlay's legibility guarantee. +static void testStageTimeLandmarksLandInTheirBands() { + const double at10ms = timeNormFromSeconds(0.010); + const double at100ms = timeNormFromSeconds(0.100); + CHECK(at10ms >= 0.12 && at10ms <= 0.20); + CHECK(at100ms >= 0.42 && at100ms <= 0.52); + // And the two are ordered with real separation, not merely inside their bands. + CHECK(at100ms > at10ms + 0.2); +} + +static void testStageTimeIsMonotone() { + double prev = -1.0; + for (int i = 0; i <= 200000; ++i) { + const double v = timeSecondsFromNorm(static_cast(i) / 200000.0); + CHECK(v >= prev); + if (v < prev) return; // one report is enough + prev = v; + } +} + +// The FINEST drag a user can make — Ctrl's 1/20 rate over the 128 px knob travel — must still +// move the value, or the output quantum would be observable as a dead zone. +static void testEveryFinestDragStepMovesTheValue() { + const int steps = static_cast(1.0 / kFineDragScale) * 128; + for (int i = 0; i < steps; ++i) { + const double lo = timeSecondsFromNorm(static_cast(i) / steps); + const double hi = timeSecondsFromNorm(static_cast(i + 1) / steps); + CHECK(hi > lo); + if (!(hi > lo)) return; + } +} + +// THE sharpest requirement in the track. A host's reset-to-default arrives as +// toPlain(defaultNorm) with no bypass available, so the preimage has to be EXACT. Swept over the +// whole quantum grid at the resolution the defaults live at, not sampled at the two the parameter +// set happens to carry today — that is what makes the guarantee structural. +static void testEveryWholeMicrosecondRoundTripsExactly() { + for (int us = 0; us <= 200000; us += 7) { // 0 .. 200 ms, a prime stride to avoid alignment + const double seconds = static_cast(us) / 1e6; + CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds); + if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) return; + } + // And across the rest of the range, where the map is coarsest. + for (int ms = 200; ms <= 10000; ms += 13) { + const double seconds = static_cast(ms) / 1e3; + CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds); + if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) return; + } +} + +// The converse round trip is NOT required, but its residual is worth pinning: it is bounded by +// the output quantum read back through the map, which stays four orders below one drag pixel. +// Pinned so a future quantum change cannot make the needle visibly lag the hand unnoticed. +static void testNormRoundTripResidualStaysBelowOneDragPixel() { + for (int i = 0; i <= 100000; ++i) { + const double n = static_cast(i) / 100000.0; + const double back = timeNormFromSeconds(timeSecondsFromNorm(n)); + CHECK(std::fabs(back - n) < 1e-7); + if (!(std::fabs(back - n) < 1e-7)) return; + } +} + +// The two stage-time defaults the parameter set actually carries, named so a reader can see the +// values the sweep above covers generically. +static void testTheStageTimeDefaultsRoundTripExactly() { + CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.003)) == 0.003); + CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.060)) == 0.060); + CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.0)) == 0.0); +} + +// --- the depth taper ----------------------------------------------------------------------- + +static void testDepthCentreAndEndsAreExact() { + CHECK(depthNormFromSemitones(0.0, kDepth) == 0.5); + CHECK(depthSemitonesFromNorm(0.5, kDepth) == 0.0); + CHECK(depthNormFromSemitones(kDepth, kDepth) == 1.0); + CHECK(depthNormFromSemitones(-kDepth, kDepth) == 0.0); + CHECK(depthSemitonesFromNorm(1.0, kDepth) == kDepth); + CHECK(depthSemitonesFromNorm(0.0, kDepth) == -kDepth); + // Beyond the throw clamps rather than extrapolating. + CHECK(depthNormFromSemitones(100.0, kDepth) == 1.0); + CHECK(depthSemitonesFromNorm(5.0, kDepth) == kDepth); +} + +// Symmetric BITWISE, not approximately: a bipolar knob whose two halves disagreed by an ulp +// would read a different depth up than down at the same distance from centre. +static void testDepthIsExactlySymmetric() { + for (int i = 0; i <= 1000; ++i) { + const double n = static_cast(i) / 1000.0; + CHECK(depthSemitonesFromNorm(n, kDepth) == -depthSemitonesFromNorm(1.0 - n, kDepth)); + if (depthSemitonesFromNorm(n, kDepth) != -depthSemitonesFromNorm(1.0 - n, kDepth)) return; + } +} + +// Centre expansion: the musically useful +/-7 st gets more than half of each half-travel. +static void testDepthLandmarkLandsInItsBand() { + const double halfTravel = (depthNormFromSemitones(7.0, kDepth) - 0.5) * 2.0; + CHECK(halfTravel >= 0.50 && halfTravel <= 0.58); + // The negative half is the same distance out. Compared with a tolerance, not bitwise: 0.5+h + // and 0.5-h round differently, and the mirror that has to be EXACT is the one in the plain + // direction (testDepthIsExactlySymmetric) — a sub-ulp difference in a needle angle is not. + CHECK(std::fabs((0.5 - depthNormFromSemitones(-7.0, kDepth)) * 2.0 - halfTravel) < 1e-15); +} + +static void testDepthIsMonotone() { + double prev = -1e9; + for (int i = 0; i <= 200000; ++i) { + const double v = depthSemitonesFromNorm(static_cast(i) / 200000.0, kDepth); + CHECK(v >= prev); + if (v < prev) return; + prev = v; + } +} + +// Same exact-preimage guarantee as the time taper: every value on the depth quantum grid comes +// back bitwise. Whole semitones are the case a Shift-snap produces, so they are swept explicitly. +static void testEveryWholeSemitoneRoundTripsExactly() { + for (int st = -24; st <= 24; ++st) { + const double d = static_cast(st); + CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d); + } + for (int milli = -24000; milli <= 24000; milli += 37) { + const double d = static_cast(milli) / 1000.0; + CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d); + if (depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) != d) return; + } +} + +// A degenerate throw is a caller bug, not a crash: the map collapses to the centre. +static void testDegenerateThrowCollapsesToCentre() { + CHECK(depthNormFromSemitones(3.0, 0.0) == 0.5); + CHECK(depthSemitonesFromNorm(0.9, 0.0) == 0.0); +} + +// --- the whole-unit snaps ------------------------------------------------------------------- + +static void testMillisecondSnap() { + CHECK(snapSecondsToWholeMs(0.0124) == 0.012); + CHECK(snapSecondsToWholeMs(0.0126) == 0.013); + CHECK(snapSecondsToWholeMs(0.0004) == 0.0); + CHECK(snapSecondsToWholeMs(-1.0) == 0.0); + CHECK(snapSecondsToWholeMs(9.9996) == 10.0); + // The snapped value is itself on the taper's grid, so a snap followed by a round trip holds. + CHECK(timeSecondsFromNorm(timeNormFromSeconds(snapSecondsToWholeMs(0.0347))) == 0.035); +} + +static void testPercentSnap() { + CHECK(snapFractionToWholePercent(0.514) == 0.51); + CHECK(snapFractionToWholePercent(0.516) == 0.52); + CHECK(snapFractionToWholePercent(-0.514) == -0.51); + CHECK(snapFractionToWholePercent(1.0) == 1.0); + CHECK(snapFractionToWholePercent(0.0) == 0.0); +} + +static void testSemitoneSnap() { + CHECK(snapSemitonesToWhole(6.6) == 7.0); + CHECK(snapSemitonesToWhole(-6.6) == -7.0); + CHECK(snapSemitonesToWhole(0.4) == 0.0); + CHECK(depthSemitonesFromNorm(depthNormFromSemitones(snapSemitonesToWhole(6.6), kDepth), + kDepth) == 7.0); +} + +// The exponent snap reaches 1.0, the linear neutral — one snap from the dial's centre — and +// clamps into curve_law's own domain rather than rounding to a zero that is not an exponent. +static void testExponentSnap() { + CHECK(snapExponentToWhole(1.4) == 1.0); + CHECK(snapExponentToWhole(2.6) == 3.0); + CHECK(snapExponentToWhole(0.3) == util::kCurveMin); + CHECK(snapExponentToWhole(0.6) == 1.0); + CHECK(snapExponentToWhole(1e9) == util::kCurveMax); +} + +int main() { + testShiftBeatsCtrlForTheFineDragRate(); + + testStageTimeEndpointsAreExact(); + testStageTimeCeilingIsTenSeconds(); + testStageTimeLandmarksLandInTheirBands(); + testStageTimeIsMonotone(); + testEveryFinestDragStepMovesTheValue(); + testEveryWholeMicrosecondRoundTripsExactly(); + testNormRoundTripResidualStaysBelowOneDragPixel(); + testTheStageTimeDefaultsRoundTripExactly(); + + testDepthCentreAndEndsAreExact(); + testDepthIsExactlySymmetric(); + testDepthLandmarkLandsInItsBand(); + testDepthIsMonotone(); + testEveryWholeSemitoneRoundTripsExactly(); + testDegenerateThrowCollapsesToCentre(); + + testMillisecondSnap(); + testPercentSnap(); + testSemitoneSnap(); + testExponentSnap(); + + if (g_fail == 0) std::printf("param_taper: all tests passed\n"); + else std::printf("param_taper: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} From 589a8e078b31e2b151682135b181a2f0e4c7bfe2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:06:06 -0400 Subject: [PATCH 05/56] =?UTF-8?q?=CE=93-W1-T5:=20a=20real=20Preserve=20tim?= =?UTF-8?q?e-stretcher=20=E2=80=94=20write=20rate=20is=20duration,=20tap?= =?UTF-8?q?=20rate=20is=20pitch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the correlation-aligned SOLA delay line so the feed and the shift are independent rates over one ring. Unity is bit-identical to the shipped read, asserted against a hash baseline captured pre-change. --- src/core/instrument/CLAUDE.md | 4 +- src/core/instrument/engine/CMakeLists.txt | 10 +- src/core/instrument/engine/pitch_shift.cpp | 65 +++-- src/core/instrument/engine/pitch_shift.h | 39 ++- src/core/instrument/engine/time_stretch.h | 73 +++++ src/core/instrument/engine/voice.cpp | 10 +- src/core/instrument/engine/voice.h | 112 +++++--- tests/test_pitch_shift.cpp | 169 ++++++++++++ tests/test_sampler_core.cpp | 307 +++++++++++++++++++++ tests/test_time_stretch.cpp | 155 +++++++++++ 10 files changed, 873 insertions(+), 71 deletions(-) create mode 100644 src/core/instrument/engine/time_stretch.h create mode 100644 tests/test_time_stretch.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 272c14a..54c1e58 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -287,7 +287,9 @@ anything for a trigger shape. - `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot. - `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes. - `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read. -- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. +- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter AND time-stretcher for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. + - **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher** — `writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids. +- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index af6bb07..46690f9 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -32,7 +32,8 @@ reasampler_test(live_params LINK live_params) # boundary costs the hot path nothing. reasampler_pure_library(sampler_core SOURCES voice.cpp voice_engine.cpp - LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span) + LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span + time_stretch) # Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3 # or REAPER type reaching the core would fail to compile or link here. reasampler_test(sampler_core LINK sampler_core) @@ -48,3 +49,10 @@ reasampler_test(live_delivery LINK sampler_core) # The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD # both mode shapes share, and the Trigger tail's terminal behaviour. reasampler_test(staged_envelopes LINK sampler_core) + +# The Preserve read's source-feed schedule — the TIME half beside pitch_shift's PITCH half. +# Header-only (it sits on the per-sample feed), hence INTERFACE. +add_library(time_stretch INTERFACE) +target_include_directories(time_stretch INTERFACE ${REASAMPLER_SRC_DIR}) +target_link_libraries(time_stretch INTERFACE loop_span) +reasampler_test(time_stretch LINK time_stretch) diff --git a/src/core/instrument/engine/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp index 8eea981..39db550 100644 --- a/src/core/instrument/engine/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -1,8 +1,9 @@ // pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history. // -// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input -// sample (source rate, duration preserved). One active read tap advances by the shift -// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When +// Algorithm: a delay ring of 2*window frames. The write head advances one frame per source +// frame the caller feeds; the active read tap advances by the shift `ratio_` per OUTPUT frame, +// so its delay behind the writer drifts at (feedRate - ratio) per frame — one frame in, one +// frame out (`feedRate == 1`) preserves duration, and any other feed cadence stretches it. When // that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of // one window — clamped to the filled span so it never lands in unwritten silence — refined // by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a @@ -38,6 +39,7 @@ void PitchShifter::configure(std::int64_t windowFrames) { fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0; filled_ = 0; ratio_ = 1.0; + feedRate_ = 1.0; tailFrozen_ = false; lastSplice_ = SpliceEvent{}; return; @@ -85,6 +87,7 @@ void PitchShifter::reset() { } filled_ = 0; ratio_ = 1.0; + feedRate_ = 1.0; tailFrozen_ = false; lastSplice_ = SpliceEvent{}; } @@ -93,7 +96,7 @@ void PitchShifter::freezeTail() { if (window_ <= 1 || tailFrozen_) return; tailFrozen_ = true; // An in-flight crossfade was sized for a retreating writer (outgoing tap drains at - // ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so + // ratio-feedRate per frame); frozen, it closes at the full ratio instead. Cap the live fade so // it completes before tap B reaches the parked writer and reads lapped content mid-fade. if (fading_) { // Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the @@ -158,6 +161,10 @@ void PitchShifter::setShiftRatio(double ratio) { if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run the tap backward/stall) } +void PitchShifter::setFeedRate(double rate) { + if (rate > 0.0) feedRate_ = rate; +} + double PitchShifter::readTap(double pos) const { // Fractional linear interpolation with ring wrap. double p = pos; @@ -266,18 +273,19 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { while (p >= len) p -= len; posA_ = p; // Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the - // writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the - // writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade - // and play stale read-ahead data. Cap the live fade at the drain headroom actually - // available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts - // drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames, - // so they always keep the full fade. + // writer at (ratio - feedRate) per frame; the nominal window/4 fade only keeps it behind + // the writer while that rate stays under ~1 — beyond that (e.g. +24 st = ratio 4, or a + // half-speed feed under any up-shift) it would cross mid-fade and play stale read-ahead + // data. Cap the live fade at the drain headroom actually available, minus 2 (trigger + // undershoot + interpolator read-ahead margin). A drain rate at or below zero (down-shifts, + // and up-shifts the feed outruns) can't reach the ring end within window/4 frames, so those + // always keep the full fade. // // Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in - // either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap - // applies at every ratio (including unity, since delay now drains at unity too). + // either shift direction, so the drain rate is ratio_ regardless of feed and the cap applies + // at every ratio (including unity, since delay now drains at unity too). fadeLen_ = fadeFrames_; - const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0); + const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_); if (drainRate > 0.0) { const double headroom = static_cast(dLow_) - drainRate - 2.0; // Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios @@ -310,14 +318,27 @@ void PitchShifter::applySplice(const SpliceEvent& ev) { lastSplice_ = ev; // observable mirror (tests assert follower == master per frame) } -AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); } +AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr, true); } AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) { - return processImpl(in, &master); + return processImpl(in, &master, true); } -AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) { - if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) +AudioSample PitchShifter::processNoInput() { return processImpl(0.0f, nullptr, false); } + +AudioSample PitchShifter::processNoInputLinked(const SpliceEvent& master) { + return processImpl(0.0f, &master, false); +} + +void PitchShifter::writeFrame(AudioSample in) { + if (window_ <= 1 || tailFrozen_) return; + ring_[static_cast(writePos_)] = in; + if (filled_ < ringLen_) ++filled_; + if (++writePos_ >= ringLen_) writePos_ = 0; +} + +AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, bool write) { + if (window_ <= 1) return write ? in : 0.0f; // pass-through (unconfigured / degenerate) // Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer). const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{}; @@ -325,8 +346,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) // Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the // ring keeps its all-real final two windows) and hold the write head; read/splice/fade - // below run unchanged over the frozen content. - if (!tailFrozen_) { + // below run unchanged over the frozen content. A starved stretch frame (`write` false) takes + // the identical shape: no input was due this output frame, so there is nothing to write. + if (write && !tailFrozen_) { ring_[static_cast(writePos_)] = in; if (filled_ < ringLen_) ++filled_; } @@ -378,8 +400,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) } } - // Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio. - if (!tailFrozen_) { + // Advance heads: write head one frame (parked while tail-frozen or starved), tap(s) by the + // shift ratio. + if (write && !tailFrozen_) { ++writePos_; if (writePos_ >= ringLen_) writePos_ = 0; } diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index ea8fc50..832a181 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -1,11 +1,16 @@ #pragma once -// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core). +// pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core). // Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap // chases the write head at the shift ratio; when it drifts out of its safe delay band it is // relocated by a nominal window jump, refined by a cross-correlation search so the new read -// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are -// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed -// `readPos_ += ratio_` resample path). +// point is waveform-aligned, then old/new taps crossfade (raised-cosine). +// +// The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio = +// pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via +// process() holds duration and moves pitch; feeding faster/slower via writeFrame() / +// processNoInput() moves duration at whatever pitch the tap is set to. Nothing here resamples +// to preserve duration — the splice/overlap-add IS the pitch-preserving mechanism, which is +// what the "WDL_Resampler is not a Preserve engine" invariant asks for. // // Regression history — do not revert any of these: // - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann @@ -89,6 +94,13 @@ public: // ratio) so a bad input never runs the tap backward or stalls it. void setShiftRatio(double ratio); + // Source frames written per output frame — 1.0 unless the caller is stretching. Used ONLY + // to size a splice crossfade safely: the outgoing tap closes on the write head at + // (ratio - feedRate) per frame, so a fade sized against an assumed 1.0 overruns when the + // source is fed slower than the output runs and the tail of the fade reads lapped content. + // Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit. + void setFeedRate(double rate); + // Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the // pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise // writes `in` at the write head, reads the active tap (crossfading against the outgoing tap @@ -103,6 +115,20 @@ public: // their ring state advances in lockstep. RT-safe: same guarantees as process(). AudioSample processLinked(AudioSample in, const SpliceEvent& master); + // Writes one source frame WITHOUT producing an output frame — the stretch path's surplus + // input when the source is consumed faster than the output runs. No splice can fire here: + // splices are decided on the read side. No-op while unconfigured or tail-frozen, and it + // deliberately leaves lastSplice_ alone so a linked follower's schedule is unaffected. + // RT-safe. + void writeFrame(AudioSample in); + + // Produces one output frame WITHOUT consuming a source frame — the stretch path's starved + // output frame when the source is consumed slower than the output runs. Identical to + // process()/processLinked() in every other respect. Returns 0 while unconfigured (there is + // no input to pass through). RT-safe. + AudioSample processNoInput(); + AudioSample processNoInputLinked(const SpliceEvent& master); + const SpliceEvent& lastSplice() const { return lastSplice_; } // Call once the source stream is exhausted — no real frame remains to feed process(). @@ -137,7 +163,9 @@ private: void applySplice(const SpliceEvent& ev); // Shared body of process()/processLinked(); `linked` null = master mode (own trigger + // search), non-null = follower mode (splice iff linked->fired, with linked's decision). - AudioSample processImpl(AudioSample in, const SpliceEvent* linked); + // `write` false is the starved stretch frame: read/splice/advance the taps, but consume no + // input and hold the write head (the same shape tail-freezing already takes). + AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write); std::vector ring_; // delay line, length `ringLen_` == 2 * window_ std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through @@ -161,6 +189,7 @@ private: // clamps its up-jump to this so it never lands in // unwritten silence double ratio_ = 1.0; // current shift ratio (>0) + double feedRate_ = 1.0; // source frames written per output frame; splice-fade only SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared // at the top of every frame, set on a splice bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h new file mode 100644 index 0000000..545ec3d --- /dev/null +++ b/src/core/instrument/engine/time_stretch.h @@ -0,0 +1,73 @@ +#pragma once +// time_stretch — the Preserve engine's TIME half: how fast the source is consumed, given a +// playback rate. It pairs with pitch_shift's PITCH half (how fast the ring's read tap runs); +// the two rates are independent over one delay ring, and only their difference reaches the +// splice machinery. Header-inline: every member sits on the per-voice-per-sample feed. + +#include + +#include "core/instrument/engine/loop/loop_span.h" + +namespace reasampler::instrument::engine { + +// The playback rates the Preserve DSP is measured over, and therefore the only ones it +// accepts. Two independent reasons they are here and not wider: +// - the ceiling is what bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame source +// frames), which is the RT-safety argument for feeding a variable count at all; +// - the splice search can only align a period it can see. The tap's delay drifts at +// |rate - shift| per frame, so a wide rate over a deep DOWN-shift splices faster than one +// period of the output tone and the correlation stops holding the pitch: measured at rate +// 4.0 with -24 st, the observed period came out 539 frames against 785 wanted. +inline constexpr double kStretchRateMin = 0.5; +inline constexpr double kStretchRateMax = 2.0; +inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) + +// Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave +// playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to +// run the tap backward). 1.0 in gives exactly 1.0 out, which is what keeps the unity read +// bit-identical. +inline double clampStretchRate(double rate) { + if (!(rate > 0.0)) return 1.0; + if (rate < kStretchRateMin) return kStretchRateMin; + return rate > kStretchRateMax ? kStretchRateMax : rate; +} + +// One Preserve voice's source-feed schedule: a fractional source cursor answering, per OUTPUT +// frame, which whole source frames fall due. At rate 1.0 that is exactly one frame per output +// frame with no residue carried — bit for bit the pre-stretch feed. +class StretchCursor { +public: + // `frame` is where the ring prime stopped; the per-frame feed continues there. + void start(std::int64_t frame) { + frame_ = frame; + debt_ = 0.0; + } + + // Adds one output frame's worth of source at `rate` and returns how many whole source + // frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp + // lives here rather than at the caller because this return value is the loop bound. + std::int64_t due(double rate) { + debt_ += clampStretchRate(rate); + const std::int64_t whole = static_cast(debt_); // debt_ >= 0: trunc = floor + debt_ -= static_cast(whole); + return whole; + } + + // The next due source frame, wrapped into the sustain loop, advancing the cursor past it. + // Advances even past the playable span — the caller freezes the shifter's writer there, and + // a cursor that stalled instead would re-feed one frame forever. + std::int64_t next(const loop::ResolvedLoop& lp) { + if (lp.active) { + while (frame_ >= lp.end) frame_ -= lp.length; + } + return frame_++; + } + + std::int64_t frame() const { return frame_; } + +private: + std::int64_t frame_ = 0; + double debt_ = 0.0; // fractional source frames carried into the next output frame +}; + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index b64fd72..f4aee7e 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -18,7 +18,8 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) { primeBuf_.assign(windowFrames > 1 ? static_cast(windowFrames) : 0, 0.0f); } -void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) { +void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover, + double stretchRate) { // Before any state reset, record the pre-cut reference (last rendered output) and mark // the compensation pending iff this start is a takeover/steal of a sounding voice and the // caller opted in. The ramp is seeded on the first frame rendered after the restart, from @@ -59,6 +60,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_; playMode_ = p.playMode; pitchEngine_ = p.pitchEngine; + // Clamped once here so the read head's increment and the feed cursor's debt accumulate the + // SAME value — they must stay exactly one window apart for the note's whole life. + stretchRate_ = instrument::engine::clampStretchRate(stretchRate); // Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top) // rather than starting a voice already off the end. @@ -234,7 +238,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick } // Per-frame feed continues at `p` (the feed bound when the prime exhausted the // playable span). - feedPos_ = p; + stretch_.start(p); + shiftL_.setFeedRate(stretchRate_); + shiftR_.setFeedRate(stretchRate_); if (!loopWrap && primeCount < w) { // Sub-window playable span: the source is already exhausted at prime time. shiftL_.freezeTail(); diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 3c1f1eb..00e1b15 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -19,6 +19,7 @@ #include "core/instrument/engine/loop/loop_span.h" #include "core/instrument/engine/pitch_shift.h" #include "core/instrument/engine/play_params.h" +#include "core/instrument/engine/time_stretch.h" #include "core/instrument/engine/velocity_curve.h" namespace reasampler { @@ -108,7 +109,14 @@ public: // and this voice is currently active (a takeover/steal restart, not a fresh start), arms // the difference-seeded declick compensation on the first frame after the restart (see // kDeclickDecay above). A fresh start never declicks. - void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false); + // + // `stretchRate` is the PRESERVE playback rate — source frames consumed per output frame, + // clamped to [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an + // argument, not a member set separately) because the loop fold and the contour scale it + // composes with are both note-on folds. Varispeed ignores it: there, rate is a factor of the + // read increment, not a second rate. 1.0 is the shipped Preserve read, bit for bit. + void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false, + double stretchRate = 1.0); // Mono legato takeover: re-pitch this active voice to `note` without touching the // amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both @@ -441,56 +449,75 @@ private: // and the amp envelope shapes the filtered result (drive included). double outL, outRlocal = 0.0; if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { - // Feed the shifters the source stream at unity rate (duration held) and transpose - // the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the - // shift amount, not the read rate. The feed runs one window ahead of readPos_ (the - // rings were primed with that window at start()), under the same sustain-loop wrap - // rule, reading integer source frames (nothing to interpolate). Past the last real - // frame the shifter's writer is frozen — it recycles the real tail it already holds. - if (loop.active) { - while (feedPos_ >= loop.end) feedPos_ -= loop.length; - } - // feedPos_ runs one window ahead of readPos_; the last real source frame is - // playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound - // the source is exhausted — feeding the held last sample instead would give the - // splice correlation a DC plateau it can't align on (periodic troughs at the splice - // cadence, growing toward the note end). Freezing the shifter's writer means no - // padding ever enters the ring, so the splice machinery keeps recycling the frozen - // all-real tail — a continuous tone through the voice's own end. The sustain-loop - // path never gets here: the wrap above keeps feedPos_ < loop.end forever. + // The two rates the shifter takes (pitch_shift.h owns why they are independent): + // the source is FED at stretchRate_, and the tap is SHIFTED by + // 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount, + // never to the read rate. The feed runs one window ahead of readPos_ (the rings were + // primed with that window at start()), under the same sustain-loop wrap rule, + // reading integer source frames (nothing to interpolate). + const bool stereoOut = stereo && haveR && shiftR_.configured(); + // The last real source frame is playEnd_-1 for Trigger or frameCount-1 for Gate. + // Once the feed reaches that bound the source is exhausted — feeding the held last + // sample instead would give the splice correlation a DC plateau it can't align on + // (periodic troughs at the splice cadence, growing toward the note end). Freezing the + // shifter's writer means no padding ever enters the ring, so the splice machinery + // keeps recycling the frozen all-real tail — a continuous tone through the voice's + // own end. The sustain-loop path never gets here: the wrap keeps the cursor inside + // the loop forever. const std::int64_t feedBound = (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) ? playEnd_ : frameCount; - const bool exhausted = feedPos_ >= feedBound; - if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen - const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount); - // Crossfaded on the way IN to the shifter, not on the way out: loop the source, - // shift the output. - const double feedXw = crossfadeWeight(loop, static_cast(feedPos_)); - const AudioSample feedL = - feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f; const double shift = baseRatio_ * envFactor; shiftL_.setShiftRatio(shift); - const double shiftedL = static_cast(shiftL_.process(feedL)); + if (stereoOut) shiftR_.setShiftRatio(shift); + + // 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are + // written without producing output; the last rides the ordinary 1-in-1-out + // process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged. + // Crossfaded on the way IN to the shifter, not on the way out: loop the source, + // shift the output. + const std::int64_t due = stretch_.due(stretchRate_); + AudioSample feedL = 0.0f, feedR = 0.0f; + bool fed = false; + for (std::int64_t k = 0; k < due; ++k) { + if (fed) { // an earlier frame of this batch: write-only, no output + shiftL_.writeFrame(feedL); + if (stereoOut) shiftR_.writeFrame(feedR); + } + const std::int64_t q = stretch_.next(loop); + if (q >= feedBound) { + shiftL_.freezeTail(); // idempotent; input ignored while frozen + if (stereoOut) shiftR_.freezeTail(); + feedL = feedR = 0.0f; + } else { + const double xw = crossfadeWeight(loop, static_cast(q)); + feedL = crossfadedSource(pcm, loop, q, xw); + if (stereoOut) feedR = crossfadedSource(pcmR, loop, q, xw); + } + fed = true; + } + const double shiftedL = + fed ? static_cast(shiftL_.process(feedL)) + : static_cast(shiftL_.processNoInput()); outL = shiftedL; if (stereo) { - if (haveR && shiftR_.configured()) { + if (stereoOut) { // Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's // splice decisions via processLinked — one correlation search, one lag, one // splice schedule for both channels (standard stereo SOLA). An independent // per-channel search re-drew an inter-channel offset of up to +/-maxLag at // every splice: stereo image wander at the splice cadence + mono-sum // combing. Each shifter is still processed EXACTLY ONCE per output frame - // (never twice — that would advance its heads twice and corrupt the state). + // (never twice — that would advance its heads twice and corrupt the state); + // the batch's earlier frames go through writeFrame, which produces none. // Gated on haveR so a MONO sample never touches shiftR_ — start() only // primes it for genuinely stereo samples, and a stale un-primed ring must // not leak a previous note. - if (exhausted) shiftR_.freezeTail(); - const AudioSample feedR = - feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f; - shiftR_.setShiftRatio(shift); outRlocal = - static_cast(shiftR_.processLinked(feedR, shiftL_.lastSplice())); + fed ? static_cast( + shiftR_.processLinked(feedR, shiftL_.lastSplice())) + : static_cast( + shiftR_.processNoInputLinked(shiftL_.lastSplice())); } else { // Mono sample in stereo mode (dual-mono): shiftL_ already produced the // shifted value from the mono feed; mirror it to R. Do NOT call @@ -498,9 +525,10 @@ private: outRlocal = shiftedL; } } - ++feedPos_; - // Preserve advances the read head at the SOURCE rate (duration preserved). - ratio_ = 1.0; + // Preserve advances the read head at the STRETCH rate — the one duration control. + // Everything downstream of it (the loop wrap, the Trigger span, the spline phase) + // therefore stays a source-frame fact and scales by construction. + ratio_ = stretchRate_; } else { // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the // pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when @@ -676,9 +704,10 @@ private: // // The shifter rings are primed at start() with the first window of the actual upcoming // source (silence past the end) — output frame 0 is source frame `start`, no ring-fill - // silence, and splices always land in real history. feedPos_ is the integer source frame - // fed to the shifters next; it runs exactly one window ahead of readPos_ under the same - // sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end; + // silence, and splices always land in real history. stretch_ is the integer source frame + // fed to the shifters next plus the fractional rate debt; it runs one window ahead of + // readPos_ under the same sustain-loop wrap rule and at the same rate, so the two stay one + // window apart at every stretch. Once it passes the last real frame (Gate: sample end; // Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the // splice machinery recycles the frozen real tail through the note end (see advanceFrame). // primeBuf_ is the presized scratch the prime stream is assembled into. @@ -686,7 +715,8 @@ private: PitchEnvelope pitchEnv_; PitchShifter shiftL_; PitchShifter shiftR_; - std::int64_t feedPos_ = 0; + instrument::engine::StretchCursor stretch_; + double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on std::vector primeBuf_; // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start() diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index 768e45e..ffd1f17 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -23,6 +23,10 @@ // 6. unity + latency contract — asserted bit-exactly: a warm()ed shifter at ratio 1.0 IS a // clean window delay; a prime()d one has ZERO added latency (out[i] == src[i] to the // bit) — the GA2 immediate-onset claim. +// 9. time-stretch — the write rate (duration) and the tap rate (pitch) are independent: a +// source fed faster/slower than the output runs moves along the output timeline with its +// pitch untouched, composes with the full transposition range, and never resamples to do +// it. A resampled read is the explicit non-tautology witness in the duration test. // 8. stereo linked lag (Q-W0 T1-01) — a follower channel driven via processLinked() mirrors // the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on // decorrelated stereo content where an independent per-channel search provably diverges. @@ -581,6 +585,168 @@ static void testStereoLinkedLagSharedSchedule() { CHECK(!mirrorDiverged); // applySplice() reproduces splice() bit-identically } +// --- 9. Time-stretch: the WRITE rate is duration, the TAP rate is pitch, and they are +// independent. Feeding faster/slower than the output runs moves the content along the +// output timeline WITHOUT moving its pitch — no resampling anywhere, which is what +// "WDL_Resampler is not a Preserve engine" asks for. --- + +// Drives the shifter with a fractional feed rate the way the Voice does: all but the last +// source frame due on an output frame go through writeFrame (no output), the last through +// process(); an output frame with none due takes processNoInput(). Returns the output plus, +// via `consumed`, how much source it ate. +static std::vector runStretch(const std::vector& src, std::int64_t w, + double feedRate, double shift, std::size_t outFrames, + std::size_t* consumed) { + PitchShifter ps; + ps.configure(w); + ps.prime(src.data(), w); + ps.setShiftRatio(shift); + ps.setFeedRate(feedRate); + std::size_t pos = static_cast(w); + double debt = 0.0; + std::vector out(outFrames); + for (std::size_t i = 0; i < outFrames; ++i) { + debt += feedRate; + const int due = static_cast(debt); + debt -= static_cast(due); + AudioSample last = 0.0f; + bool fed = false; + for (int k = 0; k < due; ++k) { + if (fed) ps.writeFrame(last); + last = pos < src.size() ? src[pos] : 0.0f; + ++pos; + fed = true; + } + out[i] = static_cast(fed ? ps.process(last) : ps.processNoInput()); + } + if (consumed != nullptr) *consumed = pos - static_cast(w); + return out; +} + +// Mean spacing between positive-going zero crossings over [from, to). +static double periodIn(const std::vector& v, std::size_t from, std::size_t to) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = from + 1; i < to; ++i) { + if (v[i - 1] <= 0.0 && v[i] > 0.0) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; +} + +static void testStretchMovesDurationNotPitch() { + // A source that changes pitch ONCE, at a known source frame: period 200 before it, period + // 100 after. Where that change lands in the OUTPUT is duration; what the two periods + // measure is pitch. A stretcher moves the first and not the second; a resampled read moves + // both, which is exactly the distinction under test. + const std::int64_t w = 2205; + const std::size_t change = 40000; // source frame where the period halves + const std::size_t srcLen = 160000; + std::vector src(srcLen); + double phase = 0.0; + for (std::size_t i = 0; i < srcLen; ++i) { + phase += 2.0 * kPi / (i < change ? 200.0 : 100.0); + src[i] = static_cast(std::sin(phase)); + } + for (double rate : {0.5, 1.0, 2.0}) { + // Duration: the source is consumed at the feed rate, so the change lands at + // change/rate in the output — the run is sized to reach past it at every rate. + const std::size_t changeOut = static_cast(change / rate); + const std::size_t outFrames = changeOut + 12000; + std::size_t consumed = 0; + const std::vector out = + runStretch(src, w, rate, /*shift=*/1.0, outFrames, &consumed); + // Pitch: measured well clear of the transition on both sides, and UNCHANGED by the + // rate — 200 before, 100 after, at 0.5x, 1x and 2x alike. + const double before = periodIn(out, changeOut / 4, changeOut / 4 + 6000); + const double after = periodIn(out, changeOut + 2000, changeOut + 8000); + CHECK(approx(before, 200.0, 10.0)); + CHECK(approx(after, 100.0, 5.0)); + // Non-tautology witness: a RESAMPLED read of the same source at the same rate would + // have produced 200/rate and 100/rate here. At rate != 1 those differ from the + // measurements above by far more than the tolerances, so the assertions genuinely + // separate a stretch from a resample. + if (rate != 1.0) { + CHECK(std::fabs(before - 200.0 / rate) > 20.0); + CHECK(std::fabs(after - 100.0 / rate) > 20.0); + } + // ...and the source really was consumed at the rate (the duration half of the claim). + CHECK(approx(static_cast(consumed), + static_cast(outFrames) * rate, 2.0)); + // No dead stretches anywhere, frame 0 included: the stretch path must not reintroduce + // the onset gap prime() exists to close. + std::size_t worstGap = 0, run = 0; + for (std::size_t i = 0; i < outFrames; ++i) { + if (std::fabs(out[i]) < 1e-3) { + ++run; + if (run > worstGap) worstGap = run; + } else { + run = 0; + } + } + CHECK(worstGap < 32); + } +} + +// The stretch and the transposition compose over the SAME ring, and a feed rate the shift does +// not match is where the splice fade's headroom is tightest (the outgoing tap closes on the +// writer at ratio - feedRate, which setFeedRate exists to tell it). Bounded, finite, gap-free +// across the corners of the engine's rate range crossed with the full transposition range. +static void testStretchAndShiftComposeSafely() { + const std::int64_t w = 2205; + const double f0 = 1.0 / 196.37; // the adversarial non-integer period + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); + } + for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) { + for (double semis : {-24.0, -12.0, -5.0, 0.0, 7.0, 12.0, 24.0}) { + const double shift = std::pow(2.0, semis / 12.0); + const std::size_t outFrames = 60000; + const std::vector out = + runStretch(src, w, rate, shift, outFrames, nullptr); + std::size_t worstGap = 0, run = 0; + double peak = 0.0; + for (std::size_t i = 0; i < outFrames; ++i) { + CHECK(std::isfinite(out[i])); + const double a = std::fabs(out[i]); + if (a > peak) peak = a; + if (a < 1e-3) { + ++run; + if (run > worstGap) worstGap = run; + } else { + run = 0; + } + } + CHECK(worstGap < 32); // continuous: every splice landed in real, aligned history + CHECK(peak < 1.2); // complementary fades: no cancellation, no bulge + CHECK(peak > 0.8); // ...and it played at full level + // Pitch is the TAP's, not the feed's: the observed period is the source period + // divided by the shift, whatever the rate. + const double p = periodIn(out, 20000, 50000); + if (!approx(p, 196.37 / shift, 196.37 / shift * 0.12)) { + std::printf(" rate %.2f semis %.0f: period %.2f want %.2f\n", rate, semis, p, + 196.37 / shift); + } + CHECK(approx(p, 196.37 / shift, 196.37 / shift * 0.12)); + } + } +} + +// The two new entry points on a shifter that was never configured (a Varispeed voice's) — +// neither may touch the empty ring. +static void testStretchEntryPointsOnPassThrough() { + PitchShifter ps; + CHECK(!ps.configured()); + ps.writeFrame(0.5f); // no ring to write into + CHECK(ps.processNoInput() == 0.0f); // no input to pass through + CHECK(ps.process(0.25f) == 0.25f); // and the 1:1 path still passes through +} + int main() { testDurationInvariance(); testUnityRoughlyReproduces(); @@ -590,6 +756,9 @@ int main() { testUnityBitExactAndLatency(); testFreezeTailContinuousTone(); testStereoLinkedLagSharedSchedule(); + testStretchMovesDurationNotPitch(); + testStretchAndShiftComposeSafely(); + testStretchEntryPointsOnPassThrough(); if (g_fail == 0) { std::printf("all pitch_shift tests passed\n"); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 16cc49c..8b6c059 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -21,7 +21,10 @@ #include #include +#include #include +#include +#include #include using namespace reasampler; @@ -2880,6 +2883,303 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() { CHECK(blockPeak(out, 0, frames) > 0.5); // and it genuinely played at full level } +// --------------------------------------------------------------------------- +// The Preserve read path's stretch generalization: the source is consumed at the playback +// rate while the shifter's read tap runs at the transposition, over ONE delay ring. Only +// their DIFFERENCE reaches the splice machinery. +// --------------------------------------------------------------------------- + +// FNV-1a over the raw float bits — an exact-stream witness, not a tolerance. +static std::uint64_t hashStream(const std::vector& v) { + std::uint64_t h = 1469598103934665603ull; + for (const AudioSample s : v) { + std::uint32_t bits = 0; + std::memcpy(&bits, &s, sizeof(bits)); + for (int b = 0; b < 4; ++b) { + h ^= static_cast((bits >> (8 * b)) & 0xffu); + h *= 1099511628211ull; + } + } + return h; +} + +// A source with no symmetry a shifter could accidentally satisfy: a sine at a non-integer +// period plus a deterministic pseudo-random dither, so any change in the splice schedule, +// the fed frame sequence or the tap position moves the hash. +static SampleData stretchProbeSample(std::size_t frames, bool stereo) { + SampleData s; + s.frames.resize(frames); + if (stereo) s.framesR.resize(frames); + std::uint32_t lcg = 12345u; + for (std::size_t i = 0; i < frames; ++i) { + lcg = lcg * 1664525u + 1013904223u; + const double n = static_cast(lcg >> 8) / 8388608.0 - 1.0; // [-1,1) + const double t = static_cast(i); + s.frames[i] = static_cast(0.8 * std::sin(2.0 * kPi * t / 196.37) + 0.1 * n); + if (stereo) { + s.framesR[i] = + static_cast(0.8 * std::sin(2.0 * kPi * t / 123.13) - 0.1 * n); + } + } + s.rootNote = 60; + s.sampleRate = 44100; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + return s; +} + +// Renders one raw Voice (not through VoiceEngine, which publishes no rate) for `outFrames`. +static void renderVoice(const SampleData& s, int note, double rate, std::int64_t window, + bool stereo, std::vector& l, std::vector& r) { + Voice v; + v.presizePreserveShifters(window); + v.start(note, 127, s, /*declickTakeover=*/false, rate); + for (std::size_t i = 0; i < l.size(); ++i) { + if (stereo) { + AudioSample a = 0.0f, b = 0.0f; + v.renderFrameStereo(a, b); + l[i] = a; + r[i] = b; + } else { + l[i] = v.renderFrame(); + } + } +} + +// --- The null case, asserted against a baseline the SHIPPED engine produced. --- +// The four constants below were captured by running this same function against the +// pre-stretch build (phase-g, before the rate seam existed) and printing the hashes; they are +// therefore a witness that the generalized read path reproduces the shipped Preserve output +// bit for bit at rate 1.0, not a self-consistency check. A change here is a change to what +// every already-saved project sounds like — re-derive the cause before re-baselining. +static void testPreserveUnityRateIsBitIdenticalToTheShippedRead() { + const std::int64_t w = 2205; // the product window at 44.1k + const std::size_t n = 6000; + struct Case { + int note; + bool stereo; + bool loop; + std::uint64_t hashL; + std::uint64_t hashR; + }; + const Case cases[] = { + {60, false, false, 16118581538698271917ull, 0ull}, // on root: unity shift + {67, false, false, 17268489061432447375ull, 0ull}, // +7 st: real splices + {55, false, false, 17626155132441637249ull, 0ull}, // -5 st: down-shift + {67, true, true, 116487689553455907ull, 9528575457480122654ull}, // stereo linked + loop + }; + for (const Case& c : cases) { + SampleData s = stretchProbeSample(4000, c.stereo); + if (c.loop) { + s.loop.hasLoop = true; + s.loop.start = 1200; + s.loop.end = 3600; + s.loopCrossfadeFrames = 256; + } + std::vector l(n), r(c.stereo ? n : 0); + renderVoice(s, c.note, /*rate=*/1.0, w, c.stereo, l, r); + const std::uint64_t hl = hashStream(l); + CHECK(hl == c.hashL); + if (hl != c.hashL) std::printf(" note %d L hash %lluull\n", c.note, hl); + if (c.stereo) { + const std::uint64_t hr = hashStream(r); + CHECK(hr == c.hashR); + if (hr != c.hashR) std::printf(" note %d R hash %lluull\n", c.note, hr); + } + } +} + +// --- Rate changes DURATION only; the transposition alone sets pitch. --- +static void testPreserveStretchChangesDurationNotPitch() { + // Gate, no loop: the voice's life is exactly how long the source lasts, so the frame at + // which it goes idle IS the note's duration. + const std::int64_t w = 1024; + const std::size_t frames = 24000; + const double srcPeriod = 160.0; + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / srcPeriod)); + } + s.rootNote = 60; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + + auto run = [&](double rate, PitchEngine engine, std::size_t& lifeFrames) { + SampleData local = s; + local.play.pitchEngine = engine; + Voice v; + v.presizePreserveShifters(w); + v.start(60, 127, local, /*declickTakeover=*/false, rate); + std::vector out; + out.reserve(frames * 3); + lifeFrames = 0; + for (std::size_t i = 0; i < frames * 3 && v.active(); ++i) { + out.push_back(v.renderFrame()); + ++lifeFrames; + } + return out; + }; + + std::size_t lifeUnity = 0, lifeSlow = 0, lifeFast = 0; + const std::vector unity = run(1.0, PitchEngine::Preserve, lifeUnity); + const std::vector slow = run(0.5, PitchEngine::Preserve, lifeSlow); + const std::vector fast = run(2.0, PitchEngine::Preserve, lifeFast); + + // Duration scales by 1/rate (the small excess over the source length is the terminal + // declick ring-out Preserve ends on). + CHECK(approx(static_cast(lifeUnity), 24000.0, 200.0)); + CHECK(approx(static_cast(lifeSlow), 48000.0, 400.0)); + CHECK(approx(static_cast(lifeFast), 12000.0, 200.0)); + + // ...and the pitch does not move with it. Measured away from the onset and the tail. + auto period = [](const std::vector& v, std::size_t from, std::size_t to) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { + if (v[i - 1] <= 0.0f && v[i] > 0.0f) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; + }; + CHECK(approx(period(unity, 2000, 9000), srcPeriod, 8.0)); + CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0)); + CHECK(approx(period(fast, 2000, 9000), srcPeriod, 8.0)); + + // The non-tautology witness: VARISPEED is the engine that couples them. Reaching the same + // durations there costs exactly the pitch change Preserve refuses to make — so the three + // equal periods above are a property of the stretcher, not of the measurement. + std::size_t lifeVari = 0; + const std::vector vari = run(0.5, PitchEngine::Varispeed, lifeVari); + CHECK(approx(static_cast(lifeVari), 24000.0, 200.0)); // rate ignored under Varispeed + SampleData down = s; + down.play.pitchEngine = PitchEngine::Varispeed; + Voice vv; + vv.presizePreserveShifters(w); + vv.start(48, 127, down); // -12 st under Varispeed: duration doubles AND pitch halves + std::vector variDown; + std::size_t variLife = 0; + for (std::size_t i = 0; i < frames * 3 && vv.active(); ++i) { + variDown.push_back(vv.renderFrame()); + ++variLife; + } + CHECK(approx(static_cast(variLife), 48000.0, 200.0)); // same duration... + CHECK(approx(period(variDown, 2000, 9000), srcPeriod * 2.0, 16.0)); // ...at half pitch +} + +// --- The onset is a regression surface: no added latency at ANY rate. --- +static void testPreserveStretchSpeaksOnFrameZeroAtEveryRate() { + const std::int64_t w = 2048; + SampleData s = stretchProbeSample(12000, false); + s.startFrame = 500; // and the first output frame is the START frame, not frame 0 + for (double rate : {0.5, 1.0, 2.0}) { + for (int note : {48, 60, 67}) { + Voice v; + v.presizePreserveShifters(w); + v.start(note, 127, s, /*declickTakeover=*/false, rate); + const AudioSample first = v.renderFrame(); + // The primed ring parks the tap ON the start frame, so output frame 0 is source + // frame `startFrame` exactly — at every rate and every transposition. A stretcher + // that buffered a window before speaking would fail here, which is the whole point. + CHECK(first == s.frames[500]); + // ...and it keeps speaking: no first-window dip while the schedule settles. The + // 256-frame measuring window spans most of a period even at the lowest note tested + // (-12 st stretches the probe's 196-frame period to 393), so a continuous tone + // peaks well above the floor in every one of them and only a real gap can sink it. + double lo = 1e9; + for (int i = 0; i < 20; ++i) { + double peak = 0.0; + for (int k = 0; k < 256; ++k) { + peak = (std::max)(peak, std::fabs(static_cast(v.renderFrame()))); + } + lo = (std::min)(lo, peak); + } + if (!(lo > 0.5)) std::printf(" rate %.2f note %d: lo %.3f\n", rate, note, lo); + CHECK(lo > 0.5); + } + } +} + +// --- "Loop the source, shift the output" is unweakened by a stretch. --- +static void testPreserveStretchLoopsTheSourceSpan() { + for (double rate : {0.5, 1.0, 2.0}) { + for (int note : {48, 60, 72}) { + SampleData s; + s.frames.resize(200, 0.0f); + for (int i = 60; i < 120; ++i) s.frames[i] = 0.5f; + s.rootNote = 60; + s.sampleRate = 48000; + s.loop.hasLoop = true; + s.loop.start = 80; + s.loop.end = 120; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + Voice v; + v.presizePreserveShifters(64); + v.start(note, 127, s, /*declickTakeover=*/false, rate); + std::vector out(4000); + for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame(); + // The loop is a SOURCE-frame fact, so it keeps the voice alive and at level for as + // long as it is held, whatever the rate consumes it at. + CHECK(v.active()); + double sum = 0.0; + for (std::size_t i = out.size() - 200; i < out.size(); ++i) sum += out[i]; + CHECK(approx(sum / 200.0, 0.5, 0.05)); + } + } +} + +// --- The 32-voice measurement gate. Asserts correctness; PRINTS the cost, which is the +// number reported for the algorithm decision (meaningful only in a Release build). --- +static void testPreserveStretchThirtyTwoVoicesHoldUp() { + const std::int64_t w = 2205; // the product window at 44.1k + const std::size_t blockFrames = 44100; // one second of audio + const std::size_t voiceCount = 32; + SampleData s = stretchProbeSample(200000, true); + s.loop.hasLoop = true; // held notes: all 32 sound for the whole run + s.loop.start = 40000; + s.loop.end = 160000; + s.loopCrossfadeFrames = 1024; + + // 1.0 is the reference: it is the cost the shipped Preserve read already carries, so the + // two stretched rows are read as a delta against it rather than in isolation. + for (double rate : {1.0, 0.5, 2.0}) { + std::vector voices(voiceCount); + for (std::size_t i = 0; i < voiceCount; ++i) { + voices[i].presizePreserveShifters(w); + voices[i].start(48 + static_cast(i), 100, s, /*declickTakeover=*/false, rate); + } + const std::clock_t t0 = std::clock(); + double guard = 0.0; + std::size_t sounding = 0; + for (std::size_t f = 0; f < blockFrames; ++f) { + AudioSample l = 0.0f, r = 0.0f; + for (std::size_t i = 0; i < voiceCount; ++i) { + AudioSample a = 0.0f, b = 0.0f; + voices[i].renderFrameStereo(a, b); + l += a; + r += b; + } + guard += static_cast(l) + static_cast(r); + CHECK(std::isfinite(l) && std::isfinite(r)); + } + const double secs = static_cast(std::clock() - t0) / CLOCKS_PER_SEC; + for (std::size_t i = 0; i < voiceCount; ++i) { + if (voices[i].active()) ++sounding; + } + CHECK(sounding == voiceCount); // all 32 held the whole second (the loop kept them up) + CHECK(std::fabs(guard) > 0.0); // ...and genuinely produced audio + std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: %.3f s wall for 1.0 s " + "audio (%.1f%% of one core, %.1f ns/voice/frame)\n", + rate, secs, 100.0 * secs, + secs * 1e9 / (static_cast(blockFrames) * + static_cast(voiceCount))); + } +} + int main() { testEveryKeyPlaysTheLoadedCapture(); testUnplayableCaptureRefusesEveryNote(); @@ -3006,6 +3306,13 @@ int main() { testPreservePrimeStopsAtTriggerPlayEnd(); testPreserveSubWindowSampleNoZeroPadInRing(); + // The Preserve read path's stretch generalization. + testPreserveUnityRateIsBitIdenticalToTheShippedRead(); + testPreserveStretchChangesDurationNotPitch(); + testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); + testPreserveStretchLoopsTheSourceSpan(); + testPreserveStretchThirtyTwoVoicesHoldUp(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0; diff --git a/tests/test_time_stretch.cpp b/tests/test_time_stretch.cpp new file mode 100644 index 0000000..b01b831 --- /dev/null +++ b/tests/test_time_stretch.cpp @@ -0,0 +1,155 @@ +// Standalone tests for reasampler::instrument::engine::StretchCursor — the Preserve read's +// source-feed schedule. No VST3, no REAPER, no vendor, no test framework. +// +// Covers: +// 1. rate 1.0 is EXACTLY one source frame per output frame, forever and with no residue — +// the mechanism behind the "unity is bit-identical to the shipped Preserve read" gate. +// 2. the schedule tracks the rate: over N output frames the cursor consumes N*rate source +// frames to within one, at rates either side of unity and at irrational ones. +// 3. the per-output-frame feed count never exceeds kMaxFeedPerFrame — the bound that makes +// a variable-length feed loop RT-safe. +// 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a +// silent stall or a quarter-speed surprise). +// 5. the sustain loop wraps the cursor and never lets it leave [start, end) — "loop the +// source" holds at every rate, including one that steps over the loop end. + +#include "../src/core/instrument/engine/time_stretch.h" + +#include +#include +#include + +using namespace reasampler::instrument::engine; +using reasampler::instrument::engine::loop::ResolvedLoop; + +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 ResolvedLoop noLoop() { return ResolvedLoop{}; } + +static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) { + ResolvedLoop lp; + lp.active = true; + lp.start = start; + lp.end = end; + lp.length = end - start; + return lp; +} + +// --- 1. Unity is exactly one frame per output frame, with no drifting residue. --- +static void testUnityRateFeedsExactlyOneFramePerOutputFrame() { + StretchCursor c; + c.start(100); + const ResolvedLoop lp = noLoop(); + for (std::int64_t i = 0; i < 200000; ++i) { + CHECK(c.due(1.0) == 1); + CHECK(c.next(lp) == 100 + i); + } + // No accumulated debt after 200k frames: the source frame the cursor is about to feed is + // exactly the one an un-stretched integer walk would be at. A residue of even one frame + // over a long note would move the shipped Preserve output. + CHECK(c.frame() == 100 + 200000); +} + +// --- 2. The schedule tracks the rate. --- +static void testTotalConsumedTracksTheRate() { + const ResolvedLoop lp = noLoop(); + // Includes a rate with no exact binary representation, where a naive per-frame rounding + // would drift without bound rather than carrying the residue. + for (double rate : {0.5, 0.75, 1.0, 1.3333333333333333, 2.0, 1.0 / 3.0 + 1.0}) { + StretchCursor c; + c.start(0); + const std::int64_t outFrames = 100000; + for (std::int64_t i = 0; i < outFrames; ++i) { + const std::int64_t due = c.due(rate); + for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp); + } + const double expected = static_cast(outFrames) * clampStretchRate(rate); + CHECK(std::fabs(static_cast(c.frame()) - expected) <= 1.0); + } +} + +// --- 3. The feed count is bounded — the RT-safety argument for a variable-length loop. --- +static void testFeedPerOutputFrameIsBounded() { + const ResolvedLoop lp = noLoop(); + // Drive at, above and around the ceiling; an unclamped rate would run the caller's loop + // for as many iterations as the rate names. + for (double rate : {kStretchRateMax, kStretchRateMax * 100.0, 3.99, 2.5}) { + StretchCursor c; + c.start(0); + std::int64_t worst = 0; + for (std::int64_t i = 0; i < 20000; ++i) { + const std::int64_t due = c.due(rate); + if (due > worst) worst = due; + for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp); + } + CHECK(worst <= kMaxFeedPerFrame); + CHECK(worst >= 1); // and the bound is not vacuous — frames genuinely fell due + } +} + +// --- 4. The clamp. --- +static void testRateClamp() { + CHECK(clampStretchRate(1.0) == 1.0); // exact: the unity read depends on it + CHECK(clampStretchRate(0.5) == 0.5); + CHECK(clampStretchRate(2.0) == 2.0); + CHECK(clampStretchRate(0.001) == kStretchRateMin); + CHECK(clampStretchRate(1000.0) == kStretchRateMax); + // Unusable input plays at speed rather than stalling or quarter-speeding. + CHECK(clampStretchRate(0.0) == 1.0); + CHECK(clampStretchRate(-2.0) == 1.0); + CHECK(clampStretchRate(std::nan("")) == 1.0); + // ...and the cursor honours it rather than looping on the raw value. + StretchCursor c; + c.start(0); + CHECK(c.due(-5.0) == 1); // folded to unity + StretchCursor d; + d.start(0); + CHECK(d.due(50.0) <= kMaxFeedPerFrame); +} + +// --- 5. The loop wraps the SOURCE cursor, at every rate. --- +static void testCursorStaysInsideTheLoopSpan() { + const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it + for (double rate : {0.5, 1.0, 2.0, 4.0}) { + StretchCursor c; + c.start(1000); + std::int64_t lowest = 1 << 30, highest = -1; + for (std::int64_t i = 0; i < 50000; ++i) { + const std::int64_t due = c.due(rate); + for (std::int64_t k = 0; k < due; ++k) { + const std::int64_t q = c.next(lp); + if (q < lowest) lowest = q; + if (q > highest) highest = q; + } + } + // Never reads outside the span — the "loop the source, shift the output" contract does + // not weaken under a stretch, because the span is a source-frame fact. + CHECK(lowest >= lp.start); + CHECK(highest < lp.end); + CHECK(highest == lp.end - 1); // and it genuinely covered the span + CHECK(lowest == lp.start); + } + // A cursor started BEYOND the loop end (the start-point-past-the-loop case) is pulled in on + // its first take rather than reading off the end. + StretchCursor c; + c.start(5000); + const std::int64_t q = c.next(lp); + CHECK(q >= lp.start && q < lp.end); +} + +int main() { + testUnityRateFeedsExactlyOneFramePerOutputFrame(); + testTotalConsumedTracksTheRate(); + testFeedPerOutputFrameIsBounded(); + testRateClamp(); + testCursorStaysInsideTheLoopSpan(); + + if (g_fail == 0) { + std::printf("all time_stretch tests passed\n"); + return 0; + } + std::printf("%d time_stretch check(s) failed\n", g_fail); + return 1; +} From ee8a956fbdf291f4bb8ae94572c60b0c831fd190 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 20:07:48 -0400 Subject: [PATCH 06/56] =?UTF-8?q?=CE=93-W1-T1=20review=20fixes:=20mode-ind?= =?UTF-8?q?ependent=20taper=20rounding,=20sharper=20drag-step=20test,=20re?= =?UTF-8?q?set-sweep=20verifies=20stored=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap nearbyint for std::round (MXCSR-independent); derive the finest-drag test from the editor floor, not the knob; verify resets against fields, not norms; record the spline-point modifier exclusion. --- src/core/instrument/CLAUDE.md | 2 +- src/core/instrument/ui/CMakeLists.txt | 5 ++- src/core/instrument/ui/deck_values.cpp | 13 +++----- src/core/instrument/ui/deck_values.h | 10 ++++++ src/core/instrument/ui/param_taper.cpp | 16 ++++++---- src/core/instrument/ui/param_taper.h | 15 +++++---- tests/test_deck_values.cpp | 25 +++++++++++---- tests/test_param_taper.cpp | 42 ++++++++++++++++++++++++-- 8 files changed, 96 insertions(+), 32 deletions(-) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index cc82441..ada6c38 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -334,7 +334,7 @@ anything for a trigger shape. parameter set does not carry (key-track, voice count, master gain, preview velocity) and the labels for them. - `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there. -- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. +- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. - `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 6138e9e..114ac7b 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -100,4 +100,7 @@ reasampler_test(curve_popup LINK curve_popup velocity_curve) # envelope_overlay and deck_values all read it — which is exactly why it could not stay inside # deck_values, which sits above envelope_overlay. reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_law) -reasampler_test(param_taper LINK param_taper) +# envelope_overlay and sample_bands are linked for the test only: the finest-drag-step assertion +# is judged against the envelope node drag at the editor's own floor width (the sharper of the +# taper's two consumers), read from the allocator/overlay rather than copied as a number. +reasampler_test(param_taper LINK param_taper envelope_overlay sample_bands) diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index a391fc5..782ea2f 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -16,7 +16,6 @@ namespace reasampler::instrument::ui { using engine::filter::MorphLaw; using util::clamp01; - double deckParamNorm(DeckParam id, const PlaySeconds& play) { switch (id) { case DeckParam::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; @@ -197,12 +196,10 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { enforceGateUnavailableWhileDrawn(play); } -namespace { - -// The ADDRESS of the one stored field a knob id owns. deckParamNorm and setDeckParam carry each -// id's MAP — which taper, which clamp; this carries only its LOCATION, which is the whole -// mechanism of the taper-free reset. A toggle, radio or curve cell has no reset gesture and -// resolves to null. +// deckParamNorm and setDeckParam carry each id's MAP — which taper, which clamp; these two carry +// only its LOCATION, which is the whole mechanism of the taper-free reset (see deck_values.h for +// why they are exposed beyond that one caller). A toggle, radio or curve cell has no reset gesture +// and resolves to null. double* deckDoubleField(DeckParam id, PlaySeconds& p) { switch (id) { case DeckParam::kAttack: return &p.adsr.attackSeconds; @@ -257,8 +254,6 @@ float* deckFloatField(DeckParam id, PlaySeconds& p) { } } -} // namespace - void resetDeckParam(DeckParam id, PlaySeconds& play) { PlaySeconds defaults; if (double* dst = deckDoubleField(id, play)) { diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index 8c23804..b27b46f 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -48,6 +48,16 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment); // For knob-valued controls — a toggle has no reset gesture. void resetDeckParam(DeckParam id, PlaySeconds& play); +// The ADDRESS of the one stored field `id` owns — the mechanism resetDeckParam bypasses the taper +// with. Exposed beyond that one caller so a test can verify a reset (or any other mutation) +// against the actual stored field rather than its normalized read-back, which deckParamNorm does +// not guarantee is injective. Null for a control with no reset gesture (a toggle, radio, or +// curve-popup cell) or one whose value lives outside PlaySeconds (master gain, key-track). +double* deckDoubleField(DeckParam id, PlaySeconds& p); +// The filter's four tone controls store their normalized position as float — see deckFloatField's +// definition for why that is a second resolver rather than a widened first one. +float* deckFloatField(DeckParam id, PlaySeconds& p); + // THE snap-unit table: which whole unit Shift snaps each control to. Includes the deck's // processor-side ids (voice count, master gain), which have no entry in the two functions above // because their VALUE lives outside the parameter set — the unit does not. diff --git a/src/core/instrument/ui/param_taper.cpp b/src/core/instrument/ui/param_taper.cpp index 647d589..59cc49f 100644 --- a/src/core/instrument/ui/param_taper.cpp +++ b/src/core/instrument/ui/param_taper.cpp @@ -8,14 +8,18 @@ namespace reasampler::instrument::ui { namespace { -// The output quanta (header: EXACT PREIMAGE). Powers of TEN on purpose: nearbyint(v*S)/S is the +// The output quanta (header: EXACT PREIMAGE). Powers of TEN on purpose: std::round(v*S)/S is the // correctly-rounded double of k/S, which is the same double a decimal literal of k/S parses to — // so a default written as 0.003 or 0.060 lands on the grid exactly. A power-of-two quantum would // not have that property against decimal literals. constexpr double kSecondsPerQuantum = 1e9; // 1 ns constexpr double kSemitonesPerQuantum = 1e6; // 1 micro-semitone -double resolveTo(double value, double perUnit) { return std::nearbyint(value * perUnit) / perUnit; } +// std::nearbyint reads the CURRENT FP rounding mode (MXCSR) — not exclusively ours on a DAW's UI +// thread. Under round-toward-zero it can drop a grid value by a whole quantum, which is exactly +// what the quantization scheme exists to prevent. std::round (half-away-from-zero) is the same +// regardless of that mode, which is what makes the EXACT PREIMAGE guarantee (header) structural. +double resolveTo(double value, double perUnit) { return std::round(value * perUnit) / perUnit; } // The shifted-log offsets. Both are FITTED AGAINST THE CEILING above them, which is why the // ceiling could not be raised in a later track: doing the two apart means fitting twice. @@ -64,19 +68,19 @@ double depthSemitonesFromNorm(double norm, double maxSemitones) { double snapSecondsToWholeMs(double seconds) { if (!(seconds > 0.0)) return 0.0; - return std::nearbyint(seconds * 1000.0) / 1000.0; + return std::round(seconds * 1000.0) / 1000.0; } double snapFractionToWholePercent(double fraction) { - return std::nearbyint(fraction * 100.0) / 100.0; + return std::round(fraction * 100.0) / 100.0; } -double snapSemitonesToWhole(double semitones) { return std::nearbyint(semitones); } +double snapSemitonesToWhole(double semitones) { return std::round(semitones); } // Rounding lands on 1..10; anything under half a unit clamps to the domain floor rather than to // zero, which is not an exponent. 1.0, the linear neutral, is therefore one snap from centre. double snapExponentToWhole(double exponent) { - return util::clampCurve(std::nearbyint(util::clampCurve(exponent))); + return util::clampCurve(std::round(util::clampCurve(exponent))); } } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/param_taper.h b/src/core/instrument/ui/param_taper.h index 248e3fe..cbfc703 100644 --- a/src/core/instrument/ui/param_taper.h +++ b/src/core/instrument/ui/param_taper.h @@ -43,12 +43,15 @@ enum class UnitCategory { // as toPlain(defaultNorm) with no editor-side bypass available, so every default must satisfy // toPlain(toNormalized(d)) == d BITWISE. No transcendental map delivers that at an arbitrary // interior point — the image of toPlain is sparser there than the doubles around it — so both -// maps below resolve their output onto a fixed decimal quantum. That turns the guarantee into -// "every value on the quantum grid round-trips exactly" instead of a libm coincidence that a -// compiler upgrade could take away. Both quanta sit four or more orders below the finest -// reachable drag step, so nothing observable is quantized. The converse, -// toNormalized(toPlain(n)) == n at arbitrary n, is NOT required and must not be demanded: no log -// map satisfies it in double, and requiring it would rule out the shape the range needs. +// maps below resolve their output onto a fixed decimal quantum, via std::round rather than +// std::nearbyint: round is half-away-from-zero regardless of the caller's FP rounding mode, so +// the quantization is mode-independent, not just decimal-exact. That turns the guarantee into +// "every value on the quantum grid round-trips exactly" instead of a libm/MXCSR coincidence that +// a compiler upgrade or a host's UI thread could take away. Both quanta sit roughly 3.7-4 orders +// below the finest reachable drag step (time ~3.98, depth ~3.71), so nothing observable is +// quantized. The converse, toNormalized(toPlain(n)) == n at arbitrary n, is NOT required and must +// not be demanded: no log map satisfies it in double, and requiring it would rule out the shape +// the range needs. // The stage-time domain's upper end — the value at norm 1, and the one home of that number: // envelope_overlay's kGateStageMaxSeconds and deck_values' kEnvTimeMaxSeconds are both aliases diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index fc85595..d82090b 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -175,8 +175,11 @@ static void testResetLandsOnTheStoredDefaultOfEachControl() { // EVERY knob resets to its own stored default, not just the six dual-ring pairs above. Swept // over the whole control-id space so a control added later cannot quietly miss the reset table: // perturb, reset, and require the control to read exactly what a fresh PlaySeconds reads. +// Compared against the STORED FIELD directly (deckDoubleField/deckFloatField), not the +// normalized read-back: deckParamNorm is not guaranteed injective, so a norm match is weaker +// than the criterion — verification against a default-constructed PlaySeconds. static void testEveryKnobIdResetsToItsDefault() { - const PlaySeconds defaults; + PlaySeconds defaults; for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { const DeckParam id = static_cast(i); if (deckParamUnit(id) == UnitCategory::None) continue; // no reset gesture @@ -184,9 +187,17 @@ static void testEveryKnobIdResetsToItsDefault() { PlaySeconds p; setDeckParam(id, p, 0.37, 0); setDeckParam(id, p, 0.83, 0); // two writes: one of the two is off every default - CHECK(deckParamNorm(id, p) != deckParamNorm(id, defaults)); - resetDeckParam(id, p); - CHECK(deckParamNorm(id, p) == deckParamNorm(id, defaults)); + if (double* pd = deckDoubleField(id, p)) { + CHECK(*pd != *deckDoubleField(id, defaults)); + resetDeckParam(id, p); + CHECK(*pd == *deckDoubleField(id, defaults)); + } else if (float* pf = deckFloatField(id, p)) { + CHECK(*pf != *deckFloatField(id, defaults)); + resetDeckParam(id, p); + CHECK(*pf == *deckFloatField(id, defaults)); + } else { + CHECK(false); // every non-None, non-excluded id must own a reset field + } } } @@ -279,8 +290,10 @@ static void testAValueStoredUnderTheOldCeilingIsReadNotRewritten() { CHECK(p.adsr.releaseSeconds == 2.0); CHECK(normDecay > 0.0 && normDecay < 1.0); // still on the knob, just at a new angle CHECK(deckParamNorm(DeckParam::kRelease, p) > normDecay); - // And it survives the norm the knob would hand back, so a no-op touch of the control does - // not quantize a legacy value away. + // And a no-op touch survives the norm the knob would hand back — for THIS value, which is + // exactly on the taper's output quantum grid (1.75 s parses to a grid-aligned double). A + // legacy value off the grid (e.g. 1.2345678912345) WOULD be re-quantized on first touch; + // that is correct, intended behaviour, not a gap this test is claiming to cover. setDeckParam(DeckParam::kDecay, p, normDecay, 0); CHECK(p.adsr.decaySeconds == 1.75); } diff --git a/tests/test_param_taper.cpp b/tests/test_param_taper.cpp index 49dab04..4cf38f5 100644 --- a/tests/test_param_taper.cpp +++ b/tests/test_param_taper.cpp @@ -9,6 +9,10 @@ #include "../src/core/instrument/ui/param_taper.h" +#include "../src/core/instrument/ui/envelope_overlay.h" // gateStageSlotPx: finest drag surface +#include "../src/core/instrument/ui/sample_bands.h" // kEditorMinWidth/kPad: the editor floor + +#include #include #include @@ -78,10 +82,15 @@ static void testStageTimeIsMonotone() { } } -// The FINEST drag a user can make — Ctrl's 1/20 rate over the 128 px knob travel — must still -// move the value, or the output quantum would be observable as a dead zone. +// The FINEST drag a user can make on ANY surface this taper serves — not the knob's own 128 px +// travel, which is coarser than the AHDSR schematic's node drag at the editor floor. Derived from +// the floor constant and the overlay's own slot-width formula, so a later floor change sharpens +// (or coarsens) the step this test exercises automatically instead of leaving a copied number +// silently stale. static void testEveryFinestDragStepMovesTheValue() { - const int steps = static_cast(1.0 / kFineDragScale) * 128; + const Rect floorArea = Rect::ltrb(0, 0, kEditorMinWidth - 2 * kPad, 100); + const double slot = gateStageSlotPx(floorArea); + const int steps = static_cast(slot / kFineDragScale); for (int i = 0; i < steps; ++i) { const double lo = timeSecondsFromNorm(static_cast(i) / steps); const double hi = timeSecondsFromNorm(static_cast(i + 1) / steps); @@ -108,6 +117,32 @@ static void testEveryWholeMicrosecondRoundTripsExactly() { } } +// MODE-INDEPENDENCE, the whole point of resolveTo's std::round over std::nearbyint. First shows +// the defect directly, generically: under round-toward-zero, the RETIRED std::nearbyint reads +// that mode and truncates a value whose fraction is well past half, while std::round (specified +// to round half-away-from-zero REGARDLESS of the current mode) does not. Then proves the +// production round trip itself — not a stand-in — survives the same hostile mode across the grid. +static void testRoundingSurvivesAHostileFpRoundingMode() { + const int saved = std::fegetround(); + CHECK(std::fesetround(FE_TOWARDZERO) == 0); + + CHECK(std::nearbyint(12.9) == 12.0); // the RETIRED behaviour: mode-dependent, wrong here + CHECK(std::round(12.9) == 13.0); // the fix: mode-independent, rounds to nearest + + for (int us = 0; us <= 200000; us += 7) { + const double seconds = static_cast(us) / 1e6; + CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds); + if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) break; + } + for (int milli = -24000; milli <= 24000; milli += 37) { + const double d = static_cast(milli) / 1000.0; + CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d); + if (depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) != d) break; + } + + std::fesetround(saved); // restore — every other test in this binary assumes the default +} + // The converse round trip is NOT required, but its residual is worth pinning: it is bounded by // the output quantum read back through the map, which stays four orders below one drag pixel. // Pinned so a future quantum change cannot make the needle visibly lag the hand unnoticed. @@ -239,6 +274,7 @@ int main() { testStageTimeIsMonotone(); testEveryFinestDragStepMovesTheValue(); testEveryWholeMicrosecondRoundTripsExactly(); + testRoundingSurvivesAHostileFpRoundingMode(); testNormRoundTripResidualStaysBelowOneDragPixel(); testTheStageTimeDefaultsRoundTripExactly(); From ae59e9b70d9824b4e7c96056255df63c35a1bbad Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 20:07:59 -0400 Subject: [PATCH 07/56] =?UTF-8?q?=CE=93-W1-T5=20remediation:=20narrow=20th?= =?UTF-8?q?e=20rate-bound=20claim,=20fix=20baseline/measurement=20provenan?= =?UTF-8?q?ce,=20correct=20=C2=A72.4=20framing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-derives the splice-cadence inequality and adds a corner probe that FAILS at P=500 by design, pending a ruling. Names the baseline commit and harness edit, fixes measurement methodology, corrects the Trigger-AHD/rate coupling framing. --- src/core/instrument/engine/time_stretch.h | 23 ++- src/core/instrument/engine/voice.cpp | 2 + src/core/instrument/engine/voice.h | 21 ++- tests/test_pitch_shift.cpp | 33 +++++ tests/test_sampler_core.cpp | 166 +++++++++++++++++----- 5 files changed, 196 insertions(+), 49 deletions(-) diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index 545ec3d..d13ad5a 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -11,13 +11,22 @@ namespace reasampler::instrument::engine { // The playback rates the Preserve DSP is measured over, and therefore the only ones it -// accepts. Two independent reasons they are here and not wider: -// - the ceiling is what bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame source -// frames), which is the RT-safety argument for feeding a variable count at all; -// - the splice search can only align a period it can see. The tap's delay drifts at -// |rate - shift| per frame, so a wide rate over a deep DOWN-shift splices faster than one -// period of the output tone and the correlation stops holding the pitch: measured at rate -// 4.0 with -24 st, the observed period came out 539 frames against 785 wanted. +// accepts. The ceiling also bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame +// source frames) — the RT-safety argument for feeding a variable count at all. +// +// This range NARROWS the splice-cadence failure onto the source fundamental; it does not +// eliminate it. A splice recurs every `window / |rate - shift|` output frames (the tap's +// delay drifts across one window at that per-frame rate); the shifted tone's own period is +// `sourcePeriod / shift` output frames. Whenever the recurrence interval is shorter than +// that period, a splice lands inside a single perceived cycle and the correlation search +// has less than one period to align against. Measured at rate 4.0, shift 0.25 (-24 st): +// interval 2205/3.75 ~= 588 vs period ~4*P ~= 785 frames (P ~= 196) — matches the originally +// observed 539-vs-785 failure. This range's ceiling (2.0, not 4.0) raises the safe floor, it +// does not remove it: at rate 2.0, shift 0.25, interval = 2205/1.75 = 1260 still fails for +// any source period P > 315 frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and +// -24 st is reachable from the Pitch knob alone. (The pre-stretch rate-1.0 engine's floor by +// the same inequality is P > 735, ~60 Hz — what this range raises the floor from, not what +// it removes.) inline constexpr double kStretchRateMin = 0.5; inline constexpr double kStretchRateMax = 2.0; inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index f4aee7e..5badbc4 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -327,6 +327,8 @@ void Voice::retune(int note) { // Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it // too. The velocity offset deliberately stays the first note's, matching velocityGain_. if (filterOn_) updateFilterCutoffBase(note); + // stretchRate_ (Preserve's duration control) is untouched here too — it is a note-on latch + // like velocityGain_, not a per-note property to re-resolve on a legato slide. } void Voice::release() { diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 00e1b15..1349f9d 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -192,9 +192,9 @@ private: // This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read // at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once // per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD - // is evaluated at the source offset (readPos - startFrame) so its stages anchor to source - // frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees - // the voice. + // is evaluated at the source offset (readPos - startFrame) — see the `ratio_ = stretchRate_` + // note below for what that means for Preserve's stage-time/rate coupling. Sets + // amplitudeDone_ on finish so advanceFrame frees the voice. double tickAmplitude() { double amp; // playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it — @@ -454,7 +454,10 @@ private: // 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount, // never to the read rate. The feed runs one window ahead of readPos_ (the rings were // primed with that window at start()), under the same sustain-loop wrap rule, - // reading integer source frames (nothing to interpolate). + // reading integer source frames into the ring — no RATE-DEPENDENT interpolation + // (unlike Varispeed's readPos_ below). The shifter's own read tap still carries a + // splice's sub-sample `frac` (pitch_shift.cpp), so it interpolates on every read, + // splice or no; that constant fractional delay is not a rate coupling. const bool stereoOut = stereo && haveR && shiftR_.configured(); // The last real source frame is playEnd_-1 for Trigger or frameCount-1 for Gate. // Once the feed reaches that bound the source is exhausted — feeding the held last @@ -528,6 +531,16 @@ private: // Preserve advances the read head at the STRETCH rate — the one duration control. // Everything downstream of it (the loop wrap, the Trigger span, the spline phase) // therefore stays a source-frame fact and scales by construction. + // + // Consequence (§2.4 of instrument-control-surface.md is explicit that staged + // envelopes' stage times are wall-clock and do NOT scale with rate): Trigger's amp + // AHD and filter AHD are both evaluated at sourceOffset() = readPos_ - startFrame_ + // (tickAmplitude/tickFilterCutoff above), which now advances at stretchRate_ instead + // of always 1.0 — so those two envelopes will scale with a future non-unity Rate. + // This is NEW here: Preserve's ratio_ was pinned at 1.0 before this track, so those + // stage times were exact wall-clock. It is latent (nothing publishes a non-unity + // rate yet) and owned by the track that adds the Rate control, not this one — Gate's + // AHDSR (env_.tick(), per-output-frame) and every spline contour are unaffected. ratio_ = stretchRate_; } else { // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index ffd1f17..be8d0bf 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -737,6 +737,38 @@ static void testStretchAndShiftComposeSafely() { } } +// The [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the +// source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both +// inside the shipped range — the header's own derivation puts the safe-source floor at a +// period of 315 frames (~140 Hz @ 44.1k): testStretchAndShiftComposeSafely's probe period of +// 196.37 frames (~225 Hz) sits ABOVE that floor, so it passes because of the probe, not +// because of headroom. This probe sits BELOW the floor on purpose, asserting the corner +// rather than assuming it. A failure here is the inequality's PREDICTED outcome, not a +// defect this test exists to chase — report it, don't retune the tolerance to hide it. +static void testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter() { + const std::int64_t w = 2205; + const double rate = 2.0; + const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25 + for (double period : {500.0, 600.0, 700.0}) { + const double f0 = 1.0 / period; + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); + } + const std::size_t outFrames = 60000; + const std::vector out = runStretch(src, w, rate, shift, outFrames, nullptr); + for (double v : out) CHECK(std::isfinite(v)); + const double p = periodIn(out, 20000, 50000); + const double want = period / shift; + const bool ok = approx(p, want, want * 0.12); + std::printf(" [floor probe] period %.0f (rate 2.0, -24 st): observed %.2f want %.2f " + "-> %s\n", period, p, want, ok ? "held" : "FAILED (predicted by the " + "inequality in time_stretch.h)"); + CHECK(ok); + } +} + // The two new entry points on a shifter that was never configured (a Varispeed voice's) — // neither may touch the empty ring. static void testStretchEntryPointsOnPassThrough() { @@ -758,6 +790,7 @@ int main() { testStereoLinkedLagSharedSchedule(); testStretchMovesDurationNotPitch(); testStretchAndShiftComposeSafely(); + testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter(); testStretchEntryPointsOnPassThrough(); if (g_fail == 0) { diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 8b6c059..d593670 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -20,11 +20,11 @@ #include "../src/core/instrument/engine/voice_engine.h" #include +#include #include #include #include #include -#include #include using namespace reasampler; @@ -2947,11 +2947,13 @@ static void renderVoice(const SampleData& s, int note, double rate, std::int64_t } // --- The null case, asserted against a baseline the SHIPPED engine produced. --- -// The four constants below were captured by running this same function against the -// pre-stretch build (phase-g, before the rate seam existed) and printing the hashes; they are -// therefore a witness that the generalized read path reproduces the shipped Preserve output -// bit for bit at rate 1.0, not a self-consistency check. A change here is a change to what -// every already-saved project sounds like — re-derive the cause before re-baselining. +// The four constants below are a witness against `phase-g`'s tip, commit 0a7778b — the last +// commit before this track's rate seam — not a self-consistency check. To re-derive: check +// out 0a7778b, add this file's stretchProbeSample/hashStream/renderVoice/test body to it, and +// drop the trailing `, rate` argument from renderVoice's `v.start(...)` call (0a7778b's +// Voice::start has no 5th parameter) — then build, run, and print the hashes. A change here is +// a change to what every already-saved project sounds like — re-derive the cause before +// re-baselining. static void testPreserveUnityRateIsBitIdenticalToTheShippedRead() { const std::int64_t w = 2205; // the product window at 44.1k const std::size_t n = 6000; @@ -3084,11 +3086,16 @@ static void testPreserveStretchSpeaksOnFrameZeroAtEveryRate() { // The primed ring parks the tap ON the start frame, so output frame 0 is source // frame `startFrame` exactly — at every rate and every transposition. A stretcher // that buffered a window before speaking would fail here, which is the whole point. + // This bit-exact check is what actually carries "no first-frame smear"; the loop + // below is a coarser, complementary DROPOUT detector (see its own comment). CHECK(first == s.frames[500]); - // ...and it keeps speaking: no first-window dip while the schedule settles. The - // 256-frame measuring window spans most of a period even at the lowest note tested - // (-12 st stretches the probe's 196-frame period to 393), so a continuous tone - // peaks well above the floor in every one of them and only a real gap can sink it. + // ...and it keeps speaking: no first-window DROPOUT while the schedule settles. + // `lo > 0.5` over twenty 256-frame peak windows catches a gap of roughly a window, + // but a smeared or phase-scrambled first window can still peak above 0.5 and pass + // here — it cannot see that; the CHECK above is what does. The 256-frame measuring + // window spans most of a period even at the lowest note tested (-12 st stretches + // the probe's 196-frame period to 393), so a continuous tone peaks well above the + // floor in every one of them and only a real gap can sink it. double lo = 1e9; for (int i = 0; i < 20; ++i) { double peak = 0.0; @@ -3130,14 +3137,84 @@ static void testPreserveStretchLoopsTheSourceSpan() { CHECK(approx(sum / 200.0, 0.5, 0.05)); } } + + // The two assertions above hold even if stretchRate_ were ignored outright — the loop's + // constant content proves nothing about cadence. A one-time marker AFTER the primed window + // but BEFORE the loop start is the source-frame witness that the feed genuinely consumes + // source AT THE RATE: note-on primes the ring with the first `window` source frames up + // front (played back at 1 frame/output-frame, independent of rate — a marker inside that + // span was measured landing at a FIXED output frame at every rate, confirming it is not a + // rate witness). Past it, new content only enters the ring via the ongoing due()-scheduled + // feed, at `rate` source frames per output frame on average: the marker's single output + // appearance lands at `window + (markerFrame - window) / rate` output frames. Note == root + // (shift == 1.0), isolating the rate's effect from the pitch engine's own transposition. + // + // Excludes rate 2.0: at shift 1.0 that is drift = |rate-shift| = 1.0 exactly, and this + // geometry's own splice trigger (0.75x window output frames from note-on, measured) fires + // BEFORE the primed span even finishes playing back (< window frames) whenever drift >= + // ~0.75 — so no marker placed "past the prime" can be reached before a splice relocates + // the tap first. Confirmed by measurement, not assumed: a rate-2.0 attempt at this marker + // came back with the tap having moved on (no witness value in the output at all). The + // write-side consumption-at-the-rate claim at every rate, splice-immune because it never + // goes through the shifter, is what test_time_stretch.cpp's StretchCursor tests assert. + // + // A marker placed INSIDE the steady-state loop instead would NOT show rate-dependence + // either: once ring-resident, the read tap's own pace is governed by SHIFT alone ("shift + // the output" — ratio_ advances posA_ every output frame unconditionally), so it revisits + // every loopLength ring slots at 1 slot/output-frame regardless of how fast the writer + // filled them — confirmed by measurement (median recurrence gap 200 frames at rate 0.5, + // 1.0 AND 2.0 alike, for a 200-frame loop). Rate governs the feed/splice cadence, not the + // loop's own output period, once its content is already in the ring. + for (double rate : {0.5, 1.0}) { + SampleData s; + s.frames.assign(1000, 0.0f); + for (int i = 300; i < 900; ++i) s.frames[i] = 0.5f; + s.frames[650] = 1.0f; // past the 600-frame primed span, before the loop at 700 + s.rootNote = 60; + s.sampleRate = 48000; + s.loop.hasLoop = true; + s.loop.start = 700; + s.loop.end = 900; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + Voice v; + v.presizePreserveShifters(600); + v.start(60, 127, s, /*declickTakeover=*/false, rate); // root note: shift == 1.0 + const std::size_t total = 3000; + std::vector out(total); + for (std::size_t i = 0; i < total; ++i) out[i] = v.renderFrame(); + std::size_t hitAt = 0; + for (std::size_t i = 0; i < total; ++i) { + if (out[i] > 0.7f) { hitAt = i; break; } + } + CHECK(hitAt > 0); + const double want = 600.0 + (650.0 - 600.0) / rate; + if (!approx(static_cast(hitAt), want, want * 0.15 + 5.0)) { + std::printf(" rate %.2f: marker at frame %zu want %.2f\n", rate, hitAt, want); + } + CHECK(approx(static_cast(hitAt), want, want * 0.15 + 5.0)); + } } // --- The 32-voice measurement gate. Asserts correctness; PRINTS the cost, which is the -// number reported for the algorithm decision (meaningful only in a Release build). --- +// number reported for the algorithm decision (meaningful only in a Release build). +// +// Methodology: std::chrono::steady_clock (not std::clock() — a single wall-clock diff has no +// warm-up and no spread), kWarmupReps discarded, kTimedReps repetitions per rate, median + +// [min, max] reported. secs is wall-clock for 1.0 s of audio on ONE thread with no other work +// scheduled onto it, so 100*secs is % of REALTIME consumed — not "% of one core" (that would +// additionally claim core-pinned exclusivity this benchmark never establishes). +// +// A separate, one-off Release A/B (unity-now vs the pre-stretch build at commit 0a7778b, same +// methodology, standalone harness outside this tree) found the two statistically +// indistinguishable at ~79-82 ns/voice/frame; that is a point-in-time finding to re-derive if +// this path changes materially, not a hardcoded regression bound here. --- static void testPreserveStretchThirtyTwoVoicesHoldUp() { const std::int64_t w = 2205; // the product window at 44.1k const std::size_t blockFrames = 44100; // one second of audio const std::size_t voiceCount = 32; + const int kWarmupReps = 2; + const int kTimedReps = 7; SampleData s = stretchProbeSample(200000, true); s.loop.hasLoop = true; // held notes: all 32 sound for the whole run s.loop.start = 40000; @@ -3147,36 +3224,49 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() { // 1.0 is the reference: it is the cost the shipped Preserve read already carries, so the // two stretched rows are read as a delta against it rather than in isolation. for (double rate : {1.0, 0.5, 2.0}) { - std::vector voices(voiceCount); - for (std::size_t i = 0; i < voiceCount; ++i) { - voices[i].presizePreserveShifters(w); - voices[i].start(48 + static_cast(i), 100, s, /*declickTakeover=*/false, rate); - } - const std::clock_t t0 = std::clock(); - double guard = 0.0; - std::size_t sounding = 0; - for (std::size_t f = 0; f < blockFrames; ++f) { - AudioSample l = 0.0f, r = 0.0f; + std::vector nsPerVoiceFrame; + nsPerVoiceFrame.reserve(kTimedReps); + for (int rep = 0; rep < kWarmupReps + kTimedReps; ++rep) { + std::vector voices(voiceCount); for (std::size_t i = 0; i < voiceCount; ++i) { - AudioSample a = 0.0f, b = 0.0f; - voices[i].renderFrameStereo(a, b); - l += a; - r += b; + voices[i].presizePreserveShifters(w); + voices[i].start(48 + static_cast(i), 100, s, /*declickTakeover=*/false, + rate); + } + const auto t0 = std::chrono::steady_clock::now(); + double guard = 0.0; + std::size_t sounding = 0; + for (std::size_t f = 0; f < blockFrames; ++f) { + AudioSample l = 0.0f, r = 0.0f; + for (std::size_t i = 0; i < voiceCount; ++i) { + AudioSample a = 0.0f, b = 0.0f; + voices[i].renderFrameStereo(a, b); + l += a; + r += b; + } + guard += static_cast(l) + static_cast(r); + CHECK(std::isfinite(l) && std::isfinite(r)); + } + const double secs = + std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + for (std::size_t i = 0; i < voiceCount; ++i) { + if (voices[i].active()) ++sounding; + } + CHECK(sounding == voiceCount); // all 32 held the whole second (the loop kept them up) + CHECK(std::fabs(guard) > 0.0); // ...and genuinely produced audio + if (rep >= kWarmupReps) { + nsPerVoiceFrame.push_back(secs * 1e9 / (static_cast(blockFrames) * + static_cast(voiceCount))); } - guard += static_cast(l) + static_cast(r); - CHECK(std::isfinite(l) && std::isfinite(r)); } - const double secs = static_cast(std::clock() - t0) / CLOCKS_PER_SEC; - for (std::size_t i = 0; i < voiceCount; ++i) { - if (voices[i].active()) ++sounding; - } - CHECK(sounding == voiceCount); // all 32 held the whole second (the loop kept them up) - CHECK(std::fabs(guard) > 0.0); // ...and genuinely produced audio - std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: %.3f s wall for 1.0 s " - "audio (%.1f%% of one core, %.1f ns/voice/frame)\n", - rate, secs, 100.0 * secs, - secs * 1e9 / (static_cast(blockFrames) * - static_cast(voiceCount))); + std::sort(nsPerVoiceFrame.begin(), nsPerVoiceFrame.end()); + const double medianNs = nsPerVoiceFrame[nsPerVoiceFrame.size() / 2]; + const double secsAtMedian = + medianNs * static_cast(blockFrames) * static_cast(voiceCount) / 1e9; + std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: median %.1f ns/voice/" + "frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime at the median)\n", + rate, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), kTimedReps, + 100.0 * secsAtMedian); } } From a1b42ed1a892e597f93b92cc8bb138f433d631a8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 21:31:40 -0400 Subject: [PATCH 08/56] Fix envelope-overlay knot/trace disagreement on odd pixel spans Generalize curveMidLevel/curveFromMidLevel to curveLevelAt/curveFromLevelAt at arbitrary phi; knotVtx and its drag inverse now read the phi a knot's truncated x actually implies, not always 0.5. --- src/core/instrument/ui/envelope_edit.cpp | 59 ++++++++++--- src/core/instrument/ui/envelope_overlay.cpp | 17 ++-- src/core/instrument/ui/envelope_overlay.h | 7 ++ src/core/util/curve_law.h | 34 +++++--- tests/test_curve_law.cpp | 37 ++++++++ tests/test_envelope_edit.cpp | 35 ++++++++ tests/test_envelope_overlay.cpp | 95 ++++++++++++++++++++- 7 files changed, 252 insertions(+), 32 deletions(-) diff --git a/src/core/instrument/ui/envelope_edit.cpp b/src/core/instrument/ui/envelope_edit.cpp index dc5e2f0..5c76c61 100644 --- a/src/core/instrument/ui/envelope_edit.cpp +++ b/src/core/instrument/ui/envelope_edit.cpp @@ -11,8 +11,8 @@ namespace reasampler::instrument::ui { using util::clamp01; -using util::curveFromMidLevel; -using util::curveMidLevel; +using util::curveFromLevelAt; +using util::curveLevelAt; namespace { @@ -105,10 +105,35 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) { return s; } -// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through -// curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law). +// The pixel bounds of the segment a curve knot rides, by node — read off the SAME polyline the +// draw built (never re-derived), so the drag's phi can never disagree with knotVtx's. +struct SegmentPixels { + int x0 = 0; + int x1 = 0; + bool ok = false; +}; +SegmentPixels segmentPixels(const std::vector& poly, EnvNode knot) { + EnvNode startNode, endNode; + switch (knot) { + case EnvNode::AttackCurve: startNode = EnvNode::Origin; endNode = EnvNode::AttackEnd; break; + case EnvNode::DecayCurve: startNode = EnvNode::HoldEnd; endNode = EnvNode::DecayEnd; break; + case EnvNode::ReleaseCurve: startNode = EnvNode::ReleaseStart; endNode = EnvNode::ReleaseEnd; break; + default: return {}; + } + SegmentPixels s; + bool haveStart = false, haveEnd = false; + for (const EnvVertex& v : poly) { + if (v.node == startNode) { s.x0 = v.x; haveStart = true; } + else if (v.node == endNode) { s.x1 = v.x; haveEnd = true; } + } + s.ok = haveStart && haveEnd; + return s; +} + +// A knot drag: the grab-time level at `phi` (the phi the knot's own drawn x implies — see +// knotPhi) shifted by the pixel delta, read back through curve_law's inverse at that same phi. double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent, - const Rect& area, double dyPixels) { + double phi, const Rect& area, double dyPixels) { const SegmentLevels seg = segmentLevels(grabEnv, knot); if (!seg.ok) return grabExponent; const double span = seg.end - seg.start; @@ -117,9 +142,9 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab // ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of // level travel — a segment thinner than that is visually a no-op drag anyway. if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent; - const double grabLevel = seg.start + span * curveMidLevel(grabExponent); + const double grabLevel = seg.start + span * curveLevelAt(phi, grabExponent); const double newLevel = grabLevel - dyPixels * levelPerPixel(area); - return curveFromMidLevel((newLevel - seg.start) / span); + return curveFromLevelAt(phi, (newLevel - seg.start) / span); } // An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see @@ -178,6 +203,16 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const const double dy = static_cast(dyPixels) * scale; const double dSec = dx * secPerPx; + // A curve knot's phi is read off the same polyline knotVtx drew, so the drag inverts the + // exact phi the knot is sitting at rather than assuming the segment midpoint. + double curvePhi = 0.5; + if (node == EnvNode::AttackCurve || node == EnvNode::DecayCurve || + node == EnvNode::ReleaseCurve) { + const std::vector poly = buildEnvelopePolyline(grabEnv, area, totalSeconds); + const SegmentPixels sp = segmentPixels(poly, node); + if (sp.ok) curvePhi = knotPhi(sp.x0, sp.x1); + } + if (grabEnv.kind == EnvKind::Ahdsr) { switch (node) { // Each cumulative-time node edits its own segment duration. Non-negative durations @@ -212,15 +247,15 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const break; case EnvNode::AttackCurve: out.attackCurve = snappedExponent( - curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dy), mods); + curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods); break; case EnvNode::DecayCurve: out.decayCurve = snappedExponent( - curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dy), mods); + curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods); break; case EnvNode::ReleaseCurve: out.releaseCurve = snappedExponent( - curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dy), mods); + curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, curvePhi, rect, dy), mods); break; default: break; @@ -261,11 +296,11 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const } case EnvNode::AttackCurve: out.attackCurve = snappedExponent( - curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dy), mods); + curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods); break; case EnvNode::DecayCurve: out.decayCurve = snappedExponent( - curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dy), mods); + curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods); break; default: break; diff --git a/src/core/instrument/ui/envelope_overlay.cpp b/src/core/instrument/ui/envelope_overlay.cpp index 4648aa8..ccd2066 100644 --- a/src/core/instrument/ui/envelope_overlay.cpp +++ b/src/core/instrument/ui/envelope_overlay.cpp @@ -9,8 +9,7 @@ namespace reasampler::instrument::ui { using util::clamp01; -using util::curveMap; -using util::curveMidLevel; +using util::curveLevelAt; int timeToX(const Rect& area, double totalSeconds, double t) { const int w = std::max(0, area.width); @@ -46,6 +45,12 @@ int levelToY(const Rect& area, double level) { return area.y + static_cast(dy); } +double knotPhi(int x0, int x1) { + if (x1 == x0) return 0.5; + const int mid = (x0 + x1) / 2; + return static_cast(mid - x0) / static_cast(x1 - x0); +} + AhdSplit splitAhdSeconds(const StageEnvelope& env) { AhdSplit out; const double span = std::max(0.0, env.spanSeconds); @@ -90,14 +95,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool } // The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's -// pixel midpoint, its level read through curve_law.h's own law (the knot/dial pairing's home). +// pixel midpoint. Its level is read at the phi that midpoint's TRUNCATED x actually implies +// (knotPhi), not always phi = 0.5 — an odd-pixel span would otherwise draw the knot a half +// pixel off the curve its own vertices trace. curve_law.h owns the knot/dial pairing. EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel, double endLevel, double exponent) { - const double u = curveMidLevel(exponent); - const double level = startLevel + (endLevel - startLevel) * u; EnvVertex v; v.node = node; v.x = (x0 + x1) / 2; + const double u = curveLevelAt(knotPhi(x0, x1), exponent); + const double level = startLevel + (endLevel - startLevel) * u; v.y = levelToY(area, level); v.level = level; v.knot = true; diff --git a/src/core/instrument/ui/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h index 2cc1f0d..aae605c 100644 --- a/src/core/instrument/ui/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -115,6 +115,13 @@ int timeToX(const Rect& area, double totalSeconds, double t); // clamped. Shared with envelope_edit's node hit-test. int levelToY(const Rect& area, double level); +// The normalized phi a curve knot's TRUNCATED integer x actually lands at within its bounding +// segment [x0, x1] — exactly 0.5 only when the span is even. Shared with envelope_edit's knot +// drag so the draw and its inverse read the same phi off the same formula rather than two +// copies that could drift apart. x0 == x1 (no interior) returns 0.5; callers never place a knot +// there. +double knotPhi(int x0, int x1); + // The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so // the drawn stage boundaries land where the voice actually puts them. Attack takes at most the // span and Decay at most what Attack left, so Hold's fraction of the remainder can never push diff --git a/src/core/util/curve_law.h b/src/core/util/curve_law.h index b8382b5..27bb25f 100644 --- a/src/core/util/curve_law.h +++ b/src/core/util/curve_law.h @@ -1,8 +1,8 @@ #pragma once // curve_law — the ONE per-segment envelope curve law: the exponent domain, the map from a -// stage's normalized position to its normalized level, and the mid-segment inverse the -// overlay knot drags through. Header-only and dependency-free so the engine evaluator, the -// overlay's forward map, and its inverse all read the same law rather than three copies. +// stage's normalized position to its normalized level, and that map's inverse (mid-segment is +// the special case). Header-only and dependency-free so the engine evaluator, the overlay's +// forward map, and its inverse all read the same law rather than three copies. #include @@ -58,19 +58,25 @@ inline double knobNormFromCurve(double exponent) { return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t); } -// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the -// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on -// one value: both resolve through this law, not through each other. -inline double curveMidLevel(double exponent) { return curveMap(0.5, clampCurve(exponent)); } +// The normalized level at an arbitrary segment position phi in (0,1), and its inverse. A +// knot's DRAWN x truncates to an integer, which lands it off phi = 0.5 whenever its segment's +// pixel span is odd; reading the knot's y through the phi its own x actually implies (rather +// than assuming 0.5) is what keeps the knot on the trace its own vertices draw. +inline double curveLevelAt(double phi, double exponent) { return curveMap(phi, clampCurve(exponent)); } -// Mid-level -> exponent: u = 0.5^p, so p = ln(u)/ln(0.5). Out-of-domain u clamps to the +// Level -> exponent at phi: u = phi^p, so p = ln(u)/ln(phi). Out-of-domain u clamps to the // exponent endpoints rather than producing a non-finite exponent. -inline double curveFromMidLevel(double midLevel) { - const double lo = curveMidLevel(kCurveMax); // smallest reachable mid-level - const double hi = curveMidLevel(kCurveMin); // largest - if (!(midLevel > lo)) return kCurveMax; // also catches NaN - if (midLevel >= hi) return kCurveMin; - return clampCurve(std::log(midLevel) / std::log(0.5)); +inline double curveFromLevelAt(double phi, double level) { + const double lo = curveLevelAt(phi, kCurveMax); // smallest reachable level at this phi + const double hi = curveLevelAt(phi, kCurveMin); // largest + if (!(level > lo)) return kCurveMax; // also catches NaN + if (level >= hi) return kCurveMin; + return clampCurve(std::log(level) / std::log(phi)); } +// The segment-MIDPOINT (phi = 0.5) case — the knot's placement whenever its pixel span is +// even. Kept under its own name for the existing callers/tests that assume that case. +inline double curveMidLevel(double exponent) { return curveLevelAt(0.5, exponent); } +inline double curveFromMidLevel(double midLevel) { return curveFromLevelAt(0.5, midLevel); } + } // namespace reasampler::util diff --git a/tests/test_curve_law.cpp b/tests/test_curve_law.cpp index a5e7f48..10c90ba 100644 --- a/tests/test_curve_law.cpp +++ b/tests/test_curve_law.cpp @@ -14,6 +14,7 @@ #include #include +#include using namespace reasampler::util; @@ -107,6 +108,39 @@ static void testMidLevelInverseSaturates() { CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12); } +// curveLevelAt/curveFromLevelAt is the general form a knot's own (possibly off-centre) phi +// needs — curveMidLevel/curveFromMidLevel is the phi = 0.5 case, not a second law. +static void testMidLevelIsThePhiHalfSpecialCase() { + for (double e : {kCurveMin, 0.3, kCurveNeutral, 2.0, kCurveMax}) { + CHECK(curveLevelAt(0.5, e) == curveMidLevel(e)); + } + for (double u : {0.0, 0.2, 0.5, 0.8, 1.0}) { + CHECK(curveFromLevelAt(0.5, u) == curveFromMidLevel(u)); + } +} + +// The round trip must hold at an arbitrary phi, not only 0.5 — this is what a knot whose +// integer x lands off its segment's true midpoint (an odd pixel span) actually exercises. +static void testLevelAtRoundTripsAtArbitraryPhi() { + for (double phi : {0.1, 0.3, 0.42, 0.5, 0.63, 0.9}) { + for (int i = 0; i <= 50; ++i) { + const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 50.0); + const double level = curveLevelAt(phi, e); + CHECK(level > 0.0 && level < 1.0); + CHECK(std::fabs(curveFromLevelAt(phi, level) - e) < 1e-9); + } + } +} + +// Saturation holds at an arbitrary phi too, not only the mid-level special case. +static void testLevelAtInverseSaturatesAtArbitraryPhi() { + for (double phi : {0.2, 0.5, 0.8}) { + CHECK(curveFromLevelAt(phi, 0.0) == kCurveMax); + CHECK(curveFromLevelAt(phi, 1.0) == kCurveMin); + CHECK(curveFromLevelAt(phi, std::nan("")) == kCurveMax); + } +} + // --- The inner dial's travel --------------------------------------------------- // The knob drag delivers `start - dy/kKnobDragRangePixels`. param_slider owns that constant and @@ -184,6 +218,9 @@ int main() { testClampCurveHoldsTheDomain(); testMidLevelRoundTripsAgainstTheExponent(); testMidLevelInverseSaturates(); + testMidLevelIsThePhiHalfSpecialCase(); + testLevelAtRoundTripsAtArbitraryPhi(); + testLevelAtInverseSaturatesAtArbitraryPhi(); testKnobLawIsExactAtTheNeutralCentre(); testADialSweptThroughNeutralLandsOnTheIdentity(); testKnobLawRoundTripsOutsideTheDetent(); diff --git a/tests/test_envelope_edit.cpp b/tests/test_envelope_edit.cpp index 6effc07..5ff0c1a 100644 --- a/tests/test_envelope_edit.cpp +++ b/tests/test_envelope_edit.cpp @@ -373,6 +373,40 @@ static void testKnotOnANearLevelSegmentIsANoOp() { CHECK(out.decayCurve == 2.5); } +// The knot drag must read the SAME phi the draw used even off the segment midpoint (an odd +// pixel span), not the fixed phi = 0.5 wideArea()'s AttackCurve span happens to land on above. +// Checked two ways: a zero-delta grab reproduces the stored exponent, and a real one-pixel drag +// moves the knot's own drawn y by the same one pixel every other node axis tracks 1:1. +static void testKnotDragTracksTheDrawOnAnOddPixelSpan() { + bool found = false; + for (int width = 24; width <= 260 && !found; ++width) { + const Rect a = Rect::ltrb(0, 0, width, 100); + StageEnvelope e = ahdsrEnv(); + e.attackCurve = 3.0; + EnvVertex origin, attackEnd, knot; + const std::vector poly = buildEnvelopePolyline(e, overlayOf(a), kTotal); + if (!findNode(poly, EnvNode::Origin, origin)) continue; + if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue; + if (!findNode(poly, EnvNode::AttackCurve, knot)) continue; + const int span = attackEnd.x - origin.x; + if (span <= 0 || span % 2 == 0) continue; + found = true; + + const StageEnvelope same = + resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 0); + CHECK(std::fabs(same.attackCurve - e.attackCurve) < 1e-9); + + const StageEnvelope dragged = + resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 1); + EnvVertex knotAfter; + CHECK(findNode(buildEnvelopePolyline(dragged, overlayOf(a), kTotal), EnvNode::AttackCurve, + knotAfter)); + CHECK(knotAfter.x == knot.x); // a curve drag never moves the knot's x + CHECK(std::abs(knotAfter.y - (knot.y + 1)) <= 1); + } + CHECK(found); // the sweep must actually land on an odd span +} + // --- the interaction law on the overlay ---------------------------------------- // Ctrl scales the PIXEL delta, so it composes with every axis — the tapered schematic, the 1:1 @@ -476,6 +510,7 @@ int main() { testKnotAndModelCannotDiverge(); testKnotOnALevelSegmentIsANoOp(); testKnotOnANearLevelSegmentIsANoOp(); + testKnotDragTracksTheDrawOnAnOddPixelSpan(); testDegenerateInputsAreNoOps(); diff --git a/tests/test_envelope_overlay.cpp b/tests/test_envelope_overlay.cpp index 6c527f2..269b440 100644 --- a/tests/test_envelope_overlay.cpp +++ b/tests/test_envelope_overlay.cpp @@ -10,7 +10,9 @@ // per-segment separation at the tier-0 defaults, overrun compression, every vertex in-bounds); // splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the AHD polyline (1:1 with // the time axis, origin offset); curve knots (present only on sloped non-zero segments, height -// following the exponent); the degenerate flat baseline. +// following the exponent, and — swept across ODD and EVEN pixel spans, not one fixture's width — +// sitting on the curve its own vertices imply rather than always the segment's exact midpoint); +// the degenerate flat baseline. #include "../src/core/instrument/ui/envelope_overlay.h" @@ -422,6 +424,95 @@ static void testKnotHeightTracksTheExponent() { CHECK(steep.y >= a.y && steep.y <= a.bottom() - 1); } +// --- the knot sits ON its own curve (the reported defect, stated as the gate) ------------- + +// The general (non-truncated-phi) reading of a knot's level, computed from the vertices +// `buildEnvelopePolyline` actually returned — x0/x1/knotX are all int pixels a caller can read +// off the polyline, so this is a check ON the output, not a restatement of knotVtx's own +// formula. x0 == x1 has no interior (no knot is ever built there). +static double expectedKnotLevel(int x0, int x1, int knotX, double startLevel, double endLevel, + double exponent) { + const double phi = (x1 != x0) + ? static_cast(knotX - x0) / static_cast(x1 - x0) + : 0.5; + return startLevel + (endLevel - startLevel) * reasampler::util::curveMap(phi, exponent); +} + +// The reported defect, stated as the gate: at every exponent the knot's centre lies on the +// trace, within 1 px. Swept over a range of canvas widths (down to a few pixels of stage span) +// so the check actually exercises ODD pixel spans, where the segment's true midpoint falls +// between two pixels — testKnotHeightTracksTheExponent above sits at a width whose span happens +// to be even, which is exactly the kind of fixture that missed this defect. +static void testKnotSitsOnItsOwnCurveAcrossOddAndEvenSpans() { + bool sawOdd = false, sawEven = false; + int worstAhdsr = 0, worstAhd = 0; + for (int width = 24; width <= 260; width += 3) { + const Rect a = Rect::ltrb(0, 0, width, 100); + for (double exp : {util::kCurveMin, 0.3, 1.0, 3.0, util::kCurveMax}) { + StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0); + e.attackCurve = exp; + EnvVertex origin, attackEnd, knot; + const std::vector poly = buildEnvelopePolyline(e, overlayOf(a), 4.0); + if (findNode(poly, EnvNode::Origin, origin) && + findNode(poly, EnvNode::AttackEnd, attackEnd) && + findNode(poly, EnvNode::AttackCurve, knot)) { + const int span = attackEnd.x - origin.x; + if (span > 0) { + if (span % 2 == 0) sawEven = true; else sawOdd = true; + const double expected = + expectedKnotLevel(origin.x, attackEnd.x, knot.x, 0.0, 1.0, exp); + const int expectedY = levelToY(a, expected); + worstAhdsr = (std::max)(worstAhdsr, std::abs(knot.y - expectedY)); + CHECK(std::abs(knot.y - expectedY) <= 1); + } + } + + StageEnvelope f = ahd(0.4, 0.6, 0.5, 0.0, 3.0); + f.attackCurve = exp; + EnvVertex originAhd, attackEndAhd, knotAhd; + const std::vector polyAhd = buildEnvelopePolyline(f, overlayOf(a), 4.0); + if (findNode(polyAhd, EnvNode::Origin, originAhd) && + findNode(polyAhd, EnvNode::AttackEnd, attackEndAhd) && + findNode(polyAhd, EnvNode::AttackCurve, knotAhd)) { + const int span = attackEndAhd.x - originAhd.x; + if (span > 0) { + if (span % 2 == 0) sawEven = true; else sawOdd = true; + const double expected = expectedKnotLevel(originAhd.x, attackEndAhd.x, + knotAhd.x, 0.0, 1.0, exp); + const int expectedY = levelToY(a, expected); + worstAhd = (std::max)(worstAhd, std::abs(knotAhd.y - expectedY)); + CHECK(std::abs(knotAhd.y - expectedY) <= 1); + } + } + } + } + CHECK(sawOdd); // the sweep actually exercised an odd-pixel span... + CHECK(sawEven); // ...and an even one, so this isn't resting on one fixture's luck. + std::printf(" worst knot/curve separation: AHDSR %d px, AHD %d px\n", worstAhdsr, worstAhd); +} + +// Exponent 1.0 is still a plain straight line even off the segment's exact midpoint — checked +// at a deliberately ODD span so the linear case isn't only proven at the symmetric one. +static void testNeutralExponentIsAStraightLineOffCentre() { + bool found = false; + for (int width = 24; width <= 200 && !found; ++width) { + const Rect a = Rect::ltrb(0, 0, width, 100); + StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0); + e.attackCurve = util::kCurveNeutral; + EnvVertex origin, attackEnd, knot; + const std::vector poly = buildEnvelopePolyline(e, overlayOf(a), 4.0); + if (!findNode(poly, EnvNode::Origin, origin)) continue; + if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue; + if (!findNode(poly, EnvNode::AttackCurve, knot)) continue; + const int span = attackEnd.x - origin.x; + if (span <= 0 || span % 2 == 0) continue; + found = true; + const double phi = static_cast(knot.x - origin.x) / static_cast(span); + CHECK(std::fabs(knot.level - phi) < 1e-12); // linear: level == phi, exactly + } + CHECK(found); // the sweep must actually land on an odd span +} + // --- degenerate --------------------------------------------------------------- static void testDegenerateSurfaceYieldsFlatBaseline() { @@ -458,6 +549,8 @@ int main() { testKnotsRideOnlySlopedNonZeroSegments(); testKnotHeightTracksTheExponent(); + testKnotSitsOnItsOwnCurveAcrossOddAndEvenSpans(); + testNeutralExponentIsAStraightLineOffCentre(); testDegenerateSurfaceYieldsFlatBaseline(); From ac653aa581237736b04e47f9a3099abf071b9428 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 21:11:08 -0400 Subject: [PATCH 09/56] Measure Preserve's splice-alignment geometry on low-frequency material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A splice can only relocate by [0.75, 1.25]*window, so periods with no multiple in that interval never phase-align — at 50 ms, f < 16 Hz and 26.7-32 Hz. Harness runs by hand; too slow to gate. --- src/core/instrument/engine/CMakeLists.txt | 10 + src/core/instrument/engine/time_stretch.h | 15 + tests/test_preserve_low_frequency.cpp | 700 ++++++++++++++++++++++ 3 files changed, 725 insertions(+) create mode 100644 tests/test_preserve_low_frequency.cpp diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 46690f9..864b9b9 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -50,6 +50,16 @@ reasampler_test(live_delivery LINK sampler_core) # both mode shapes share, and the Trigger tail's terminal behaviour. reasampler_test(staged_envelopes LINK sampler_core) +# Measurement harness for Preserve on low-frequency material: how the splice search's +# reachable relocation interval interacts with a long source period. Written longhand and +# deliberately NOT add_test()'d — it sweeps frequencies, windows and spectra and takes ~2m40s +# in Debug, which does not belong in a gate whose other targets run in seconds. It still +# builds with everything else, so it cannot rot into non-compilation. Run it by hand, in +# Release, when the question is what Preserve does to a given frequency. +add_executable(preserve_low_frequency_tests + ${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp) +target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core) + # The Preserve read's source-feed schedule — the TIME half beside pitch_shift's PITCH half. # Header-only (it sits on the per-sample feed), hence INTERFACE. add_library(time_stretch INTERFACE) diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index d13ad5a..d2dd645 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -27,6 +27,21 @@ namespace reasampler::instrument::engine { // -24 st is reachable from the Pitch knob alone. (The pre-stretch rate-1.0 engine's floor by // the same inequality is P > 735, ~60 Hz — what this range raises the floor from, not what // it removes.) +// +// A SECOND, INDEPENDENT limit binds the same material, and no rate bound touches it. A splice +// relocates the tap by the nominal window refined by a search over +/- window/4, so the +// reachable relocation distances are exactly [0.75, 1.25] * window; a phase-aligned splice +// needs a WHOLE NUMBER of source periods inside that one interval. The interval is 0.5*window +// wide, so any period <= window/2 always has a multiple in it — but above that, coverage +// breaks into disjoint bands (n=1 covers periods [0.75, 1.25]*window, n=2 covers +// [0.375, 0.625]*window) and the gap between them is reachable by nothing. Because both the +// interval and the period scale with the sample rate, the unalignable set is fixed in Hz by +// the window's MILLISECONDS: at 50 ms that is f < 16 Hz and 26.7 Hz < f < 32 Hz. Measured +// (Release, 44.1k and 48k) at 30 Hz: the rendered pitch stays correct, but energy outside the +// fundamental is 3.6% at +2 st / rate 1.0 and 15.5% at rate 2.0, against 0.00% at 34 Hz under +// identical conditions; at 29 Hz / rate 2.0 the tone itself lands 7.4% flat. Unlike the +// cadence inequality above, this one is not about how OFTEN a splice fires — a window of at +// least two source periods removes it outright, and nothing else does. inline constexpr double kStretchRateMin = 0.5; inline constexpr double kStretchRateMax = 2.0; inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) diff --git a/tests/test_preserve_low_frequency.cpp b/tests/test_preserve_low_frequency.cpp new file mode 100644 index 0000000..7c02540 --- /dev/null +++ b/tests/test_preserve_low_frequency.cpp @@ -0,0 +1,700 @@ +// Measurement harness for the Preserve engine's behaviour on LOW-FREQUENCY material. +// Reports numbers; it renders no perceptual verdict and changes no DSP. +// +// The question it answers: a splice relocates the read tap by a nominal `window` refined by a +// correlation search over +/- maxLag, so the reachable relocation distances form ONE bounded +// interval. Phase-aligning a splice needs a WHOLE NUMBER OF SOURCE PERIODS inside that +// interval, and for some periods none exists — a geometric limit, separate from the +// splice-CADENCE inequality. Both now sit in time_stretch.h; this is what measured them. +// +// Two findings shaped the sections below and are worth knowing before reading the output: +// whether an unreachable multiple accumulates into a DETUNE or only wobbles the phase depends +// on whether the nearest multiple misses on one side or straddles (a straddle cancels in the +// mean); and PITCH IS THE WRONG THING TO MEASURE HERE — the fundamental usually survives, so +// the load-bearing metric is energy outside it. Zero-crossing counting in particular reports a +// wrong period on renders whose fundamental is provably correct, which is section C. +// +// Measures, at both 44.1k and 48k geometry: +// A. the reachable relocation interval, observed rather than derived (jump/lag/frac off +// every SpliceEvent), and the alignment-reachability predicate over frequency. +// B. Voice-level renders at 30 Hz: the shipped default path, then transposition at rate 1.0 +// and rate 0.5/2.0 with none, then a 20-200 Hz sweep to locate the turnover. +// C. the P=500-frame (~88 Hz) case the pitch_shift floor probe fails on, measured with three +// independent pitch estimators to separate the two mechanisms from a measurement artifact. +// D. a window sweep at 30 Hz — what a larger window would buy, and what it would cost. +// E. alignable frequencies under identical conditions, without which D and B have no scale. + +#include "../src/core/instrument/engine/pitch_shift.h" +#include "../src/core/instrument/engine/time_stretch.h" +#include "../src/core/instrument/engine/voice.h" + +#include +#include +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::engine; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +constexpr double kPi = 3.14159265358979323846; + +// --------------------------------------------------------------------------------------- +// Source + render helpers +// --------------------------------------------------------------------------------------- + +// A pure sine at `freqHz`, phase-continuous, long enough that a rate-2.0 render never +// exhausts it (the caller sizes `frames`). +static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames, + PitchEngine engine, double phase = 0.0) { + SampleData s; + s.frames.resize(frames); + const double w = 2.0 * kPi * freqHz / static_cast(sampleRate); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(w * static_cast(i) + phase)); + } + s.sampleRate = sampleRate; + s.rootNote = 60; + s.play.pitchEngine = engine; // Gate, no loop, default (fully open) AHDSR + return s; +} + +// One note through the REAL Voice: presize (off-thread step), start with the stretch rate, +// then pull `outFrames` mono frames. `note - 60` is the transposition in semitones. +static std::vector renderVoice(const SampleData& s, int note, double stretchRate, + std::int64_t window, std::size_t outFrames) { + Voice v; + v.presizePreserveShifters(window); + v.start(note, 127, s, /*declickTakeover=*/false, stretchRate); + std::vector out(outFrames, 0.0); + for (std::size_t i = 0; i < outFrames; ++i) { + out[i] = static_cast(v.renderFrame()); + } + return out; +} + +// --------------------------------------------------------------------------------------- +// Metrics +// --------------------------------------------------------------------------------------- + +// Mean spacing between positive-going zero crossings over [from, to) — the same estimator +// test_pitch_shift.cpp uses, kept identical so the two files' numbers are comparable. +static double periodIn(const std::vector& v, std::size_t from, std::size_t to) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { + if (v[i - 1] <= 0.0 && v[i] > 0.0) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; +} + +// Least-squares fit of a single tone at `cyclesPerFrame` over [from, from+len): returns the +// fitted phase and writes the residual energy fraction (1 - explained), which is the +// single-tone-purity metric — 0 = a perfect sine at that frequency, 1 = none of the energy +// is there. Robust to amplitude but NOT to phase drift within the block, which is why the +// caller keeps blocks near one period. +static double toneFit(const std::vector& v, std::size_t from, std::size_t len, + double cyclesPerFrame, double* residFraction) { + double sc = 0.0, ss = 0.0, cc = 0.0, s2 = 0.0, cs = 0.0, e = 0.0; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { + const double t = 2.0 * kPi * cyclesPerFrame * static_cast(k); + const double c = std::cos(t), s = std::sin(t); + const double x = v[from + k]; + sc += x * c; ss += x * s; cc += c * c; s2 += s * s; cs += c * s; e += x * x; + } + const double det = cc * s2 - cs * cs; + double a = 0.0, b = 0.0; + if (std::fabs(det) > 1e-12) { + a = (sc * s2 - ss * cs) / det; + b = (ss * cc - sc * cs) / det; + } + const double explained = a * sc + b * ss; // energy captured by the fit + if (residFraction != nullptr) *residFraction = e > 0.0 ? 1.0 - explained / e : 0.0; + return std::atan2(b, a); +} + +// Total unwrapped phase drift (in CYCLES) of the render relative to an ideal tone at +// `cyclesPerFrame`, measured across [from, to) in one-period blocks. This is the direct +// observable behind "the rendered pitch is wrong": a nonzero drift IS a frequency error. +static double phaseDriftCycles(const std::vector& v, std::size_t from, std::size_t to, + double cyclesPerFrame, double* worstStepCycles) { + const std::size_t blk = static_cast(1.0 / cyclesPerFrame); + double total = 0.0, prev = 0.0, worst = 0.0; + bool first = true; + for (std::size_t p = from; p + blk <= to && p + blk < v.size(); p += blk) { + const double ph = toneFit(v, p, blk, cyclesPerFrame, nullptr); + if (!first) { + double d = ph - prev; + while (d > kPi) d -= 2.0 * kPi; + while (d < -kPi) d += 2.0 * kPi; + total += d / (2.0 * kPi); + if (std::fabs(d) / (2.0 * kPi) > worst) worst = std::fabs(d) / (2.0 * kPi); + } + prev = ph; + first = false; + } + if (worstStepCycles != nullptr) *worstStepCycles = worst; + return total; +} + +// Median single-tone residual fraction over the render, in one-period blocks. +static double medianResidual(const std::vector& v, std::size_t from, std::size_t to, + double cyclesPerFrame) { + const std::size_t blk = static_cast(1.0 / cyclesPerFrame); + std::vector r; + for (std::size_t p = from; p + blk <= to && p + blk < v.size(); p += blk) { + double resid = 0.0; + toneFit(v, p, blk, cyclesPerFrame, &resid); + r.push_back(resid); + } + if (r.empty()) return 0.0; + std::size_t mid = r.size() / 2; + std::nth_element(r.begin(), r.begin() + static_cast(mid), r.end()); + return r[mid]; +} + +// Period of the highest normalized-autocorrelation peak over [minLag, maxLag] — a pitch +// estimator that, unlike zero-crossing counting, is not fooled by a low-level fast component +// adding spurious crossings. The two disagreeing is itself the diagnosis. +static double autocorrPeriod(const std::vector& v, std::size_t from, std::size_t len, + std::int64_t minLag, std::int64_t maxLag) { + double e0 = 0.0; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) e0 += v[from + k] * v[from + k]; + if (e0 <= 0.0) return 0.0; + double best = -1e18; std::int64_t bestLag = 0; + std::vector score(static_cast(maxLag - minLag + 1), 0.0); + for (std::int64_t lag = minLag; lag <= maxLag; ++lag) { + double s = 0.0, e = 0.0; + for (std::size_t k = 0; k < len && from + k + static_cast(lag) < v.size(); + ++k) { + const double b = v[from + k + static_cast(lag)]; + s += v[from + k] * b; + e += b * b; + } + const double r = e > 0.0 ? s / std::sqrt(e0 * e) : 0.0; + score[static_cast(lag - minLag)] = r; + if (r > best) { best = r; bestLag = lag; } + } + // Parabolic refinement so the estimate isn't quantized to whole frames. + const std::size_t i = static_cast(bestLag - minLag); + double frac = 0.0; + if (i > 0 && i + 1 < score.size()) { + const double den = score[i - 1] - 2.0 * score[i] + score[i + 1]; + if (den < 0.0) frac = 0.5 * (score[i - 1] - score[i + 1]) / den; + } + return static_cast(bestLag) + frac; +} + +// The five strongest spectral peaks over a Hann-windowed segment, scanned on a fine period +// grid (Goertzel-style direct evaluation, no FFT-bin quantization). Prints period in frames +// and magnitude relative to the strongest — the decisive "is the rendered pitch wrong, or is +// there a second component fooling the zero-crossing count" measurement. +static void reportSpectrum(const char* label, const std::vector& v, std::size_t from, + std::size_t len, double wantPeriod) { + const int kGrid = 2000; + const double pLo = 30.0, pHi = 8000.0; + std::vector mag(static_cast(kGrid), 0.0); + std::vector per(static_cast(kGrid), 0.0); + for (int g = 0; g < kGrid; ++g) { + // Geometric grid: constant relative resolution across three octaves of period. + const double p = pLo * std::pow(pHi / pLo, static_cast(g) / (kGrid - 1)); + per[static_cast(g)] = p; + double re = 0.0, im = 0.0; + const double w = 2.0 * kPi / p; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { + const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast(k) / + static_cast(len))); + const double x = v[from + k] * hann; + re += x * std::cos(w * static_cast(k)); + im += x * std::sin(w * static_cast(k)); + } + mag[static_cast(g)] = std::sqrt(re * re + im * im); + } + double top = 0.0; + for (double m : mag) top = std::max(top, m); + // Local maxima, ranked by MAGNITUDE (not by grid order) so the fundamental cannot be + // pushed off the list by low-level debris at a shorter period. + std::vector> peaks; // (magnitude, period) + for (int g = 1; g + 1 < kGrid; ++g) { + const std::size_t i = static_cast(g); + if (mag[i] <= mag[i - 1] || mag[i] < mag[i + 1]) continue; + if (mag[i] < 0.02 * top) continue; + peaks.emplace_back(mag[i], per[i]); + } + std::sort(peaks.begin(), peaks.end(), + [](const std::pair& a, const std::pair& b) { + return a.first > b.first; + }); + // Energy fraction OUTSIDE the fundamental's mainlobe — the honest "how much of this render + // is not the wanted tone" number, since a peak list alone can hide broadband debris. + double eTotal = 0.0, eFund = 0.0; + for (int g = 0; g < kGrid; ++g) { + const std::size_t i = static_cast(g); + const double e = mag[i] * mag[i]; + eTotal += e; + if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e; + } + std::printf(" %s spectrum (want period %.1f fr); strongest peaks >2%% of max:\n", label, + wantPeriod); + for (std::size_t k = 0; k < peaks.size() && k < 8; ++k) { + std::printf(" period %8.1f fr rel %.4f%s\n", peaks[k].second, + peaks[k].first / top, + std::fabs(peaks[k].second - wantPeriod) / wantPeriod < 0.03 + ? " <-- the wanted tone" : ""); + } + std::printf(" energy outside the wanted tone's mainlobe: %.2f%%\n", + eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0); +} + +// --------------------------------------------------------------------------------------- +// A. Splice geometry, observed off the shifter's own SpliceEvent stream +// --------------------------------------------------------------------------------------- + +struct SpliceStats { + long long count = 0; + double minReloc = 1e18, maxReloc = -1e18; + std::int64_t minLag = 1LL << 40, maxLag = -(1LL << 40); + double meanInterval = 0.0; + bool jumpAlwaysNominal = true; // |jump| == window on every splice (steady state) +}; + +// Drives a bare PitchShifter over the same feed schedule Voice uses, recording every splice. +// The audio is not kept — this measures the DECISIONS, not the sound. +static SpliceStats spliceGeometry(const std::vector& src, std::int64_t window, + double rate, double shift, std::size_t outFrames, + std::vector* audio = nullptr) { + PitchShifter ps; + ps.configure(window); + ps.prime(src.data(), window); + ps.setShiftRatio(shift); + ps.setFeedRate(rate); + StretchCursor cur; + cur.start(window); + loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through + + SpliceStats st; + if (audio != nullptr) audio->assign(outFrames, 0.0); + std::size_t lastSpliceAt = 0; + double intervalSum = 0.0; + long long intervals = 0; + for (std::size_t i = 0; i < outFrames; ++i) { + const std::int64_t due = cur.due(rate); + AudioSample last = 0.0f; + bool fed = false; + for (std::int64_t k = 0; k < due; ++k) { + if (fed) ps.writeFrame(last); + const std::int64_t q = cur.next(lp); + last = (q >= 0 && static_cast(q) < src.size()) + ? src[static_cast(q)] : 0.0f; + fed = true; + } + const AudioSample o = fed ? ps.process(last) : ps.processNoInput(); + if (audio != nullptr) (*audio)[i] = static_cast(o); + const SpliceEvent& ev = ps.lastSplice(); + if (!ev.fired) continue; + ++st.count; + const double reloc = std::fabs(static_cast(ev.jump) - + static_cast(ev.lag) - ev.frac); + if (reloc < st.minReloc) st.minReloc = reloc; + if (reloc > st.maxReloc) st.maxReloc = reloc; + if (ev.lag < st.minLag) st.minLag = ev.lag; + if (ev.lag > st.maxLag) st.maxLag = ev.lag; + if (std::llabs(ev.jump) != window) st.jumpAlwaysNominal = false; + if (lastSpliceAt != 0) { intervalSum += static_cast(i - lastSpliceAt); ++intervals; } + lastSpliceAt = i; + } + st.meanInterval = intervals > 0 ? intervalSum / static_cast(intervals) : 0.0; + return st; +} + +// Is there a whole number of source periods inside the reachable relocation interval? +static bool alignmentReachable(double periodFrames, double lo, double hi, int* whichN) { + for (int n = 1; n <= 64; ++n) { + const double m = periodFrames * n; + if (m > hi) break; + if (m >= lo) { if (whichN != nullptr) *whichN = n; return true; } + } + if (whichN != nullptr) *whichN = 0; + return false; +} + +// --------------------------------------------------------------------------------------- +// 1. The reachable relocation interval, measured +// --------------------------------------------------------------------------------------- + +static void reportReachableInterval() { + std::printf("\n=== A. Reachable splice relocation interval (measured) ===\n"); + for (const auto& g : {std::pair{44100, 2205}, + std::pair{48000, 2400}}) { + const int sr = g.first; + const std::int64_t w = g.second; + // Broadband noise: every lag is a plausible candidate, so the search's own limits — + // not the source's periodicity — set the observed extremes. + std::vector noise(600000); + std::uint32_t rng = 12345u; + for (auto& x : noise) { + rng = rng * 1664525u + 1013904223u; + x = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); + } + SpliceStats up = spliceGeometry(noise, w, 1.0, 1.5, 120000); // up-shift: jump +w + SpliceStats dn = spliceGeometry(noise, w, 1.0, 0.7, 120000); // down-shift: jump -w + const double lo = std::min(up.minReloc, dn.minReloc); + const double hi = std::max(up.maxReloc, dn.maxReloc); + std::printf(" %d Hz, window %lld frames (%.1f ms):\n", sr, static_cast(w), + 1000.0 * static_cast(w) / sr); + std::printf(" up-shift splices %lld, lag [%lld, %lld], reloc [%.2f, %.2f]\n", + up.count, static_cast(up.minLag), + static_cast(up.maxLag), up.minReloc, up.maxReloc); + std::printf(" down-shift splices %lld, lag [%lld, %lld], reloc [%.2f, %.2f]\n", + dn.count, static_cast(dn.minLag), + static_cast(dn.maxLag), dn.minReloc, dn.maxReloc); + std::printf(" observed reachable relocation interval: [%.2f, %.2f] frames " + "= [%.2f, %.2f] ms\n", lo, hi, 1000.0 * lo / sr, 1000.0 * hi / sr); + std::printf(" structural bound (window +/- window/4): [%lld, %lld]\n", + static_cast(w - w / 4), static_cast(w + w / 4)); + // The jump is nominal and the lag is inside +/- window/4 — the two facts the + // reachable interval is derived from. + CHECK(up.jumpAlwaysNominal && dn.jumpAlwaysNominal); + CHECK(up.minLag >= -(w / 4) && up.maxLag <= w / 4); + CHECK(dn.minLag >= -(w / 4) && dn.maxLag <= w / 4); + } +} + +// The reachability predicate over frequency, at both geometries. Pure arithmetic over the +// interval measured above — no render, stated as such. +static void reportReachabilityByFrequency() { + std::printf("\n=== A2. Alignment reachability by frequency (arithmetic, not rendered) ===\n"); + const double freqs[] = {12, 14, 16, 18, 20, 22, 24, 26, 26.6, 28, 30, 31, 31.9, 32, + 34, 36, 40, 50, 60, 80, 88.2, 100, 140, 200}; + for (const auto& g : {std::pair{44100, 2205}, + std::pair{48000, 2400}}) { + const int sr = g.first; + const std::int64_t w = g.second; + const double lo = static_cast(w - w / 4), hi = static_cast(w + w / 4); + std::printf(" %d Hz / window %lld, interval [%.0f, %.0f] frames:\n", sr, + static_cast(w), lo, hi); + for (double f : freqs) { + const double P = static_cast(sr) / f; + int n = 0; + const bool ok = alignmentReachable(P, lo, hi, &n); + if (ok) { + std::printf(" %6.1f Hz P=%8.1f ALIGNABLE (n=%d, n*P=%.1f)\n", f, P, n, + n * P); + } else { + // How far the nearest multiple sits outside the interval, and the phase error + // that residual forces at every splice. + double best = 1e18; double bestM = 0.0; + for (int k = 1; k <= 64; ++k) { + const double m = P * k; + const double d = m < lo ? lo - m : (m > hi ? m - hi : 0.0); + if (d < best) { best = d; bestM = m; } + } + std::printf(" %6.1f Hz P=%8.1f UNALIGNABLE (nearest n*P=%.1f, off by " + "%.1f frames = %.1f deg of phase)\n", + f, P, bestM, best, 360.0 * best / P); + } + } + } +} + +// --------------------------------------------------------------------------------------- +// 2. Voice-level renders at 30 Hz +// --------------------------------------------------------------------------------------- + +// The reassurance case: the SHIPPED default path. 30 Hz played at its root, rate 1.0, no +// transposition. The shift is exactly 1.0, so the tap's delay never drifts and no splice can +// fire; a primed shifter at unity is a bit-exact pass-through. Baseline is the SAME source +// through Varispeed at the root, which is a straight readPos_ += 1.0 read of the PCM — i.e. +// the unprocessed sample. This is NOT a comparison against a pre-change binary; it is the +// stronger claim that the path is transparent. +static void testRootRateUnityIsBitIdenticalToTheDirectRead() { + std::printf("\n=== B1. 30 Hz, root note, rate 1.0, no transposition ===\n"); + const int sr = 44100; + const std::int64_t w = 2205; + const std::size_t frames = 300000, outFrames = 250000; + const SampleData pres = sineSample(30.0, sr, frames, PitchEngine::Preserve); + const SampleData vari = sineSample(30.0, sr, frames, PitchEngine::Varispeed); + const std::vector p = renderVoice(pres, 60, 1.0, w, outFrames); + const std::vector v = renderVoice(vari, 60, 1.0, w, outFrames); + std::size_t firstDiff = outFrames; + for (std::size_t i = 0; i < outFrames; ++i) { + if (p[i] != v[i]) { firstDiff = i; break; } + } + std::printf(" Preserve vs Varispeed at root, %zu frames: %s\n", outFrames, + firstDiff == outFrames ? "BIT-IDENTICAL" + : "differ (first at frame ?)"); + if (firstDiff != outFrames) { + std::printf(" first difference at frame %zu (%.9f vs %.9f)\n", firstDiff, p[firstDiff], + v[firstDiff]); + } + CHECK(firstDiff == outFrames); + // And the same claim at the 48k geometry. + const SampleData pres48 = sineSample(30.0, 48000, frames, PitchEngine::Preserve); + const SampleData vari48 = sineSample(30.0, 48000, frames, PitchEngine::Varispeed); + const std::vector p48 = renderVoice(pres48, 60, 1.0, 2400, outFrames); + const std::vector v48 = renderVoice(vari48, 60, 1.0, 2400, outFrames); + bool same48 = true; + for (std::size_t i = 0; i < outFrames && same48; ++i) if (p48[i] != v48[i]) same48 = false; + std::printf(" same at 48k / window 2400: %s\n", same48 ? "BIT-IDENTICAL" : "DIFFER"); + CHECK(same48); + // Splice count on the same conditions, read off the shifter directly. + std::vector src(pres.frames.begin(), pres.frames.end()); + const SpliceStats st = spliceGeometry(src, w, 1.0, 1.0, outFrames); + std::printf(" splices fired over %zu frames at shift 1.0, rate 1.0: %lld\n", outFrames, + st.count); + CHECK(st.count == 0); + + // Onset at a MUCH larger window — the cost side of any window-resize option. prime() + // parks the tap on src[0] whatever the window, so frame 0 must still be source frame 0. + // Started at quarter-phase so src[0] is FULL SCALE, not the zero a sine would give: a + // frame-0 match against 0.0 would also pass on a voice that produced silence. + const SampleData cosPhase = + sineSample(30.0, sr, 300000, PitchEngine::Preserve, kPi / 2.0); + CHECK(cosPhase.frames[0] == 1.0f); + for (std::int64_t big : {std::int64_t{2205}, std::int64_t{8820}}) { + const std::vector up = renderVoice(cosPhase, 67, 1.0, big, 64); // +7 st + std::printf(" window %5lld, +7 st: out[0]=%.9f (src[0]=%.9f), |out| over frames 1..63 " + "min %.6f\n", static_cast(big), up[0], + static_cast(cosPhase.frames[0]), + *std::min_element(up.begin() + 1, up.end(), + [](double a, double b) { return std::fabs(a) < std::fabs(b); })); + CHECK(up[0] == static_cast(cosPhase.frames[0])); // zero added latency + } + // A sample SHORTER than the window: start() primes the whole playable span and freezes + // the writer immediately. A larger window moves that threshold, so check it still speaks + // on frame 0 at the largest window swept below. + const SampleData shortSample = + sineSample(30.0, sr, 3000, PitchEngine::Preserve, kPi / 2.0); + const std::vector shortOut = renderVoice(shortSample, 67, 1.0, 8820, 64); + std::printf(" 3000-frame sample under a 8820-frame window, +7 st: out[0]=%.9f src[0]=%.9f\n", + shortOut[0], static_cast(shortSample.frames[0])); + CHECK(shortOut[0] == static_cast(shortSample.frames[0])); +} + +// One measured row: render through the Voice and report every metric for that condition. +static void measureRow(const char* label, double freqHz, int sr, std::int64_t window, + int note, double rate) { + const std::size_t frames = 900000; + const std::size_t outFrames = 300000; + const SampleData s = sineSample(freqHz, sr, frames, PitchEngine::Preserve); + const double shift = std::pow(2.0, (note - 60) / 12.0); + const std::vector out = renderVoice(s, note, rate, window, outFrames); + + bool finite = true; + double peak = 0.0; + for (double x : out) { if (!std::isfinite(x)) finite = false; peak = std::max(peak, std::fabs(x)); } + + const double srcPeriod = static_cast(sr) / freqHz; + const double wantPeriod = srcPeriod / shift; // pitch is the TAP's, not the feed's + const double wantCpf = 1.0 / wantPeriod; + const std::size_t from = 40000, to = 280000; + const double gotPeriod = periodIn(out, from, to); + double worstStep = 0.0; + const double drift = phaseDriftCycles(out, from, to, wantCpf, &worstStep); + const double resid = medianResidual(out, from, to, wantCpf); + + std::vector src(s.frames.begin(), s.frames.end()); + const SpliceStats st = spliceGeometry(src, window, rate, shift, outFrames); + const double lo = static_cast(window - window / 4); + const double hi = static_cast(window + window / 4); + int n = 0; + const bool reach = alignmentReachable(srcPeriod, lo, hi, &n); + + // Effective frequency error implied by the drift, and the phase step it works out to per + // splice — the number that says whether a splice is stepping the phase or not. + const double driftPerSplice = st.count > 0 ? drift / static_cast(st.count) : 0.0; + std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | period got %8.2f want %8.2f " + "(%+.2f%%) | splices %4lld every %7.0f fr | phase drift %+8.3f cyc " + "(%+7.1f deg/splice, worst step %.1f deg) | resid %.4f | peak %.3f | " + "align %s%s\n", + label, freqHz, shift, rate, gotPeriod, wantPeriod, + wantPeriod > 0.0 ? 100.0 * (gotPeriod - wantPeriod) / wantPeriod : 0.0, + st.count, st.meanInterval, drift, 360.0 * driftPerSplice, 360.0 * worstStep, + resid, peak, reach ? "YES" : "NO", + reach ? "" : " <-- no whole period in the reachable interval"); + CHECK(finite); +} + +// Three independent pitch estimators plus the spectrum, on one condition. Where the +// zero-crossing count and the autocorrelation disagree, the render is not simply detuned — +// something else is crossing zero. +static void deepDive(const char* label, double freqHz, int sr, std::int64_t window, int note, + double rate) { + const SampleData s = sineSample(freqHz, sr, 900000, PitchEngine::Preserve); + const double shift = std::pow(2.0, (note - 60) / 12.0); + const std::vector out = renderVoice(s, note, rate, window, 300000); + const double wantPeriod = (static_cast(sr) / freqHz) / shift; + const double zc = periodIn(out, 40000, 280000); + // Search bounded to [0.5, 1.7] x the wanted period: a pure sine autocorrelates equally at + // EVERY multiple of its period, so an unbounded search reports 2P about half the time. + const double ac = autocorrPeriod(out, 60000, 60000, + std::max(40, + static_cast(wantPeriod * 0.5)), + static_cast(wantPeriod * 1.7)); + std::printf(" %s (f=%.1f Hz, shift %.4f, rate %.2f, want period %.1f fr):\n", label, freqHz, + shift, rate, wantPeriod); + std::printf(" zero-crossing period %.2f (%+.2f%%) | autocorrelation period %.2f " + "(%+.2f%%)\n", zc, 100.0 * (zc - wantPeriod) / wantPeriod, ac, + 100.0 * (ac - wantPeriod) / wantPeriod); + reportSpectrum(label, out, 60000, 131072, wantPeriod); +} + +static void reportTransposedAt30Hz() { + std::printf("\n=== B2. 30 Hz transposed, rate 1.0 (44.1k / window 2205) ===\n"); + measureRow("30 Hz +2 st", 30.0, 44100, 2205, 62, 1.0); + measureRow("30 Hz +7 st", 30.0, 44100, 2205, 67, 1.0); + measureRow("30 Hz -7 st", 30.0, 44100, 2205, 53, 1.0); + std::printf("\n=== B3. 30 Hz stretched, no transposition (44.1k / window 2205) ===\n"); + measureRow("30 Hz rate 0.5", 30.0, 44100, 2205, 60, 0.5); + measureRow("30 Hz rate 2.0", 30.0, 44100, 2205, 60, 2.0); + std::printf("\n=== B4. the same six at 48k / window 2400 ===\n"); + measureRow("30 Hz +2 st @48k", 30.0, 48000, 2400, 62, 1.0); + measureRow("30 Hz +7 st @48k", 30.0, 48000, 2400, 67, 1.0); + measureRow("30 Hz rate 2.0 @48k", 30.0, 48000, 2400, 60, 2.0); +} + +// Where does the behaviour actually turn over? Swept at a fixed, modest transposition so the +// only thing changing is the source period against the reachable interval. +static void reportFrequencySweep() { + std::printf("\n=== B5. Frequency sweep, +2 st, rate 1.0 (44.1k / window 2205) ===\n"); + const double freqs[] = {20, 22, 24, 25, 26, 26.5, 27, 28, 29, 30, 31, 31.5, 32, 33, + 34, 36, 40, 45, 50, 60, 70, 80, 88.2, 100, 120, 140, 170, 200}; + for (double f : freqs) measureRow("sweep +2 st", f, 44100, 2205, 62, 1.0); + std::printf("\n=== B6. Same sweep at rate 2.0, NO transposition ===\n"); + for (double f : freqs) measureRow("sweep rate 2.0", f, 44100, 2205, 60, 2.0); +} + +// --------------------------------------------------------------------------------------- +// 3. The P=500 case: geometric, or cadence? +// --------------------------------------------------------------------------------------- + +// pitch_shift_tests' floor probe fails at source period 500 frames, rate 2.0, shift 0.25 and +// holds at 600/700. Under the geometric claim, alignment needs a whole number of source +// periods in [0.75w, 1.25w] = [1654, 2756]. This reports whether that is satisfied for each of +// the three periods — separating "no aligned landing point exists" (geometric) from "an +// aligned landing point exists but the cadence is too fast to use it" (the inequality already +// in time_stretch.h). +static void reportFloorProbeMechanism() { + std::printf("\n=== C. The P=500/600/700 floor probe: which mechanism? ===\n"); + const std::int64_t w = 2205; + const int sr = 44100; + const double rate = 2.0; + const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25 + const double lo = static_cast(w - w / 4), hi = static_cast(w + w / 4); + for (double period : {500.0, 600.0, 700.0}) { + int n = 0; + const bool reach = alignmentReachable(period, lo, hi, &n); + const double freq = static_cast(sr) / period; + // The cadence inequality from time_stretch.h, evaluated for this row. + const double cadence = static_cast(w) / std::fabs(rate - shift); + const double outPeriod = period / shift; + std::printf(" P=%5.0f (%.1f Hz): alignable in [%.0f,%.0f]? %s%s | cadence %.0f fr vs " + "output period %.0f fr -> %s\n", + period, freq, lo, hi, reach ? "YES" : "NO", + reach ? "" : " (geometric failure)", + cadence, outPeriod, + cadence < outPeriod ? "SPLICE INSIDE A CYCLE (cadence failure)" : "ok"); + measureRow("floor probe", freq, sr, w, 60 - 24, rate); + } + // The probe's OWN signal, reproduced exactly: a bare PitchShifter fed by the same + // schedule test_pitch_shift.cpp's runStretch uses, not the Voice. Its zero-crossing + // number is the one that is currently RED, so it is the one that has to be explained. + std::printf("\n --- the probe's exact signal (bare PitchShifter, runStretch schedule) ---\n"); + for (double period : {500.0, 600.0, 700.0}) { + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * static_cast(i) / period)); + } + std::vector out; + const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out); + const double want = period / shift; + const double zc = periodIn(out, 20000, 50000); + const double ac = autocorrPeriod(out, 20000, 20000, + static_cast(want * 0.5), + static_cast(want * 1.7)); + std::printf(" P=%.0f: zero-crossing %.2f (%+.2f%%) | autocorrelation %.2f (%+.2f%%) " + "| splices %lld every %.0f fr\n", period, zc, 100.0 * (zc - want) / want, + ac, 100.0 * (ac - want) / want, st.count, st.meanInterval); + reportSpectrum("probe", out, 20000, 32768, want); + } + + std::printf("\n --- independent pitch estimators on the same three (through the Voice) ---\n"); + deepDive("P=500", 44100.0 / 500.0, sr, w, 36, rate); + deepDive("P=600", 44100.0 / 600.0, sr, w, 36, rate); + deepDive("P=700", 44100.0 / 700.0, sr, w, 36, rate); + std::printf("\n --- and on the geometric cases, for contrast ---\n"); + deepDive("30 Hz +2 st rate 1.0", 30.0, sr, w, 62, 1.0); + deepDive("30 Hz rate 2.0", 30.0, sr, w, 60, 2.0); + deepDive("29 Hz rate 2.0", 29.0, sr, w, 60, 2.0); +} + +// What would a bigger window buy? The reachable interval is [0.75w, 1.25w], so it contains a +// whole number of source periods for EVERY period P <= 0.5w — i.e. a window of at least TWO +// source periods makes alignment reachable unconditionally. This sweeps 30 Hz across windows +// spanning that threshold (1470 * 2 = 2940 frames = 66.7 ms at 44.1k) and reports what +// actually changes. The window is an ARGUMENT to configure(); nothing shipped is altered. +static void reportWindowSweep() { + std::printf("\n=== D. Window sweep at 30 Hz — what a larger window would buy ===\n"); + const int sr = 44100; + const double P = static_cast(sr) / 30.0; + std::printf(" source period %.1f frames; alignment is unconditional once window >= 2P = " + "%.0f frames (%.1f ms)\n", P, 2.0 * P, 2000.0 * P / sr); + for (std::int64_t w : {std::int64_t{2205}, std::int64_t{2646}, std::int64_t{2940}, + std::int64_t{3528}, std::int64_t{4410}, std::int64_t{8820}}) { + const double lo = static_cast(w - w / 4), hi = static_cast(w + w / 4); + int n = 0; + const bool reach = alignmentReachable(P, lo, hi, &n); + std::printf("\n window %lld fr (%.1f ms), interval [%.0f, %.0f]: %s\n", + static_cast(w), 1000.0 * static_cast(w) / sr, lo, hi, + reach ? "ALIGNABLE" : "unalignable"); + // Per-voice Preserve state: two shifter rings of 2*window floats (L/R) plus the + // window-sized prime scratch = 5*window floats (voice.cpp presizePreserveShifters). + const double bytes = 5.0 * static_cast(w) * 4.0; + std::printf(" per-voice Preserve state %.1f KB; at the 32-voice ceiling %.2f MB\n", + bytes / 1024.0, 32.0 * bytes / (1024.0 * 1024.0)); + measureRow(" 30 Hz +2 st", 30.0, sr, w, 62, 1.0); + measureRow(" 30 Hz rate 2.0", 30.0, sr, w, 60, 2.0); + deepDive(" +2 st", 30.0, sr, w, 62, 1.0); + deepDive(" rate 2.0", 30.0, sr, w, 60, 2.0); + } +} + +// Alignable neighbours under identical conditions — without these the out-of-band-energy +// numbers above have no scale. +static void reportAlignableControls() { + std::printf("\n=== E. Alignable controls (same conditions, a frequency that CAN align) ===\n"); + deepDive("34 Hz +2 st rate 1.0", 34.0, 44100, 2205, 62, 1.0); + deepDive("34 Hz rate 2.0", 34.0, 44100, 2205, 60, 2.0); + deepDive("20 Hz +2 st rate 1.0", 20.0, 44100, 2205, 62, 1.0); + deepDive("220 Hz +2 st rate 1.0", 220.0, 44100, 2205, 62, 1.0); + deepDive("220 Hz rate 2.0", 220.0, 44100, 2205, 60, 2.0); +} + +int main() { + reportReachableInterval(); + reportReachabilityByFrequency(); + testRootRateUnityIsBitIdenticalToTheDirectRead(); + reportTransposedAt30Hz(); + reportFrequencySweep(); + reportFloorProbeMechanism(); + reportAlignableControls(); + reportWindowSweep(); + + if (g_fail == 0) { + std::printf("\nall preserve_low_frequency measurements completed\n"); + return 0; + } + std::printf("\n%d preserve_low_frequency check(s) failed\n", g_fail); + return 1; +} From f39fb1b1455bd9eeb24fd1f2f292f42508956212 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 18:43:55 -0400 Subject: [PATCH 10/56] =?UTF-8?q?=CE=93-W1-T3:=20staged=20contour=20traces?= =?UTF-8?q?=20draw=20the=20curve=20their=20exponent=20defines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pure curve_tessellate joins the overlay's node vertices through curveMap, one sample per pixel column; the knot no longer floats off its own trace. --- src/core/instrument/CLAUDE.md | 1 + src/core/instrument/ui/CMakeLists.txt | 9 + src/core/instrument/ui/curve_tessellate.cpp | 65 +++ src/core/instrument/ui/curve_tessellate.h | 34 ++ src/shell/instrument/CMakeLists.txt | 1 + .../instrument/editor_paint_waveform.cpp | 11 +- tests/test_curve_tessellate.cpp | 395 ++++++++++++++++++ 7 files changed, 507 insertions(+), 9 deletions(-) create mode 100644 src/core/instrument/ui/curve_tessellate.cpp create mode 100644 src/core/instrument/ui/curve_tessellate.h create mode 100644 tests/test_curve_tessellate.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index f534336..061e08f 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -337,6 +337,7 @@ anything for a trigger shape. - `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. +- `curve_tessellate` — the staged envelope's TRACE, split from `envelope_overlay` on the axis those two already have: that module decides where a node LANDS, this strokes the span BETWEEN two of them. Joins the non-knot vertices with the curve each stage's exponent defines, sampled one point per pixel column, at `start + (end - start) * curveMap(phi)` — the composition `envelopes.h`'s four evaluators use, so a drawn stage and the sound it makes cannot diverge. Node vertices keep their exact integer coordinates (the handles are drawn on them); only the interior samples are sub-pixel. A neutral exponent or a zero level span emits the two endpoints and nothing between, which is the straight stroke drawn before curves existed, vertex for vertex. - `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge. ## Gotchas diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 114ac7b..9c428ed 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -104,3 +104,12 @@ reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_la # is judged against the envelope node drag at the editor's own floor width (the sharper of the # taper's two consumers), read from the allocator/overlay rather than copied as a number. reasampler_test(param_taper LINK param_taper envelope_overlay sample_bands) + +# The staged trace, split from envelope_overlay's vertex model. stroke_aa is the trace-point +# vocabulary, filled in place so the shell's scratch buffer is reused rather than a fresh +# vector returned per paint; curve_law arrives through envelope_overlay but is named here +# because this module evaluates the law rather than merely carrying its exponents. +reasampler_pure_library(curve_tessellate + SOURCES curve_tessellate.cpp + LINK PUBLIC envelope_overlay stroke_aa curve_law) +reasampler_test(curve_tessellate LINK curve_tessellate) diff --git a/src/core/instrument/ui/curve_tessellate.cpp b/src/core/instrument/ui/curve_tessellate.cpp new file mode 100644 index 0000000..ad341a1 --- /dev/null +++ b/src/core/instrument/ui/curve_tessellate.cpp @@ -0,0 +1,65 @@ +// curve_tessellate.cpp — see curve_tessellate.h. Pure geometry; no host types. + +#include "core/instrument/ui/curve_tessellate.h" + +#include +#include + +#include "core/util/curve_law.h" + +namespace reasampler::instrument::ui { + +using StrokePoint = reasampler::ui::StrokePoint; + +namespace { + +StrokePoint pt(double x, double y) { + return StrokePoint{static_cast(x), static_cast(y)}; +} + +// The samples strictly BETWEEN two nodes. The endpoints are the caller's, so a shared node is +// emitted once and the polyline carries no zero-length joint. +void appendInterior(int x0, int y0, int x1, int y1, double exponent, + std::vector& out) { + const int span = std::abs(x1 - x0); + if (span < 2 || y1 == y0 || exponent == util::kCurveNeutral) return; + const double dx = static_cast(x1 - x0); + const double dy = static_cast(y1 - y0); + for (int i = 1; i < span; ++i) { + // phi is exact at every column, so x lands on the integer column and the last interior + // sample is one column short of the end node. + const double phi = static_cast(i) / static_cast(span); + out.push_back(pt(static_cast(x0) + dx * phi, + static_cast(y0) + dy * util::curveMap(phi, exponent))); + } +} + +} // namespace + +double segmentCurve(const StageEnvelope& env, EnvNode endNode) { + switch (endNode) { + case EnvNode::AttackEnd: return env.attackCurve; + case EnvNode::DecayEnd: return env.decayCurve; + case EnvNode::ReleaseEnd: return env.releaseCurve; + default: return util::kCurveNeutral; + } +} + +void buildEnvelopeTrace(const std::vector& poly, const StageEnvelope& env, int xLo, + int xHi, std::vector& out) { + out.clear(); + bool started = false; + int px = 0; + int py = 0; + for (const EnvVertex& v : poly) { + if (v.knot) continue; + const int x = std::max(xLo, std::min(xHi, v.x)); + if (started) appendInterior(px, py, x, v.y, segmentCurve(env, v.node), out); + out.push_back(pt(x, v.y)); + started = true; + px = x; + py = v.y; + } +} + +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/curve_tessellate.h b/src/core/instrument/ui/curve_tessellate.h new file mode 100644 index 0000000..01d6b58 --- /dev/null +++ b/src/core/instrument/ui/curve_tessellate.h @@ -0,0 +1,34 @@ +// curve_tessellate.h — the staged envelope's TRACE: envelope_overlay's node vertices joined by +// the curve each stage's exponent defines. Split from that module on the axis the two already +// have — envelope_overlay decides where a node LANDS, this strokes the span BETWEEN two of +// them over phi, so a re-scaled time axis changes nothing here. + +#pragma once + +#include + +#include "core/instrument/ui/envelope_overlay.h" +#include "core/ui/stroke_aa.h" // StrokePoint — the stroker's own vertex type + +namespace reasampler::instrument::ui { + +// The exponent governing the segment that ENDS at `node`: attack, decay and release are the +// three sloped stages. Every other node ends a plateau, whose straightness comes from its own +// zero level span rather than from an exponent, so neutral is returned and no caller needs a +// second rule to recognize one. +double segmentCurve(const StageEnvelope& env, EnvNode endNode); + +// Replaces `out` with the polyline the shell strokes. Knot vertices are handles, not line +// vertices, and are skipped; node vertices keep their exact INTEGER coordinates (clamped to +// [xLo, xHi]) because those are the positions their draggable handles are drawn at. Only the +// interior samples are sub-pixel, one per pixel column, which is what makes the density follow +// the canvas width instead of a fixed count. +// +// A segment's level runs start + (end - start) * curveMap(phi, exponent) — the composition +// envelopes.h's four evaluators use, so the trace cannot diverge from the sound. A neutral +// exponent or a zero level span emits the two endpoints and nothing between: the straight +// stroke, vertex for vertex. +void buildEnvelopeTrace(const std::vector& poly, const StageEnvelope& env, int xLo, + int xHi, std::vector& out); + +} // namespace reasampler::instrument::ui diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index 155c272..d01e6fe 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -91,6 +91,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage bake_hold file_bytes curve_law stroke_aa + curve_tessellate bake_plan bake_render bake_reset bake_wire wav_codec) # SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives # LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC. diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 17d5d5c..9e62054 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -13,6 +13,7 @@ #include #include "core/audio/peaks.h" // computeEnvelope (waveform binning) +#include "core/instrument/ui/curve_tessellate.h" // buildEnvelopeTrace (the staged trace) #include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box) #include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX #include "shell/instrument/editor_internal.h" // kit adapters @@ -212,17 +213,9 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame); const std::vector poly = buildEnvelopePolyline(env, waveArea, totalSeconds); - // Clip x to the wave rect. Knots are handles, not line vertices. Vertices stay INTEGER here - // — unlike the spline traces above — because they are the same positions the draggable - // handles are drawn at, and a sub-pixel trace would sit off its own handles. const LICE_pixel line = toLice(roleColor(Role::OverlayTrace)); std::vector& trace = scratchPoints(); - trace.clear(); - for (const EnvVertex& v : poly) { - if (v.knot) continue; - const int vx = (std::max)(area.x, (std::min)(area.right() - 1, v.x)); - trace.push_back(ui::StrokePoint{static_cast(vx), static_cast(v.y)}); - } + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); // A degenerate envelope (every stage collapsed to zero span) can reduce this to ONE vertex. // strokePolylineAA's round-cap zero-length case then draws a dot at it, marking the sole // point rather than drawing nothing — kept deliberately as more legible than a blank trace. diff --git a/tests/test_curve_tessellate.cpp b/tests/test_curve_tessellate.cpp new file mode 100644 index 0000000..11e6371 --- /dev/null +++ b/tests/test_curve_tessellate.cpp @@ -0,0 +1,395 @@ +// Standalone tests for reasampler::instrument::ui::curve_tessellate — no VST3, no REAPER, no +// framework. Same fast assert loop as the sibling pure tests. +// +// Every assertion is expressed RELATIVE to the vertices buildEnvelopePolyline returns, never +// against an absolute pixel literal, so a re-scaled overlay axis leaves this file untouched. +// Covers: segmentCurve's node->exponent rule; the neutral exponent emitting today's straight +// vertex list unchanged; knots excluded and node vertices preserved exactly; THE GATE — the +// knot's centre within 1 px of the trace at every exponent, on all three sloped stages of both +// layout policies; the mid-segment level matching curve_law's own curveMidLevel; curvature +// direction; no overshoot past a segment's own endpoint levels; per-pixel-column density that +// scales with the canvas; the x clamp; and the degenerate/empty cases. +// +// Both layout policies here ARE all three envelopes and both play modes: the editor's +// packEnvelope collapses amp/filter/pitch x Gate/Trigger onto exactly these two EnvKinds, and +// paintEnvelopeOverlay is the single paint path over them. + +#include "../src/core/instrument/ui/curve_tessellate.h" + +#include +#include +#include + +#include "../src/core/util/curve_law.h" + +using namespace reasampler; +using namespace reasampler::instrument::ui; +using reasampler::ui::StrokePoint; + +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 OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; } + +// Offset so left/top != 0 (catches origin bugs). Tall enough that a 1 px tolerance is a small +// fraction of the level span, which is what makes the level assertions discriminating. +static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 210); } // width 1000, height 200 + +static StageEnvelope ahdsr(double a, double h, double d, double sus, double r) { + StageEnvelope e; + e.kind = EnvKind::Ahdsr; + e.attackSeconds = a; + e.holdSeconds = h; + e.decaySeconds = d; + e.sustainLevel = sus; + e.releaseSeconds = r; + return e; +} + +static StageEnvelope ahd(double a, double d, double frac, double span) { + StageEnvelope e; + e.kind = EnvKind::Ahd; + e.attackSeconds = a; + e.decaySeconds = d; + e.holdFraction = frac; + e.originSeconds = 0.0; + e.spanSeconds = span; + return e; +} + +static bool findNode(const std::vector& poly, EnvNode node, EnvVertex& out) { + for (const EnvVertex& v : poly) { + if (v.node == node) { out = v; return true; } + } + return false; +} + +// The polyline's y where it crosses `x` — the trace as STROKED, not merely its samples, so a +// knot between two samples is still measured against the line the user sees. +static bool traceYAtX(const std::vector& t, double x, double& y) { + for (std::size_t i = 1; i < t.size(); ++i) { + const double x0 = t[i - 1].x, x1 = t[i].x; + if (x1 == x0) { + if (x == x0) { y = t[i].y; return true; } + continue; + } + const double lo = x0 < x1 ? x0 : x1, hi = x0 < x1 ? x1 : x0; + if (x < lo || x > hi) continue; + const double u = (x - x0) / (x1 - x0); + y = t[i - 1].y + (t[i].y - t[i - 1].y) * u; + return true; + } + return false; +} + +// The three sloped stages, each named by the knot that rides it and the two nodes it runs +// between — the same pairing gatePolyline/ahdPolyline place the knot from. +struct Stage { + EnvNode knot; + EnvNode from; + EnvNode to; +}; +static const Stage kStages[3] = { + {EnvNode::AttackCurve, EnvNode::Origin, EnvNode::AttackEnd}, + {EnvNode::DecayCurve, EnvNode::HoldEnd, EnvNode::DecayEnd}, + {EnvNode::ReleaseCurve, EnvNode::ReleaseStart, EnvNode::ReleaseEnd}, +}; + +static const double kExponents[7] = {util::kCurveMin, 0.25, 0.5, util::kCurveNeutral, + 2.0, 4.0, util::kCurveMax}; + +// --------------------------------------------------------------------------------------- + +static void testSegmentCurve() { + StageEnvelope e = ahdsr(1.0, 0.0, 1.0, 0.5, 1.0); + e.attackCurve = 0.3; + e.decayCurve = 3.0; + e.releaseCurve = 7.0; + CHECK(segmentCurve(e, EnvNode::AttackEnd) == e.attackCurve); + CHECK(segmentCurve(e, EnvNode::DecayEnd) == e.decayCurve); + CHECK(segmentCurve(e, EnvNode::ReleaseEnd) == e.releaseCurve); + // The plateau-ending nodes carry no exponent of their own. + CHECK(segmentCurve(e, EnvNode::HoldEnd) == util::kCurveNeutral); + CHECK(segmentCurve(e, EnvNode::ReleaseStart) == util::kCurveNeutral); + CHECK(segmentCurve(e, EnvNode::Origin) == util::kCurveNeutral); +} + +// The regression guard: at the neutral exponent the trace IS the non-knot vertex list, one +// point per vertex, at the same integer coordinates — the straight stroke drawn before curves. +static void testNeutralIsTodaysStraightLine() { + const Rect area = wideArea(); + const StageEnvelope envs[2] = {ahdsr(2.0, 0.0, 2.0, 0.4, 2.0), ahd(1.2, 1.6, 0.5, 4.0)}; + for (const StageEnvelope& env : envs) { + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + std::size_t nodes = 0; + for (const EnvVertex& v : poly) if (!v.knot) ++nodes; + CHECK(trace.size() == nodes); + std::size_t i = 0; + for (const EnvVertex& v : poly) { + if (v.knot) continue; + CHECK(trace[i].x == static_cast(v.x)); + CHECK(trace[i].y == static_cast(v.y)); + ++i; + } + } +} + +// Knots are handles, not line vertices. A knot sits mid-canvas but is appended AFTER the last +// node, so admitting one would break the trace's x ordering — which is what this catches. +static void testKnotsExcludedAndNodesPreserved() { + const Rect area = wideArea(); + StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); + env.attackCurve = 0.3; + env.decayCurve = 4.0; + env.releaseCurve = 0.4; + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + std::size_t knots = 0; + for (const EnvVertex& v : poly) if (v.knot) ++knots; + CHECK(knots == 3); + + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + for (std::size_t i = 1; i < trace.size(); ++i) CHECK(trace[i].x >= trace[i - 1].x); + + // Every node vertex still appears at its exact integer position: the handles are drawn + // there, and a trace that missed one would sit off its own handle. + for (const EnvVertex& v : poly) { + if (v.knot) continue; + bool found = false; + for (const StrokePoint& p : trace) { + if (p.x == static_cast(v.x) && p.y == static_cast(v.y)) found = true; + } + CHECK(found); + } +} + +// THE GATE (plan acceptance criterion 1): at every exponent the knot's centre lies on the +// trace within 1 px. Swept over all three sloped stages of the AHDSR schematic. +static void testKnotLiesOnTraceAhdsr() { + const Rect area = wideArea(); + for (const Stage& st : kStages) { + for (double pw : kExponents) { + StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); + if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw; + else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw; + else env.releaseCurve = pw; + + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + EnvVertex knot; + CHECK(findNode(poly, st.knot, knot)); + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + double y = 0.0; + CHECK(traceYAtX(trace, static_cast(knot.x), y)); + CHECK(std::fabs(y - static_cast(knot.y)) <= 1.0); + } + } +} + +// The same gate on the AHD policy — its two sloped stages, its own 1:1 time axis. +static void testKnotLiesOnTraceAhd() { + const Rect area = wideArea(); + for (int stage = 0; stage < 2; ++stage) { + for (double pw : kExponents) { + StageEnvelope env = ahd(1.2, 1.6, 0.5, 4.0); + if (stage == 0) env.attackCurve = pw; else env.decayCurve = pw; + const EnvNode knotNode = stage == 0 ? EnvNode::AttackCurve : EnvNode::DecayCurve; + + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + EnvVertex knot; + CHECK(findNode(poly, knotNode, knot)); + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + double y = 0.0; + CHECK(traceYAtX(trace, static_cast(knot.x), y)); + CHECK(std::fabs(y - static_cast(knot.y)) <= 1.0); + } + } +} + +// The trace is the SAME law the audio evaluates: at a segment's midpoint its level is +// curve_law's curveMidLevel of that stage's exponent, composed onto the endpoints the trace +// itself returned. +static void testMidSegmentLevelMatchesTheLaw() { + const Rect area = wideArea(); + for (const Stage& st : kStages) { + for (double pw : kExponents) { + StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); + if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw; + else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw; + else env.releaseCurve = pw; + + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + EnvVertex a, b; + CHECK(findNode(poly, st.from, a)); + CHECK(findNode(poly, st.to, b)); + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + + const double xm = 0.5 * (static_cast(a.x) + static_cast(b.x)); + double y = 0.0; + CHECK(traceYAtX(trace, xm, y)); + const double want = static_cast(a.y) + + (static_cast(b.y) - static_cast(a.y)) * + util::curveMidLevel(pw); + CHECK(std::fabs(y - want) <= 1.0); + } + } +} + +// A curve must bend, and in the direction the exponent names: phi^p with p > 1 holds the level +// LOW for longer, p < 1 lifts it early. Stated against the chord between the two endpoints the +// trace returned, so it holds whichever way the segment slopes. +static void testCurvatureDirection() { + const Rect area = wideArea(); + for (const Stage& st : kStages) { + for (double pw : {0.25, 4.0}) { + StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); + if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw; + else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw; + else env.releaseCurve = pw; + + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + EnvVertex a, b; + CHECK(findNode(poly, st.from, a)); + CHECK(findNode(poly, st.to, b)); + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + + const double xm = 0.5 * (static_cast(a.x) + static_cast(b.x)); + double y = 0.0; + CHECK(traceYAtX(trace, xm, y)); + const double chord = 0.5 * (static_cast(a.y) + static_cast(b.y)); + // Normalized level at the midpoint, 0 at the start node, 1 at the end node. + const double dy = static_cast(b.y) - static_cast(a.y); + const double u = (y - static_cast(a.y)) / dy; + CHECK(std::fabs(y - chord) > 1.0); // it actually left the straight line + if (pw > util::kCurveNeutral) CHECK(u < 0.5); + else CHECK(u > 0.5); + } + } +} + +// curveMap maps 0->0 and 1->1 at every positive exponent, so no sample may pass either of its +// own segment's endpoint levels. +static void testNoOvershoot() { + const Rect area = wideArea(); + for (const Stage& st : kStages) { + for (double pw : kExponents) { + StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); + if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw; + else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw; + else env.releaseCurve = pw; + + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + EnvVertex a, b; + CHECK(findNode(poly, st.from, a)); + CHECK(findNode(poly, st.to, b)); + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + const double lo = a.y < b.y ? a.y : b.y; + const double hi = a.y < b.y ? b.y : a.y; + for (const StrokePoint& p : trace) { + if (p.x < static_cast(a.x) || p.x > static_cast(b.x)) continue; + CHECK(p.y >= static_cast(lo) - 0.001f); + CHECK(p.y <= static_cast(hi) + 0.001f); + } + } + } +} + +// Density follows the canvas: one sample per pixel column, so a wider canvas gets proportionally +// more of them. A fixed count would fail the second half. Uses the AHD policy, whose x axis is +// the waveform's own and so is independent of the AHDSR schematic's scale. +static void testDensityFollowsWidth() { + std::size_t counts[2] = {0, 0}; + const int widths[2] = {1000, 4000}; + for (int w = 0; w < 2; ++w) { + const Rect area = Rect::ltrb(20, 10, 20 + widths[w], 210); + StageEnvelope env = ahd(1.2, 1.6, 0.5, 4.0); + env.attackCurve = 4.0; + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + EnvVertex a, b; + CHECK(findNode(poly, EnvNode::Origin, a)); + CHECK(findNode(poly, EnvNode::AttackEnd, b)); + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + + std::size_t n = 0; + float prevX = 0.0f; + for (const StrokePoint& p : trace) { + if (p.x < static_cast(a.x) || p.x > static_cast(b.x)) continue; + if (n > 0) CHECK(p.x - prevX <= 1.0f); // no gap wider than one column + prevX = p.x; + ++n; + } + CHECK(n > 1); + counts[w] = n; + } + // 4x the canvas, ~4x the samples across the same stage. + CHECK(counts[1] > 3 * counts[0]); +} + +// A plateau is straight because its two endpoints share a level, so a stray exponent on the +// node that ends it must not curve it. +static void testFlatSegmentEmitsNoInterior() { + const Rect area = wideArea(); + StageEnvelope env = ahdsr(2.0, 0.0, 0.0, 1.0, 2.0); // sustain == 1: the decay span is flat + env.attackCurve = util::kCurveNeutral; + env.decayCurve = 5.0; + env.releaseCurve = util::kCurveNeutral; + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + std::vector trace; + buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); + std::size_t nodes = 0; + for (const EnvVertex& v : poly) if (!v.knot) ++nodes; + CHECK(trace.size() == nodes); +} + +static void testClampAndDegenerate() { + const Rect area = wideArea(); + StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); + env.attackCurve = 0.25; + env.decayCurve = 4.0; + const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); + + // A narrower clamp than the vertices were built for: nothing escapes it. + std::vector trace; + const int lo = area.x + 100, hi = area.right() - 200; + buildEnvelopeTrace(poly, env, lo, hi, trace); + CHECK(!trace.empty()); + for (const StrokePoint& p : trace) { + CHECK(p.x >= static_cast(lo)); + CHECK(p.x <= static_cast(hi)); + } + + // The out vector is REPLACED, not appended to — a stale trace from the previous paint + // would otherwise stroke a line across the canvas. + buildEnvelopeTrace({}, env, area.x, area.right() - 1, trace); + CHECK(trace.empty()); + + // A degenerate surface still yields the flat baseline the painter needs. + const Rect flat = Rect::ltrb(20, 10, 20, 10); + const std::vector degen = buildEnvelopePolyline(env, overlayOf(flat), 0.0); + buildEnvelopeTrace(degen, env, flat.x, flat.x, trace); + CHECK(trace.size() == degen.size()); +} + +int main() { + testSegmentCurve(); + testNeutralIsTodaysStraightLine(); + testKnotsExcludedAndNodesPreserved(); + testKnotLiesOnTraceAhdsr(); + testKnotLiesOnTraceAhd(); + testMidSegmentLevelMatchesTheLaw(); + testCurvatureDirection(); + testNoOvershoot(); + testDensityFollowsWidth(); + testFlatSegmentEmitsNoInterior(); + testClampAndDegenerate(); + if (g_fail == 0) std::printf("curve_tessellate: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} From abace156a59e59e8f5aed7960d3250aa67394679 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 21:31:32 -0400 Subject: [PATCH 11/56] Fix inverted splice-cadence test: assert artifact energy, not zero-crossing period Zero-crossing counting was anti-correlated with the real defect (splice debris fools it). Now asserts energy outside the fundamental, with an alignable control, matching test_preserve_low_frequency.cpp's approach. --- src/core/instrument/engine/time_stretch.h | 14 +++-- tests/energy_outside_fundamental.h | 50 ++++++++++++++++++ tests/test_pitch_shift.cpp | 63 ++++++++++++++++++----- 3 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 tests/energy_outside_fundamental.h diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index d2dd645..c661dae 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -22,11 +22,15 @@ namespace reasampler::instrument::engine { // has less than one period to align against. Measured at rate 4.0, shift 0.25 (-24 st): // interval 2205/3.75 ~= 588 vs period ~4*P ~= 785 frames (P ~= 196) — matches the originally // observed 539-vs-785 failure. This range's ceiling (2.0, not 4.0) raises the safe floor, it -// does not remove it: at rate 2.0, shift 0.25, interval = 2205/1.75 = 1260 still fails for -// any source period P > 315 frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -// -24 st is reachable from the Pitch knob alone. (The pre-stretch rate-1.0 engine's floor by -// the same inequality is P > 735, ~60 Hz — what this range raises the floor from, not what -// it removes.) +// does not remove it: at rate 2.0, shift 0.25, interval = 2205/1.75 = 1260 still produces +// measurable splice debris for any source period P > 315 frames (~140 Hz at 44.1k) — inside +// bass/low-vocal material, and -24 st is reachable from the Pitch knob alone. pitch_shift_tests +// (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) asserts this corner directly at +// P=500/600/700: energy outside the fundamental runs 7-21% there against ~0% on an aligned +// control at the same rate/shift — zero-crossing period is NOT what it checks, since splice +// debris fools that estimator into reading the wrong period on a render whose fundamental is +// actually fine. (The pre-stretch rate-1.0 engine's floor by the same inequality is P > 735, +// ~60 Hz — what this range raises the floor from, not what it removes.) // // A SECOND, INDEPENDENT limit binds the same material, and no rate bound touches it. A splice // relocates the tap by the nominal window refined by a search over +/- window/4, so the diff --git a/tests/energy_outside_fundamental.h b/tests/energy_outside_fundamental.h new file mode 100644 index 0000000..b71ba97 --- /dev/null +++ b/tests/energy_outside_fundamental.h @@ -0,0 +1,50 @@ +#pragma once +// Out-of-band spectral energy metric: the same period-grid, Hann-windowed direct-evaluation +// approach as test_preserve_low_frequency.cpp's reportSpectrum. Chosen over zero-crossing +// counting because splice debris adds spurious crossings that make that estimator +// anti-correlated with severity (a render can read a badly wrong PERIOD while this metric +// shows it is mostly clean, or vice versa). Grid/segment sizes are smaller than the hand-run +// harness's — this one runs inside the gated suite. + +#include +#include +#include + +namespace reasampler::test_support { + +// Percentage (0..100) of the segment [from, from+len)'s spectral energy that falls outside +// +/- 6% of `wantPeriod` (frames). 0 = a clean single tone at that period; higher values mean +// harmonics, splice-cadence sidebands, or crossfade cancellation debris are present. +inline double energyOutsideFundamentalPercent(const std::vector& v, std::size_t from, + std::size_t len, double wantPeriod) { + constexpr double kPi = 3.14159265358979323846; + constexpr int kGrid = 400; + const double pLo = 30.0, pHi = 8000.0; + std::vector mag(static_cast(kGrid)); + std::vector per(static_cast(kGrid)); + for (int g = 0; g < kGrid; ++g) { + // Geometric grid: constant relative resolution across the swept period range. + const double p = pLo * std::pow(pHi / pLo, static_cast(g) / (kGrid - 1)); + per[static_cast(g)] = p; + double re = 0.0, im = 0.0; + const double w = 2.0 * kPi / p; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { + const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast(k) / + static_cast(len))); + const double x = v[from + k] * hann; + re += x * std::cos(w * static_cast(k)); + im += x * std::sin(w * static_cast(k)); + } + mag[static_cast(g)] = std::sqrt(re * re + im * im); + } + double eTotal = 0.0, eFund = 0.0; + for (int g = 0; g < kGrid; ++g) { + const std::size_t i = static_cast(g); + const double e = mag[i] * mag[i]; + eTotal += e; + if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e; + } + return eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0; +} + +} // namespace reasampler::test_support diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index be8d0bf..c63cf6c 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -32,6 +32,7 @@ // decorrelated stereo content where an independent per-channel search provably diverges. #include "../src/core/instrument/engine/pitch_shift.h" +#include "energy_outside_fundamental.h" #include #include @@ -740,15 +741,30 @@ static void testStretchAndShiftComposeSafely() { // The [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the // source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both // inside the shipped range — the header's own derivation puts the safe-source floor at a -// period of 315 frames (~140 Hz @ 44.1k): testStretchAndShiftComposeSafely's probe period of -// 196.37 frames (~225 Hz) sits ABOVE that floor, so it passes because of the probe, not -// because of headroom. This probe sits BELOW the floor on purpose, asserting the corner -// rather than assuming it. A failure here is the inequality's PREDICTED outcome, not a -// defect this test exists to chase — report it, don't retune the tolerance to hide it. -static void testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter() { +// period of 315 frames (~140 Hz @ 44.1k): P=500/600/700 sit above that floor, on purpose, +// asserting the corner rather than assuming it. Zero-crossing period is NOT the right +// observable here: an investigation (test_preserve_low_frequency.cpp) found the P=500 +// render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike, +// while the zero-crossing estimator read 23% flat — splice debris adds spurious crossings +// the count cannot tell from a real detune. Energy outside the fundamental tracks the actual +// damage instead: measured here (same rate/shift/source, this file's own metric parameters) +// at 7.31% / 14.41% / 21.22% for P=500/600/700, against 0.10% on an alignable control (P=200, +// below the safe floor) at the same rate and shift — so that is what this asserts: a known, +// characterised property of the range, not a pass/fail on a period estimate. A failure on +// either bound below is a finding — report it, don't retune the thresholds to hide it. +static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() { + using reasampler::test_support::energyOutsideFundamentalPercent; const std::int64_t w = 2205; const double rate = 2.0; const double shift = std::pow(2.0, -24.0 / 12.0); // 0.25 + const std::size_t outFrames = 60000; + const std::size_t from = 20000, len = 32768; + + // Below the safe floor (P > 315 frames): the cadence inequality predicts real damage, + // measured at 7-21% (see above). The threshold (5%) sits above the alignable control's + // near-zero floor and under the observed range, so it discriminates a genuine cadence hit + // from a clean render; the ceiling (30%) is a generous margin above the highest measured + // value, there to catch a much worse regression rather than to chase today's exact number. for (double period : {500.0, 600.0, 700.0}) { const double f0 = 1.0 / period; const std::size_t srcLen = 400000; @@ -756,16 +772,35 @@ static void testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter() { for (std::size_t i = 0; i < srcLen; ++i) { src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); } - const std::size_t outFrames = 60000; const std::vector out = runStretch(src, w, rate, shift, outFrames, nullptr); for (double v : out) CHECK(std::isfinite(v)); - const double p = periodIn(out, 20000, 50000); const double want = period / shift; - const bool ok = approx(p, want, want * 0.12); - std::printf(" [floor probe] period %.0f (rate 2.0, -24 st): observed %.2f want %.2f " - "-> %s\n", period, p, want, ok ? "held" : "FAILED (predicted by the " - "inequality in time_stretch.h)"); - CHECK(ok); + const double energyPct = energyOutsideFundamentalPercent(out, from, len, want); + std::printf(" [cadence corner] period %.0f (rate 2.0, -24 st): energy outside " + "fundamental %.2f%% (want period %.1f fr)\n", period, energyPct, want); + CHECK(energyPct > 5.0); + CHECK(energyPct < 30.0); + } + + // The alignable control: same rate/shift, a source period (200 < 315) the cadence + // inequality does not reach. Without this, a future change that raised the noise floor + // EVERYWHERE (not just at this corner) would still read "under 30%" above and slide + // through — this is what catches that case. + { + const double period = 200.0; + const double f0 = 1.0 / period; + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * f0 * static_cast(i))); + } + const std::vector out = runStretch(src, w, rate, shift, outFrames, nullptr); + for (double v : out) CHECK(std::isfinite(v)); + const double want = period / shift; + const double energyPct = energyOutsideFundamentalPercent(out, from, len, want); + std::printf(" [alignable control] period %.0f (rate 2.0, -24 st): energy outside " + "fundamental %.2f%% (want period %.1f fr)\n", period, energyPct, want); + CHECK(energyPct < 5.0); } } @@ -790,7 +825,7 @@ int main() { testStereoLinkedLagSharedSchedule(); testStretchMovesDurationNotPitch(); testStretchAndShiftComposeSafely(); - testStretchCadenceBelowSafeFloorAtRate2ShiftQuarter(); + testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter(); testStretchEntryPointsOnPassThrough(); if (g_fail == 0) { From 3baf4ee50b2c1e194fcb359e4d76c30b093426d6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:05:57 -0400 Subject: [PATCH 12/56] =?UTF-8?q?=CE=93-W1-T2:=20the=20master=20bus=20?= =?UTF-8?q?=E2=80=94=20a=20true-peak=20limiter=20whose=20ceiling=20is=20a?= =?UTF-8?q?=20theorem,=20the=20meter's=20published=20half,=20and=20the=20p?= =?UTF-8?q?lugin's=20first=20PDC=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/instrument/CLAUDE.md | 4 +- src/core/instrument/engine/CMakeLists.txt | 8 + src/core/instrument/engine/limiter.cpp | 207 +++++++++++++ src/core/instrument/engine/limiter.h | 113 +++++++ .../instrument/engine/meter_ballistics.cpp | 57 ++++ src/core/instrument/engine/meter_ballistics.h | 40 +++ src/core/instrument/map/component_state_io.h | 19 +- src/core/instrument/map/params_payload.cpp | 45 ++- src/core/instrument/map/sample_map.h | 5 + src/shell/instrument/CLAUDE.md | 5 +- src/shell/instrument/CMakeLists.txt | 2 +- src/shell/instrument/processor_state.cpp | 43 ++- src/shell/instrument/reasampler_processor.cpp | 40 ++- src/shell/instrument/reasampler_processor.h | 67 +++- tests/test_component_state_io.cpp | 172 +++++++++-- tests/test_limiter.cpp | 285 ++++++++++++++++++ tests/test_meter_ballistics.cpp | 140 +++++++++ 17 files changed, 1187 insertions(+), 65 deletions(-) create mode 100644 src/core/instrument/engine/limiter.cpp create mode 100644 src/core/instrument/engine/limiter.h create mode 100644 src/core/instrument/engine/meter_ballistics.cpp create mode 100644 src/core/instrument/engine/meter_ballistics.h create mode 100644 tests/test_limiter.cpp create mode 100644 tests/test_meter_ballistics.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 9d54898..c9fa2ee 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -292,12 +292,14 @@ anything for a trigger shape. - `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. +- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental; the engage/disengage crossfade is the codebase's standing ramp-every-gain-path-change rule applied to a limiter switching in. +- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. ### `map/` - `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`. - `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping. -- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v14), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning. +- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v15), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning. - `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home. - `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects. - `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 864b9b9..96e4d39 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -66,3 +66,11 @@ add_library(time_stretch INTERFACE) target_include_directories(time_stretch INTERFACE ${REASAMPLER_SRC_DIR}) target_link_libraries(time_stretch INTERFACE loop_span) reasampler_test(time_stretch LINK time_stretch) + +# The master bus's two pure halves. Neither links the engine: the limiter runs on the summed +# output, and the ballistics run on what the audio thread published about it. +reasampler_pure_library(limiter SOURCES limiter.cpp) +reasampler_test(limiter LINK limiter) + +reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp) +reasampler_test(meter_ballistics LINK meter_ballistics) diff --git a/src/core/instrument/engine/limiter.cpp b/src/core/instrument/engine/limiter.cpp new file mode 100644 index 0000000..ea602bb --- /dev/null +++ b/src/core/instrument/engine/limiter.cpp @@ -0,0 +1,207 @@ +// limiter.cpp — see limiter.h. + +#include "core/instrument/engine/limiter.h" + +#include +#include + +namespace reasampler::instrument::engine { + +namespace { + +constexpr int kProtoLen = kLimiterOversample * kLimiterOsTaps + 1; // 33: odd, so phase 0 is exact + +double sincPi(double x) { + if (x == 0.0) return 1.0; + const double a = 3.14159265358979323846 * x; + return std::sin(a) / a; +} + +} // namespace + +double limiterCeilingLinear() { return std::pow(10.0, kLimiterCeilingDbTp / 20.0); } + +int limiterLookaheadSamples(double sampleRate) { + if (!(sampleRate > 0.0)) return 0; + const int n = static_cast(kLimiterLookaheadSeconds * sampleRate + 0.5); + // One sample above the detector's group delay is the floor: the smoothing window must have + // at least one entry of its own for the no-overshoot bound to say anything. + return n > kLimiterOsDelay ? n : kLimiterOsDelay + 1; +} + +void Limiter::prepare(double sampleRate) { + latency_ = limiterLookaheadSamples(sampleRate); + if (latency_ <= 0) latency_ = kLimiterOsDelay + 1; + window_ = latency_ - kLimiterOsDelay + 1; + ceiling_ = static_cast(limiterCeilingLinear()); + const double rate = sampleRate > 0.0 ? sampleRate : 48000.0; + releaseCoeff_ = static_cast(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate))); + mixStep_ = static_cast(1.0 / (kLimiterCrossfadeSeconds * rate)); + + // Windowed-sinc polyphase interpolator, built here because it costs transcendentals. + // Phase 0's taps all land on sinc zeros except the centre, so it is an exact delay and is + // read straight out of the history instead of being convolved. + for (int p = 0; p < kLimiterOversample; ++p) { + for (int k = 0; k < kLimiterOsTaps; ++k) { + const int i = kLimiterOversample * k + p; + const double centred = static_cast(i) - (kProtoLen - 1) / 2.0; + const double hann = + 0.5 - 0.5 * std::cos(2.0 * 3.14159265358979323846 * i / (kProtoLen - 1)); + osTaps_[p][k] = static_cast(sincPi(centred / kLimiterOversample) * hann); + } + } + + delayL_.assign(static_cast(latency_), 0.f); + delayR_.assign(static_cast(latency_), 0.f); + wedgeVal_.assign(static_cast(window_), 1.f); + wedgeIdx_.assign(static_cast(window_), 0); + avgRing_.assign(static_cast(window_), 1.f); + reset(); +} + +void Limiter::clearState() { + std::fill(delayL_.begin(), delayL_.end(), 0.f); + std::fill(delayR_.begin(), delayR_.end(), 0.f); + delayPos_ = 0; + for (int i = 0; i < kLimiterOsTaps; ++i) { histL_[i] = 0.f; histR_[i] = 0.f; } + histPos_ = 0; + wedgeHead_ = 0; + wedgeCount_ = 0; + pushIndex_ = 0; + std::fill(avgRing_.begin(), avgRing_.end(), 1.f); + avgSum_ = static_cast(window_); + avgPos_ = 0; + releaseGain_ = 1.f; +} + +void Limiter::reset() { + clearState(); + active_ = target_.load(std::memory_order_relaxed); + mix_ = active_ ? 1.f : 0.f; + primeRemaining_ = 0; +} + +void Limiter::setEnabled(bool on) { target_.store(on, std::memory_order_relaxed); } + +float Limiter::detectTruePeak(float xl, float xr, bool stereo) { + histPos_ = (histPos_ + 1) & (kLimiterOsTaps - 1); + histL_[histPos_] = xl; + if (stereo) histR_[histPos_] = xr; + + // Phase 0 is the exact delay, so the sample under test is read, not convolved. + const int base = (histPos_ - kLimiterOsDelay + kLimiterOsTaps) & (kLimiterOsTaps - 1); + float peak = std::fabs(histL_[base]); + if (stereo) { + const float r0 = std::fabs(histR_[base]); + if (r0 > peak) peak = r0; + } + for (int p = 1; p < kLimiterOversample; ++p) { + float accL = 0.f, accR = 0.f; + for (int k = 0; k < kLimiterOsTaps; ++k) { + const int idx = (histPos_ - k + kLimiterOsTaps) & (kLimiterOsTaps - 1); + accL += osTaps_[p][k] * histL_[idx]; + if (stereo) accR += osTaps_[p][k] * histR_[idx]; + } + const float al = std::fabs(accL); + if (al > peak) peak = al; + if (stereo) { + const float ar = std::fabs(accR); + if (ar > peak) peak = ar; + } + } + return peak; +} + +float Limiter::smoothGain(float target) { + // Sliding minimum over `window_` via a monotonic wedge. Expiring the front BEFORE the push + // is what bounds the wedge to `window_` entries — pushing first can lap the ring. + while (wedgeCount_ > 0 && + wedgeIdx_[static_cast(wedgeHead_)] <= pushIndex_ - window_) { + wedgeHead_ = (wedgeHead_ + 1) % window_; + --wedgeCount_; + } + while (wedgeCount_ > 0) { + const int back = (wedgeHead_ + wedgeCount_ - 1) % window_; + if (wedgeVal_[static_cast(back)] < target) break; + --wedgeCount_; + } + const int slot = (wedgeHead_ + wedgeCount_) % window_; + wedgeVal_[static_cast(slot)] = target; + wedgeIdx_[static_cast(slot)] = pushIndex_; + ++wedgeCount_; + ++pushIndex_; + const float windowMin = wedgeVal_[static_cast(wedgeHead_)]; + + // Moving average of the same width over those minima. + avgSum_ += static_cast(windowMin) - static_cast(avgRing_[static_cast(avgPos_)]); + avgRing_[static_cast(avgPos_)] = windowMin; + avgPos_ = (avgPos_ + 1 == window_) ? 0 : avgPos_ + 1; + float smoothed = static_cast(avgSum_ / window_); + // Never above unity — the structural form of "no makeup gain, ever", and what makes the + // at-rest gain land on EXACTLY 1.0f after the running sum has been added to and subtracted + // from for hours. + if (!(smoothed < 1.f)) smoothed = 1.f; + + // Release: falls with the smoother, rises no faster than the one-pole. Staying at or below + // `smoothed` is what preserves the no-overshoot bound. + if (smoothed < releaseGain_) releaseGain_ = smoothed; + else releaseGain_ += (smoothed - releaseGain_) * releaseCoeff_; + return releaseGain_; +} + +float Limiter::process(float* left, float* right, int frames) { + if (!left || frames <= 0 || latency_ <= 0) return 1.f; + const bool want = target_.load(std::memory_order_relaxed); + if (!want && !active_) return 1.f; // settled bypass: not one sample read or written + if (want && !active_) { + // A live engage. Start dry, fill the delay line, then crossfade — so the wet path is + // never silence weighted above zero. + clearState(); + active_ = true; + mix_ = 0.f; + primeRemaining_ = latency_; + } + + const bool stereo = (right != nullptr); + float blockMin = 1.f; + for (int i = 0; i < frames; ++i) { + const float dryL = left[i]; + const float dryR = stereo ? right[i] : 0.f; + + const float peak = detectTruePeak(dryL, dryR, stereo); + const float targetGain = peak > ceiling_ ? ceiling_ / peak : 1.f; + const float gain = smoothGain(targetGain); + if (gain < blockMin) blockMin = gain; + + const std::size_t slot = static_cast(delayPos_); + const float wetL = delayL_[slot] * gain; + const float wetR = stereo ? delayR_[slot] * gain : 0.f; + delayL_[slot] = dryL; + if (stereo) delayR_[slot] = dryR; + delayPos_ = (delayPos_ + 1 == latency_) ? 0 : delayPos_ + 1; + + // The endpoints are branches rather than blend arithmetic so a settled state is exact: + // dry + (wet - dry) * 1.0f is not wet in floating point. At m <= 0 the buffer is left + // untouched, which is the dry sample already in it. + const float m = mix_; + if (m >= 1.f) { + left[i] = wetL; + if (stereo) right[i] = wetR; + } else if (m > 0.f) { + left[i] = dryL + (wetL - dryL) * m; + if (stereo) right[i] = dryR + (wetR - dryR) * m; + } + + if (primeRemaining_ > 0) { + --primeRemaining_; + } else if (want) { + mix_ = (mix_ + mixStep_ >= 1.f) ? 1.f : mix_ + mixStep_; + } else { + mix_ = (mix_ - mixStep_ <= 0.f) ? 0.f : mix_ - mixStep_; + } + } + if (!want && mix_ <= 0.f && primeRemaining_ == 0) active_ = false; + return blockMin; +} + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/limiter.h b/src/core/instrument/engine/limiter.h new file mode 100644 index 0000000..600c666 --- /dev/null +++ b/src/core/instrument/engine/limiter.h @@ -0,0 +1,113 @@ +// limiter.h — the master bus's lookahead brickwall limiter: true-peak sidechain detection, +// stereo-linked gain, and NO makeup gain of any kind. RT: process() allocates nothing, takes +// no lock and evaluates no transcendental; prepare() owns every allocation and every exp/pow. +// Bypassed and settled, process() returns without touching a sample — that untouched buffer +// is what makes the master bus byte-identical to the bare ramped multiply with the limiter off. + +#pragma once + +#include +#include +#include + +namespace reasampler::instrument::engine { + +// The BAKED ceiling. A safety device with no configurable controls, so this is not a +// parameter. dBTP is a TRUE-peak target, which is why the detector oversamples and the +// signal path never does. +inline constexpr double kLimiterCeilingDbTp = -0.3; + +// The total delay the limiter imposes while engaged, and therefore the plugin's whole reported +// PDC latency. The detector's own group delay is inside this budget, not on top of it. +inline constexpr double kLimiterLookaheadSeconds = 0.002; + +// Gain recovery. The min-then-average smoother releases in one lookahead window on its own, +// which distorts low frequencies; this one-pole only ever slows the RISE, so the smoother's +// no-overshoot bound survives it unchanged. +inline constexpr double kLimiterReleaseSeconds = 0.100; + +// The engage/disengage crossfade. A limiter engaging is a gain-path change and this codebase +// ramps every gain-path change; it also covers the window before the host acts on the latency +// change, which is the plugin's to keep clean because the host schedules that, not us. +inline constexpr double kLimiterCrossfadeSeconds = 0.010; + +// 4x true-peak oversampling (ITU-R BS.1770's floor at 48 kHz) over an 8-tap-per-phase +// polyphase interpolator. The 33-tap prototype's centre tap makes phase 0 an exact 4-sample +// delay, and that delay is the detector's group delay. +inline constexpr int kLimiterOversample = 4; +inline constexpr int kLimiterOsTaps = 8; +inline constexpr int kLimiterOsDelay = 4; + +// kLimiterCeilingDbTp as a linear magnitude. +double limiterCeilingLinear(); + +// The delay the limiter imposes while engaged, in samples at `sampleRate` — what the plugin +// reports to the host's PDC. 0 at a non-positive rate; never below the detector's own delay. +int limiterLookaheadSamples(double sampleRate); + +// The master-bus limiter. One instance per plugin instance; prepare() before the first block. +// +// The gain law is a sliding MINIMUM of the per-sample target gain over the lookahead window, +// then a MOVING AVERAGE of the same width. Every term of that average is a minimum whose own +// window contains the sample being gained, so the smoothed gain is <= the target gain at every +// sample by construction — the ceiling is held structurally rather than by a tuned attack. +class Limiter { +public: + // Sizes the delay line, the detector and the smoothers, and snaps to the current enable + // state. Allocates and evaluates transcendentals: main/UI thread only, never in process(). + void prepare(double sampleRate); + + // Clears the delay line and the detector and snaps to the current enable state, skipping + // the engage crossfade — an activation has nothing sounding to be continuous with. + // Main/UI thread only (the host guarantees process() is stopped at both call sites). + void reset(); + + // The enable target. Set on the UI thread, observed by process() at block start. + void setEnabled(bool on); + bool enabled() const { return target_.load(std::memory_order_relaxed); } + + // Applies the limiter in place over `frames` of `left` (and `right`, which may be null for + // a mono buffer). Returns the SMALLEST gain applied in this block — 1.0 for none, and the + // value a settled bypass returns. + float process(float* left, float* right, int frames); + +private: + void clearState(); + // The detector's true-peak estimate for the sample kLimiterOsDelay back, given the newest + // input frame. Advances the FIR history. + float detectTruePeak(float xl, float xr, bool stereo); + // Pushes one target gain through the sliding minimum and the moving average. + float smoothGain(float target); + + std::atomic target_{false}; + + // --- prepared geometry --- + int latency_ = 0; // total delay; also the delay ring's length + int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1 + float ceiling_ = 1.f; + float releaseCoeff_ = 1.f; + float mixStep_ = 1.f; + float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay) + + // --- audio-thread state --- + std::vector delayL_, delayR_; + int delayPos_ = 0; + float histL_[kLimiterOsTaps] = {}; + float histR_[kLimiterOsTaps] = {}; + int histPos_ = 0; + // Monotonic wedge over the target gain: values ascending from the front, so the front is + // the window minimum. Amortized O(1) per sample, bounded by 2 ops per push over a block. + std::vector wedgeVal_; + std::vector wedgeIdx_; + int wedgeHead_ = 0, wedgeCount_ = 0; + std::int64_t pushIndex_ = 0; + std::vector avgRing_; + double avgSum_ = 0.0; // double: the running sum is added to and subtracted from forever + int avgPos_ = 0; + float releaseGain_ = 1.f; + bool active_ = false; // the limiter path is running (engaged, or mid-crossfade) + float mix_ = 0.f; // 0 = dry, 1 = limited + int primeRemaining_ = 0; // samples the crossfade waits on while the delay line fills +}; + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/meter_ballistics.cpp b/src/core/instrument/engine/meter_ballistics.cpp new file mode 100644 index 0000000..b312669 --- /dev/null +++ b/src/core/instrument/engine/meter_ballistics.cpp @@ -0,0 +1,57 @@ +// meter_ballistics.cpp — see meter_ballistics.h. + +#include "core/instrument/engine/meter_ballistics.h" + +#include + +namespace reasampler::instrument::engine { + +double meterDbFromLinear(double linear) { + if (!(linear > 0.0)) return kMeterFloorDb; // also catches NaN + const double db = 20.0 * std::log10(linear); + return db < kMeterFloorDb ? kMeterFloorDb : db; +} + +double meterNormFromDb(double db) { + if (!(db > kMeterFloorDb)) return 0.0; // also catches NaN + if (db >= kMeterTopDb) return 1.0; + return (db - kMeterFloorDb) / (kMeterTopDb - kMeterFloorDb); +} + +MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds) { + const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0; + const double fall = kMeterFallDbPerSecond * dt; + const double peakDb = meterDbFromLinear(blockPeakLinear); + + MeterState next = prev; + // Instantaneous rise, timed fall — one expression, because a fall can never take the bar + // below the peak this very block carried. + const double fallen = prev.levelDb - fall; + next.levelDb = fallen > peakDb ? fallen : peakDb; + + if (next.levelDb >= next.holdDb) { + next.holdDb = next.levelDb; + next.holdRemainingSeconds = kMeterPeakHoldSeconds; + } else { + next.holdRemainingSeconds = prev.holdRemainingSeconds - dt; + if (next.holdRemainingSeconds < 0.0) { + // Spend the overshoot as fall time so the tick's release does not quantize to the + // UI frame it happened to expire on. + const double held = kMeterFallDbPerSecond * -next.holdRemainingSeconds; + const double dropped = next.holdDb - held; + next.holdDb = dropped > next.levelDb ? dropped : next.levelDb; + next.holdRemainingSeconds = 0.0; + } + } + + if (blockPeakLinear >= 1.0) next.clip = true; + return next; +} + +MeterState clearMeterClip(MeterState prev) { + MeterState next = prev; + next.clip = false; + return next; +} + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/meter_ballistics.h b/src/core/instrument/engine/meter_ballistics.h new file mode 100644 index 0000000..40ed766 --- /dev/null +++ b/src/core/instrument/engine/meter_ballistics.h @@ -0,0 +1,40 @@ +// meter_ballistics.h — the output meter's ballistics and its dB scale: peak fall, peak hold, +// clip latch, and the dB -> normalized map the bar draws against. UI-thread math ONLY: the +// audio thread publishes raw block peaks per block and converts, holds and decays nothing. + +#pragma once + +namespace reasampler::instrument::engine { + +// The scale is LINEAR IN dB across this span. Above 0 dBFS is shown because that is exactly +// what the limiter-off case has to make visible. +inline constexpr double kMeterFloorDb = -60.0; +inline constexpr double kMeterTopDb = 6.0; + +// A peak meter must not smooth its attack or it under-reports, so the rise is instantaneous +// and only the fall is timed. 20 dB/s is close to the IEC 60268-18 PPM fallback. +inline constexpr double kMeterFallDbPerSecond = 20.0; +inline constexpr double kMeterPeakHoldSeconds = 1.5; + +// Linear magnitude -> dBFS, floored at kMeterFloorDb — a silent block reads the floor rather +// than -inf, so the state stays a finite number the ballistics can subtract from. +double meterDbFromLinear(double linear); + +// dBFS -> [0,1] up the meter, clamped at both ends. +double meterNormFromDb(double db); + +struct MeterState { + double levelDb = kMeterFloorDb; + double holdDb = kMeterFloorDb; + double holdRemainingSeconds = 0.0; + bool clip = false; // latched; only clearMeterClip lowers it +}; + +// One UI frame of ballistics against the block peak the audio thread published and the time +// since the previous frame. Clip latches at a block peak >= 0 dBFS and is never cleared here. +MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds); + +// The click-to-clear on the meter's clip cap. +MeterState clearMeterClip(MeterState prev); + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index f680682..287d616 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -8,7 +8,7 @@ // own links are velocity_curve + master_gain (wire value validation), never the engine. // // EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params -// payload v1..v14) must be preserved exactly. This header is the ONE home for both ladders +// payload v1..v15) must be preserved exactly. This header is the ONE home for both ladders // and every version constant; the payload half is IMPLEMENTED in params_payload. #include @@ -104,7 +104,7 @@ namespace reasampler::instrument::map { // which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve // drawn into the negative half comes back with that half clamped to 0. // -// v13 (CURRENT WRITE FORMAT) is v12 PLUS the DUAL Staged/Spline envelope state, appended after +// v13 is v12 PLUS the DUAL Staged/Spline envelope state, appended after // the velocity->pitch curve. Its two halves, in order: // (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged / // 1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE @@ -120,7 +120,7 @@ namespace reasampler::instrument::map { // A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour} // on all three EGs with no hard point anywhere, so it plays exactly as it did. // -// v14 (CURRENT WRITE FORMAT) is v13 PLUS the resample bake's Hold division, appended after the +// v14 is v13 PLUS the resample bake's Hold division, appended after the // hard-flag tails: 4-byte LE quarterExponent (two's-complement int32) + 1 byte modifier (0 // Straight / 1 Dotted / 2 Triplet). Decoded through makeDivision, which clamps both fields — // never memcpy'd into the type (core/instrument/note/CLAUDE.md owns why). A v13-or-older blob @@ -129,6 +129,13 @@ namespace reasampler::instrument::map { // A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record — // the same revive discipline the v13 hard-flag tails follow, and for the same reason. // +// v15 (CURRENT WRITE FORMAT) is v14 PLUS ONE byte: the master-bus limiter's enable, appended +// after the Hold division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed, +// which is also the field's product default, so a project saved before the limiter existed +// reopens with the limiter off and sounding identical. It carries the Hold's revive +// discipline too: now that it, not the Hold, is the last tail, a truncation inside this byte +// would otherwise reset the record the Hold's own revive just preserved. +// // The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in // shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced // them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to @@ -160,7 +167,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // The params-payload format version and its detection marker. The marker is a high sentinel // no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so // a reader detects record shape independent of the envelope version. -inline constexpr std::uint32_t kParamsPayloadVersion = 14; // v13 + the bake Hold division +inline constexpr std::uint32_t kParamsPayloadVersion = 15; // v14 + the limiter enable inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u; // The first SINGLE-RECORD payload version. Everything below it is a retired zone list and @@ -192,6 +199,10 @@ inline constexpr std::uint32_t kParamsSplineVersion = 13; // kParamsPayloadVersion. inline constexpr std::uint32_t kParamsBakeHoldVersion = 14; +// v14 + the master-bus limiter enable; the appended byte branches on THIS, never on +// kParamsPayloadVersion. +inline constexpr std::uint32_t kParamsLimiterVersion = 15; + // (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to // seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter // (frames / projectRate = seconds) — the same rate the build already receives, so the diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index 2984da3..1aa523b 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -221,24 +221,48 @@ void readHardFlags(ByteReader& r, VelocityCurve& curve) { for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0); } -// Read the v14 bake Hold. Same revive discipline as readHardFlags directly above, and for the -// same reason: this tail reaches no audio path, so a blob truncated inside it must cost the -// Hold alone and not reset the whole record that parsed cleanly ahead of it. It sits LAST, so -// a truncation stranding the hard flags strands this too — reviving in only one of the two -// would still wipe the record. +// THE shared ending for every appended tail past the hard flags: revive, then DRAIN. Both +// halves are load-bearing and neither is optional. +// +// Revive, because these tails reach no audio path — a blob truncated inside one must cost +// that field alone and not reset the whole record that parsed cleanly ahead of it. An r.ok +// already false on entry (an earlier, unrelated field genuinely truncated) is left alone; +// that failure is not this tail's to forgive. +// +// Drain, because a FAILED read does not advance the cursor. The bytes it rejected are still +// sitting there for the NEXT tail to consume as its own — a truncated Hold whose two +// surviving exponent bytes arrive at the limiter byte reads back as ENABLED. Reviving without +// draining does not degrade to absent; it fabricates. Every tail added after this one must +// end here too. +// +// Returns true when the caller must abandon its field. +bool reviveTruncatedTail(ByteReader& r, bool enteredOk) { + if (r.ok) return false; + if (enteredOk) r.ok = true; + drainUnaligned(r); + return true; +} + +// Read the v14 bake Hold. void readBakeHold(ByteReader& r, InstrumentParams& p) { const bool enteredOk = r.ok; const std::int32_t exponent = r.i32(); const std::uint8_t modifier = r.u8(); - if (!r.ok) { - if (enteredOk) r.ok = true; - return; - } + if (reviveTruncatedTail(r, enteredOk)) return; // makeDivision clamps BOTH fields, so a corrupt pair becomes the nearest legal rung // rather than an unrepresentable one — never a memcpy into the type. p.bakeHold = note::makeDivision(exponent, static_cast(modifier)); } +// Read the v15 limiter enable. Bypassed is what a truncation means and what the field already +// holds, so a missing byte costs nothing beyond the enable itself. +void readLimiterEnable(ByteReader& r, InstrumentParams& p) { + const bool enteredOk = r.ok; + const std::uint8_t flag = r.u8(); + if (reviveTruncatedTail(r, enteredOk)) return; + p.limiterEnabled = (flag != 0); +} + // Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default, // which is what makes a v8 blob play bit-identically under the new codec. The curve reads as // bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its @@ -475,6 +499,8 @@ void putParamsPayload(std::vector& out, const InstrumentParams& p) putLE(out, static_cast( static_cast(p.bakeHold.quarterExponent()))); out.push_back(static_cast(p.bakeHold.modifier())); + // v15: the master-bus limiter enable. + out.push_back(p.limiterEnabled ? 1 : 0); } // Read whichever payload shape follows: the single-record shape (v8 onward, growing by @@ -529,6 +555,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) { readHardFlags(r, p.play.pitchVelocityCurve); } if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p); + if (pv >= kParamsLimiterVersion) readLimiterEnable(r, p); // A truncated record leaves whatever parsed plus construction defaults for the rest — // the same degrade-don't-throw contract the zone ladder always had. if (!r.ok) return PayloadRead{}; diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 419b38b..0cac6b6 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -195,6 +195,11 @@ struct InstrumentParams { // (bake_plan.h's bakeWindowNeedsHold is the predicate). Default one bar; a blob predating // the field lifts to it, and no other bake changes. note::Division bakeHold = note::makeDivision(2, note::DivisionModifier::Straight); + // The master-bus limiter's single enable. It sits OUTSIDE PlaySeconds deliberately: it is + // a post-voice-mixer concern the shell applies to the summed output, never a voice + // parameter, so it must not ride into the live block or the SampleData build. Default off + // — a blob predating the field lifts to bypassed and sounds identical. + bool limiterEnabled = false; }; // The loaded capture resolved for decode + build: project-relative WAV path (file seam) diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index f9d6ed3..bf0774a 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -11,7 +11,8 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`, `sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, -`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and +`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, +`limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and `core/wire` and is documented there — this directory consumes it but does not own it. ## Invariants @@ -104,7 +105,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h ## Modules - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. -- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published per block as relaxed atomics (per-channel peak, latched clip, the block's smallest limiter gain). The limiter's enable is persisted in the parameter set (params payload v14) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index d01e6fe..55180b1 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -89,7 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") waveform_view bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage - bake_hold + limiter meter_ballistics bake_hold file_bytes curve_law stroke_aa curve_tessellate bake_plan bake_render bake_reset bake_wire wav_codec) diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 112decf..95b369b 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -12,6 +12,7 @@ #include #include "pluginterfaces/base/ibstream.h" +#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kLatencyChanged #include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp) #include "core/instrument/map/component_state_io.h" // the ComponentState codec @@ -148,8 +149,46 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() { } void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { - std::lock_guard lock(paramsMutex_); - params_ = params; + { + std::lock_guard lock(paramsMutex_); + params_ = params; + } + // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — + // funnels through here, so mirroring the limiter flag at this one point is what keeps the + // audio thread's copy and the latency report from ever lagging what is persisted. + publishLimiterEnabled(params.limiterEnabled); +} + +void ReaSamplerProcessor::publishLimiterEnabled(bool on) { + limiterEnabled_.store(on, std::memory_order_relaxed); + limiter_.setEnabled(on); +} + +void ReaSamplerProcessor::setLimiterEnabled(bool on) { + { + std::lock_guard lock(paramsMutex_); + if (params_.limiterEnabled == on) return; // no change: no restart to request + params_.limiterEnabled = on; + } + publishLimiterEnabled(on); + // The SDK requires this on the UI thread and answers getLatencySamples only after the host's + // own deactivate/reactivate — so the flag above is already committed by the time the host + // asks. This is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode + // kIoChanged bus renegotiation (see initialize()); do not conflate the two. + if (componentHandler) componentHandler->restartComponent(kLatencyChanged); +} + +MasterBusMeter ReaSamplerProcessor::masterBusMeter() const { + MasterBusMeter m; + m.peakL = meterPeakL_.load(std::memory_order_relaxed); + m.peakR = meterPeakR_.load(std::memory_order_relaxed); + m.minGain = meterMinGain_.load(std::memory_order_relaxed); + m.clip = meterClip_.load(std::memory_order_relaxed); + return m; +} + +void ReaSamplerProcessor::clearMasterBusClip() { + meterClip_.store(false, std::memory_order_relaxed); } void ReaSamplerProcessor::publishLiveParams() { diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index bf5f14c..7c173d3 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -95,6 +95,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { // project's ext-state parses, nothing retries until the next activation or editor // tick — open a pre-v10 instrument once after upgrading if it restores silent. reloadInstrument(); + // The host performs this deactivate/reactivate whenever it acts on a kLatencyChanged + // request, so the limiter starts each activation with an empty delay line and snapped + // to its persisted state — no crossfade, because there is nothing sounding to be + // continuous with once the block above has destroyed every voice. + limiter_.reset(); } else { std::lock_guard lock(reloadMutex_); // Free EVERYTHING, including live_: its voices are frozen mid-flight, and if it @@ -108,6 +113,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { return kResultOk; } +uint32 PLUGIN_API ReaSamplerProcessor::getLatencySamples() { + if (!limiterEnabled_.load(std::memory_order_relaxed)) return 0; + return static_cast( + instrument::engine::limiterLookaheadSamples(sampleRate_)); +} + tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { sampleRate_ = setup.sampleRate; maxBlockSize_ = setup.maxSamplesPerBlock; @@ -116,6 +127,8 @@ tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { if (sampleRate_ > 0.0) { gainRampStep_ = static_cast(1.0 / (kGainRampSeconds * sampleRate_)); } + // Every limiter allocation and transcendental happens here, off the audio thread. + limiter_.prepare(sampleRate_); return SingleComponentEffect::setupProcessing(setup); } @@ -238,7 +251,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { - embedPeak_.store(0.f, std::memory_order_relaxed); + publishSilentMeterBlock(); return kResultOk; } AudioBusBuffers& out = data.outputs[0]; @@ -247,7 +260,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // 64-bit host processing is not supported by the mono float core; emit silence // rather than mis-render. REAPER runs 32-bit float by default. if (data.symbolicSampleSize != kSample32) { - embedPeak_.store(0.f, std::memory_order_relaxed); + publishSilentMeterBlock(); for (int32 ch = 0; ch < out.numChannels; ++ch) { if (double* buf = out.channelBuffers64[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; @@ -294,21 +307,26 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } } + // The chain's last stage before the bus, after the gain above. + const float minGain = limiter_.process(ch0, ch1, frames); + meterMinGain_.store(minGain, std::memory_order_relaxed); // Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). for (int32 ch = 2; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; } } - // Block peak (max across L/R) for the embed strip's level indicator. - float peak = 0.f; + // Meter tap: the bus output, post-limiter. Raw per-channel block peaks only. + float peakL = 0.f, peakR = 0.f; for (int32 i = 0; i < frames; ++i) { const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; - if (a0 > peak) peak = a0; - if (a1 > peak) peak = a1; + if (a0 > peakL) peakL = a0; + if (a1 > peakR) peakR = a1; } - embedPeak_.store(peak, std::memory_order_relaxed); + meterPeakL_.store(peakL, std::memory_order_relaxed); + meterPeakR_.store(peakR, std::memory_order_relaxed); + if (peakL >= 1.f || peakR >= 1.f) meterClip_.store(true, std::memory_order_relaxed); } else if (ch0) { // Mono: render into channel 0, replicate to any extra channels (defensive). for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; @@ -335,17 +353,23 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } } + const float minGain = limiter_.process(ch0, nullptr, frames); + meterMinGain_.store(minGain, std::memory_order_relaxed); float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; if (a > peak) peak = a; } - embedPeak_.store(peak, std::memory_order_relaxed); + meterPeakL_.store(peak, std::memory_order_relaxed); + meterPeakR_.store(peak, std::memory_order_relaxed); + if (peak >= 1.f) meterClip_.store(true, std::memory_order_relaxed); for (int32 ch = 1; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; } } + } else { + publishSilentMeterBlock(); } // Report silence only when nothing is loaded (lets the host optimize when idle); with diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 95290fc..e831b2f 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -20,6 +20,7 @@ #include "shell/instrument/reaper_bridge.h" #include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set) #include "core/instrument/map/component_state_io.h" // ComponentState codec +#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter #include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block) #include "core/instrument/engine/voice_engine.h" @@ -33,6 +34,16 @@ using instrument::map::kPreviewVelocityDefault; class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface) +// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw +// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics), +// because a hold timer or a log on the audio thread would be per-block work that buys nothing. +struct MasterBusMeter { + float peakL = 0.f; // max |x| this block + float peakR = 0.f; + float minGain = 1.f; // smallest limiter gain applied this block; 1 = no reduction + bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it +}; + // The decoded capture + the voice engine playing it. The engine holds a reference to the // sample, so both must live/die together at a stable address — heap-allocated, // non-copyable, non-movable. process() only ever reads this through an atomic pointer. @@ -92,6 +103,12 @@ public: Steinberg::tresult PLUGIN_API process( Steinberg::Vst::ProcessData& data) override; + // The plugin's PDC report: 0 with the limiter bypassed, the limiter's lookahead with it + // engaged. Read from the PERSISTED enable, never from a transient — the SDK's contract + // (pluginterfaces/vst/ivsteditcontroller.h, kLatencyChanged) is that the host asks this + // AFTER the deactivate/reactivate it performs, and setActive(false) clears the engine. + Steinberg::uint32 PLUGIN_API getLatencySamples() override; + // Fixed stereo output bus — channel mode is a decode policy, never a bus fact; mono // renders dual-mono through it. Do not reintroduce per-instance bus renegotiation. // Accepts only a single stereo output proposal; otherwise rejects and keeps stereo. @@ -108,12 +125,18 @@ public: Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void** obj) override; - // The embedded-strip activity level (0..1) for the embed shell, UI thread. Backed by - // embedPeak_, a lock-free relaxed atomic the audio thread writes each block. + // The embedded-strip activity level (0..1) for the embed shell, UI thread. The loudest of + // the two published bus peaks — one publication serves the strip and the meter. double embedActivityLevel() const { - return static_cast(embedPeak_.load(std::memory_order_relaxed)); + const float l = meterPeakL_.load(std::memory_order_relaxed); + const float r = meterPeakR_.load(std::memory_order_relaxed); + return static_cast(l > r ? l : r); } + // What the audio thread published about the output bus last block. UI thread. + MasterBusMeter masterBusMeter() const; + void clearMasterBusClip(); + // Resolves the selection against the instance-owned SampleRefs, decodes its WAV // off-thread, and publishes the built instrument via atomic swap — no bank read // required. When the bank blob is readable it's first folded into the refs table @@ -208,6 +231,15 @@ public: } void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()] + // The master-bus limiter's single enable (persisted in the parameter set). UI thread only: + // the setter requests the host's kLatencyChanged restart, which the SDK requires be issued + // from the UI thread and which process() must therefore never trigger. Setting the value it + // already holds is a no-op, so repeated clicks on one segment cost no restart. + bool limiterEnabled() const { + return limiterEnabled_.load(std::memory_order_relaxed); + } + void setLimiterEnabled(bool on); + // Fires a one-shot preview note-on/off through the live VoiceEngine — the same // noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice // count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio @@ -248,6 +280,19 @@ private: // Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine. void publishBuiltLocked(std::unique_ptr built); + // Publishes a silent block to the meter. EVERY process() path that emits no audio calls + // this, or the bar freezes at the last peak it saw. The clip latch is deliberately not + // touched — it survives silence until the user clears it. + void publishSilentMeterBlock() { + meterPeakL_.store(0.f, std::memory_order_relaxed); + meterPeakR_.store(0.f, std::memory_order_relaxed); + meterMinGain_.store(1.f, std::memory_order_relaxed); + } + + // Mirrors the persisted limiter enable onto the audio thread and the latency reader. Called + // from every writer of the parameter set, so the three views can never disagree. + void publishLimiterEnabled(bool on); + // Publishes this instance's held captures to its per-instance ext-state key // ("rsusage_") so the extension's prune can never reclaim them. Called at // the tail of every reloadInstrument, off the audio thread. Mints instanceGuid_ on @@ -401,9 +446,19 @@ private: // unique_ptr, so its own refcount is a no-op. std::unique_ptr embed_; - // Per-block mono peak the audio thread stores relaxed; embedActivityLevel() reads it - // for the embed strip's level indicator. Advisory only. - std::atomic embedPeak_{0.f}; + // The master-bus limiter, applied post-gain over the summed output. Its own enable target + // is the mirror of params_.limiterEnabled; limiterEnabled_ is the lock-free copy + // getLatencySamples answers from. + instrument::engine::Limiter limiter_; + std::atomic limiterEnabled_{false}; + + // What the audio thread publishes about the output bus each block, relaxed — peaks, the + // latched clip, and the limiter's smallest gain. No dB, no ballistics, no hold timer here; + // the UI runs those off these values and its own elapsed time. + std::atomic meterPeakL_{0.f}; + std::atomic meterPeakR_{0.f}; + std::atomic meterMinGain_{1.f}; + std::atomic meterClip_{false}; }; } // namespace reasampler::vst diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 85daaac..276734c 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -453,7 +453,7 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, - 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0e,0x00,0x00, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0f,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, @@ -549,6 +549,8 @@ static void testGoldenFullBlobFixture() { // --- payload v14 bake Hold, at its one-bar default --- 0x02,0x00,0x00,0x00, // quarterExponent 2 (== 1/1) 0x00, // Straight + // --- payload v15 limiter enable --- + 0x00, // bypassed (the default) }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); @@ -596,19 +598,22 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(bytes[4] == 0); // ChannelMode::Mono } CHECK(kComponentStateVersion == 11); - CHECK(kParamsPayloadVersion == 14); + CHECK(kParamsPayloadVersion == 15); CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); - // The filter, staged-curve, loop, velocity, spline and bake-Hold tails rode PAYLOAD bumps, - // not envelope ones — the two axes stay independent, so a future envelope field cannot - // collide with any of them on one number. + // The filter, staged-curve, loop, velocity, spline, bake-Hold and limiter tails rode + // PAYLOAD bumps, not envelope ones — the two axes stay independent, so a future envelope + // field cannot collide with any of them on one number. This pins the NUMBERS only; that + // each tail's bytes sit in the order its number implies is + // testAppendedTailsSitInVersionOrderOnTheWire's job. CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); CHECK(kParamsCurveVersion > kParamsFilterVersion); CHECK(kParamsLoopVersion > kParamsCurveVersion); CHECK(kParamsVelocityVersion > kParamsLoopVersion); CHECK(kParamsSplineVersion > kParamsVelocityVersion); CHECK(kParamsBakeHoldVersion > kParamsSplineVersion); - CHECK(kParamsPayloadVersion == kParamsBakeHoldVersion); + CHECK(kParamsLimiterVersion > kParamsBakeHoldVersion); + CHECK(kParamsPayloadVersion == kParamsLimiterVersion); } // --- The filter tail (payload v9) -------------------------------------------- @@ -787,21 +792,28 @@ static void testNonFiniteAhdSecondsLiftToZero() { // --- The v13 hard-flag tail: corruption must never widen past its own three curves ----------- -// The two trailing blocks of a CURRENT blob, so the splice tests below can cut back to the +// The three trailing blocks of a CURRENT blob, so the splice tests below can cut back to the // hard flags and rewrite them without hand-counting the payload twice. Every velocity curve // in those fixtures is at its default 2-point shape, which is what pins the flag block sizes. static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2; static constexpr std::size_t kBakeHoldTailBytes = 4 + 1; +static constexpr std::size_t kLimiterTailBytes = 1; -// The v14 tail, re-appended after a splice so the record still ends where the reader expects. +// The v14/v15 tails, re-appended after a splice so the record still ends where the reader +// expects. They go back in wire order: Hold first, then the limiter byte. static void putBakeHoldTail(std::vector& out, int quarterExponent, note::DivisionModifier modifier) { legacy::u32v(out, static_cast(static_cast(quarterExponent))); legacy::u8v(out, static_cast(modifier)); } -static void putDefaultBakeHoldTail(std::vector& out) { +static void putLimiterTail(std::vector& out, bool enabled) { + legacy::u8v(out, enabled ? 1 : 0); +} + +static void putDefaultTrailingTails(std::vector& out) { putBakeHoldTail(out, 2, note::DivisionModifier::Straight); // 1/1, the field's default + putLimiterTail(out, false); // bypassed, the field's default } // A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still @@ -836,8 +848,8 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() { // order in params_payload.cpp) is deterministic and this test can splice it exactly. std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u32v(bytes, 5); // amp: bogus count... for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts legacy::u32v(bytes, 2); // filter: correct count, unchanged @@ -846,7 +858,7 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() { legacy::u32v(bytes, 2); // pitch: correct count, unchanged legacy::u8v(bytes, 0); legacy::u8v(bytes, 0); - putDefaultBakeHoldTail(bytes); + putDefaultTrailingTails(bytes); const ComponentState out = deserializeComponentState(bytes, 48000.0); // Every param preceding AND following the corrupted amp tail survives untouched. @@ -884,8 +896,8 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() { in.params.loopCrossfadeFrames = 321; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry // …and nothing at all after it, so the blob simply ends inside the v13 tail. @@ -903,6 +915,7 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() { CHECK(out.params.velocityCurve.size() == 2); // unaffected: not misapplied, not discarded CHECK(!out.params.velocityCurve.points()[0].hard); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); + CHECK(!out.params.limiterEnabled); } // The stranding case, and the reason a bogus count DRAINS rather than skipping in place: the @@ -918,6 +931,9 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric in.params.play.adsr.releaseSeconds = 0.44; in.params.loopCrossfadeFrames = 321; in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet); + // Enabled on the in-state so the drain has something to cost on the LAST tail too: a drain + // that stopped short of it would hand back the stored `true` off bytes it cannot trust. + in.params.limiterEnabled = true; // A THREE-point amp curve, so its flag block is three bytes rather than two: the // misaligned reads below then land on bytes that decode to something other than the @@ -928,12 +944,13 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric reasampler::instrument::engine::CurveDomain::Unipolar); // A REAL blob with exactly ONE corrupt field: the amp hard-flag count, patched in place. - // Everything after it — the amp flags, both well-formed neighbour blocks, and the Hold — - // is exactly what the serializer wrote, which is the whole hazard. + // Everything after it — the amp flags, both well-formed neighbour blocks, the Hold and the + // limiter byte — is exactly what the serializer wrote, which is the whole hazard. constexpr std::size_t kThreePointFlagTail = (4 + 3) + (4 + 2) + (4 + 2); + constexpr std::size_t kTrailingTails = kBakeHoldTailBytes + kLimiterTailBytes; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kThreePointFlagTail + kBakeHoldTailBytes); - const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kBakeHoldTailBytes; + CHECK(bytes.size() >= kThreePointFlagTail + kTrailingTails); + const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kTrailingTails; for (std::size_t i = 0; i < 4; ++i) bytes[ampCountAt + i] = i == 0 ? 0x00 : 0xFF; const ComponentState out = deserializeComponentState(bytes, 48000.0); @@ -950,6 +967,8 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric // clamps to the top rung. CHECK(out.params.bakeHold != note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight)); + // The drain reaches the last tail as well: the stored `true` is past the damage too. + CHECK(!out.params.limiterEnabled); } // Numeric domains are established at the DOOR, not at each consumer. A NaN pitch depth reaches @@ -1025,10 +1044,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord() in.params.loopCrossfadeFrames = 5; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); - // Drops the bake-Hold tail with the flags: the truncation strands everything after it, - // which is the whole point — Hold lifts to its default alongside the flags. - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); + // Drops the bake-Hold and limiter tails with the flags: the truncation strands everything + // after it, which is the whole point — both lift to their defaults alongside the flags. + bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing legacy::u8v(bytes, 0x00); @@ -1214,6 +1233,87 @@ static void testPriorPayloadVersionsLiftToAHardSeam() { } } +// The v15 rung. The limiter enable is a strict SUFFIX on v14, so a v14 blob is a valid prefix +// of it and lifts to BYPASSED — the migration bar for a project saved before the limiter +// existed: it reopens with the limiter off and therefore sounding identical. +// +// The v14 case is also the load-bearing ORDERING proof at the reader. A v14 blob is the +// current one with its last byte cut, so if the limiter byte were written AHEAD of the Hold +// the cut would take the Hold's modifier instead and the v14 read would resolve the Hold off +// the limiter byte — the stored division below would not survive. Transposing the two writes +// fails here, not merely in the byte fixture. +static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact() { + ComponentState in; + in.selectionId = "pad"; + in.params.limiterEnabled = true; + in.params.keyTrack = 0.25; // a neighbour ahead of the new byte, so a misread shows up here too + // Ξ's v14 field, off its default, so the lift below can prove it came back untouched. + in.params.bakeHold = note::makeDivision(-1, note::DivisionModifier::Dotted); + + const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); + CHECK(out.params.limiterEnabled); + CHECK(out.params.keyTrack == 0.25); + CHECK(out.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted)); + + // The same state stamped v14, with exactly the one appended byte cut away: byte-for-byte + // what the Ξ binary wrote. Its Hold must survive in full. + const ComponentState v14 = deserializeComponentState( + payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes), 48000.0); + CHECK(!v14.params.limiterEnabled); + CHECK(v14.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted)); + CHECK(v14.params.keyTrack == 0.25); + + // And a v13 blob, one rung further back, lifts to BOTH defaults. + const ComponentState v13 = deserializeComponentState( + payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes), + 48000.0); + CHECK(!v13.params.limiterEnabled); + CHECK(v13.params.bakeHold == InstrumentParams{}.bakeHold); + CHECK(v13.params.keyTrack == 0.25); + + // Bypassed is the default at the struct as well as on the wire. + CHECK(!InstrumentParams{}.limiterEnabled); + const ComponentState fresh = + deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0); + CHECK(!fresh.params.limiterEnabled); +} + +// The ORDERING proof at the WRITER, stated in bytes rather than in prose: the payload's whole +// discipline is that each version's fields are a strict suffix on the previous version's, so +// v14's Hold pair must be emitted BEFORE v15's limiter byte or every v14 blob already saved +// mis-parses. Asserted at absolute offsets from the end of the blob, with both fields off +// their defaults, so transposing the two writes fails on the values and not just the layout. +static void testAppendedTailsSitInVersionOrderOnTheWire() { + ComponentState in; + in.selectionId = "pad"; + in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet); + in.params.limiterEnabled = true; + + const std::vector bytes = serializeComponentState(in); + CHECK(bytes.size() > kBakeHoldTailBytes + kLimiterTailBytes); + + // The last six bytes are, in order: the v14 Hold's 4-byte LE exponent, its 1-byte + // modifier, then the v15 limiter byte. + const std::size_t holdAt = bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes; + CHECK(bytes[holdAt + 0] == 0xfe); // -2 as int32 LE two's-complement + CHECK(bytes[holdAt + 1] == 0xff); + CHECK(bytes[holdAt + 2] == 0xff); + CHECK(bytes[holdAt + 3] == 0xff); + CHECK(bytes[holdAt + 4] == static_cast(note::DivisionModifier::Triplet)); + CHECK(bytes[bytes.size() - 1] == 0x01); // the limiter enable, last + + // The same claim from the other side: flipping only the limiter changes only the LAST + // byte, so the byte the limiter owns cannot be one the Hold also writes. + ComponentState off = in; + off.params.limiterEnabled = false; + const std::vector offBytes = serializeComponentState(off); + CHECK(offBytes.size() == bytes.size()); + if (offBytes.size() == bytes.size()) { + for (std::size_t i = 0; i + 1 < bytes.size(); ++i) CHECK(offBytes[i] == bytes[i]); + CHECK(offBytes[bytes.size() - 1] == 0x00); + } +} + // The sharp edge of the bipolar change: v12 widened the filter curve's y domain, and the lift // is a pure DOMAIN RE-TAG — no rescaling, no rounding. A pre-v12 curve's y values all lie in // [0,1], which is inside [-1,+1], so every knot must come back bit-identical, the depth beside @@ -1308,10 +1408,10 @@ static void testV13BlobLiftsToTheDefaultHold() { in.params.loopCrossfadeFrames = 128; in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted); - // Stamp the payload back to v13 and drop exactly the v14 tail: byte-for-byte what the - // previous binary would have written. - const std::vector v13 = - payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes); + // Stamp the payload back to v13 and drop the v14 and v15 tails both: byte-for-byte what + // the v13 binary would have written. + const std::vector v13 = payloadDowngradedTo( + in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes); const ComponentState out = deserializeComponentState(v13, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(out.selectionId == "pad"); @@ -1327,18 +1427,22 @@ static void testBakeHoldCorruptPairClampsToTheLadder() { ComponentState in; in.selectionId = "pad"; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kBakeHoldTailBytes); - bytes.resize(bytes.size() - kBakeHoldTailBytes); + CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes); + bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u32v(bytes, static_cast(static_cast(9999))); legacy::u8v(bytes, 200); // an unnamed modifier byte + putLimiterTail(bytes, true); // a well-formed byte after it, so the clamp is the only fault const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight)); + // The tail behind the corrupt pair still lands on its own field: the clamp consumed exactly + // the five bytes it was owed, so the limiter byte was not read out of the Hold's modifier. + CHECK(out.params.limiterEnabled); } -// A blob truncated INSIDE the v14 tail costs the Hold alone. It sits last, so without the -// revive a stray missing byte would reset every parameter ahead of it to defaults. +// A blob truncated INSIDE the v14 tail costs the Hold alone — and, with the v15 byte stranded +// behind it, the limiter's revive is what stops that truncation resetting the record anyway. static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() { ComponentState in; in.selectionId = "pad"; @@ -1346,15 +1450,17 @@ static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() { in.params.play.adsr.attackSeconds = 0.017; in.params.loopCrossfadeFrames = 96; in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet); + in.params.limiterEnabled = true; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kBakeHoldTailBytes); - bytes.resize(bytes.size() - kBakeHoldTailBytes); + CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes); + bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing legacy::u8v(bytes, 0x00); const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); + CHECK(!out.params.limiterEnabled); // stranded behind the Hold, and revived not wiped CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 71); CHECK(out.params.play.adsr.attackSeconds == 0.017); @@ -1935,6 +2041,8 @@ int main() { testLoopSpanAndCrossfadeRoundTrip(); testNegativeCrossfadeOnTheWireLiftsToZero(); testPriorPayloadVersionsLiftToAHardSeam(); + testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact(); + testAppendedTailsSitInVersionOrderOnTheWire(); testPreV12FilterVelocityLiftsAsAPureDomainReTag(); testWriterEmitsCurrentPayloadVersion(); testSingleZoneMigrationIsLossless(); diff --git a/tests/test_limiter.cpp b/tests/test_limiter.cpp new file mode 100644 index 0000000..a35daf3 --- /dev/null +++ b/tests/test_limiter.cpp @@ -0,0 +1,285 @@ +// Standalone tests for reasampler::instrument::engine::Limiter — no VST3, no REAPER, no +// framework. The properties the master bus depends on, asserted rather than judged by ear: +// +// * bypassed and settled, process() does not touch one byte of the buffers (the byte-identical +// at-rest path) and reports no reduction; +// * engaged below the ceiling, the output is the input DELAYED and bit-exact — nothing is +// louder, quieter or altered at rest, and there is no makeup gain to find; +// * engaged on program +12 dB over, no output sample passes the ceiling; bypassed, the same +// program still passes 0 dBFS, so the toggle is doing the work; +// * the detection is TRUE-peak: a signal whose SAMPLES all clear the ceiling but whose +// inter-sample peak does not still engages; +// * the gain is stereo-linked, so a dual-mono signal stays centered across a full toggle; +// * the engage/disengage crossfade leaves no step larger than the signal's own. + +#include "../src/core/instrument/engine/limiter.h" + +#include +#include +#include +#include +#include + +using namespace reasampler::instrument::engine; + +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 constexpr double kRate = 48000.0; + +// A deterministic non-repeating pattern, so an untouched-buffer check cannot pass by accident. +static std::vector pattern(int n, float scale = 1.f) { + std::vector v(static_cast(n)); + std::uint32_t s = 0x1234567u; + for (int i = 0; i < n; ++i) { + s = s * 1664525u + 1013904223u; + v[static_cast(i)] = + scale * (static_cast(static_cast(s >> 8) % 20001 - 10000) / 10000.f); + } + return v; +} + +// Runs `in` through `lim` in blocks of `block`, returning the output and the smallest gain +// reported across the whole run. +static std::vector runMono(Limiter& lim, const std::vector& in, int block, + float* minGainOut = nullptr) { + std::vector out = in; + float lowest = 1.f; + for (std::size_t i = 0; i < out.size(); i += static_cast(block)) { + const int n = static_cast( + std::min(static_cast(block), out.size() - i)); + const float g = lim.process(out.data() + i, nullptr, n); + if (g < lowest) lowest = g; + } + if (minGainOut) *minGainOut = lowest; + return out; +} + +static void testBypassedLeavesEveryByteUntouched() { + Limiter lim; + lim.prepare(kRate); + CHECK(!lim.enabled()); + const std::vector in = pattern(2048, 1.8f); // well over full scale + float minGain = 0.f; + const std::vector out = runMono(lim, in, 512, &minGain); + bool identical = true; + for (std::size_t i = 0; i < in.size(); ++i) { + if (out[i] != in[i]) { identical = false; break; } + } + CHECK(identical); + CHECK(minGain == 1.f); + // And that untouched signal still passes 0 dBFS — the toggle, not the meter, is what + // stops it. + float peak = 0.f; + for (float v : out) peak = std::max(peak, std::fabs(v)); + CHECK(peak > 1.f); +} + +static void testEngagedBelowThresholdIsTheInputDelayedBitExactly() { + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); // prepare snaps to the target: no crossfade, no priming + const int latency = limiterLookaheadSamples(kRate); + // Comfortably under the ceiling at every sample AND between samples. + const std::vector in = pattern(4096, 0.4f); + float minGain = 0.f; + const std::vector out = runMono(lim, in, 256, &minGain); + CHECK(minGain == 1.f); // exactly unity: there is no makeup gain and no residual trim + bool exact = true; + for (std::size_t i = static_cast(latency); i < in.size(); ++i) { + if (out[i] != in[i - static_cast(latency)]) { exact = false; break; } + } + CHECK(exact); +} + +static void testEngagedHoldsTheCeilingOnProgramTwelveDbOver() { + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); + const int latency = limiterLookaheadSamples(kRate); + const float ceiling = static_cast(limiterCeilingLinear()); + // +12 dB over the ceiling, sustained, with the transient content the pattern gives. + std::vector in = pattern(24000, ceiling * 3.98f); + float minGain = 0.f; + const std::vector out = runMono(lim, in, 128, &minGain); + CHECK(minGain < 0.4f); // it really did pull the gain down + float worst = 0.f; + for (std::size_t i = static_cast(latency); i < out.size(); ++i) { + worst = std::max(worst, std::fabs(out[i])); + } + // Sample peak, so the true-peak ceiling is the bound with room to spare for float rounding. + CHECK(worst <= ceiling * 1.0001f); +} + +static void testTruePeakDetectionEngagesWhereSamplePeakWouldNot() { + // fs/4 at 45 degrees: every SAMPLE sits at A/sqrt(2) while the waveform reaches A between + // them. A sample-peak detector would pass this through untouched. + const double amp = 1.2; + const float ceiling = static_cast(limiterCeilingLinear()); + std::vector in(8000); + for (std::size_t i = 0; i < in.size(); ++i) { + in[i] = static_cast( + amp * std::cos(3.14159265358979323846 * (0.5 * static_cast(i) + 0.25))); + } + float samplePeak = 0.f; + for (float v : in) samplePeak = std::max(samplePeak, std::fabs(v)); + CHECK(samplePeak < ceiling); // the premise: no SAMPLE is over + + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); + float minGain = 0.f; + runMono(lim, in, 256, &minGain); + CHECK(minGain < 1.f); +} + +static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { + Limiter lim; + lim.prepare(kRate); + const float ceiling = static_cast(limiterCeilingLinear()); + const std::vector src = pattern(48000, ceiling * 2.5f); + std::vector l = src, r = src; // dual mono: L and R are the same signal + const int block = 64; + bool centered = true; + for (std::size_t i = 0; i < l.size(); i += static_cast(block)) { + // Toggle on a quarter in and off three quarters in, so the run covers bypassed, + // the engage crossfade, fully engaged, the disengage crossfade, and bypassed again. + if (i >= l.size() / 4 && !lim.enabled()) lim.setEnabled(true); + if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); + const int n = static_cast( + std::min(static_cast(block), l.size() - i)); + lim.process(l.data() + i, r.data() + i, n); + } + for (std::size_t i = 0; i < l.size(); ++i) { + if (l[i] != r[i]) { centered = false; break; } + } + CHECK(centered); + // And the engaged stretch really was limited, so the equality above is not equality on an + // untouched buffer. + float worstEngaged = 0.f; + for (std::size_t i = l.size() / 2; i < (l.size() * 3) / 4; ++i) { + worstEngaged = std::max(worstEngaged, std::fabs(l[i])); + } + CHECK(worstEngaged <= ceiling * 1.0001f); + CHECK(worstEngaged > 0.f); +} + +static void testToggleEmitsNoStepLargerThanTheSignalsOwn() { + // A steady sine: the crossfade blends it with a copy of itself delayed by the lookahead, + // which at 440 Hz is nearly half a cycle out — switching hard instead of fading would step + // by up to twice the amplitude, so this assertion has real teeth. + const double freq = 440.0; + const double amp = 0.5; // under the ceiling: this measures the TRANSITION, not limiting + std::vector x(48000); + for (std::size_t i = 0; i < x.size(); ++i) { + x[i] = static_cast( + amp * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast(i) / kRate)); + } + const float naturalStep = + static_cast(amp * 2.0 * 3.14159265358979323846 * freq / kRate); + + Limiter lim; + lim.prepare(kRate); + const int block = 32; + for (std::size_t i = 0; i < x.size(); i += static_cast(block)) { + if (i >= x.size() / 4 && !lim.enabled()) lim.setEnabled(true); + if (i >= (x.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); + const int n = static_cast( + std::min(static_cast(block), x.size() - i)); + lim.process(x.data() + i, nullptr, n); + } + float worstStep = 0.f; + for (std::size_t i = 1; i < x.size(); ++i) { + worstStep = std::max(worstStep, std::fabs(x[i] - x[i - 1])); + } + CHECK(worstStep <= naturalStep * 1.2f); +} + +static void testCrossfadeSettlesToTheExactEngagedAndBypassedPaths() { + Limiter lim; + lim.prepare(kRate); + const int latency = limiterLookaheadSamples(kRate); + const int settle = static_cast(kLimiterCrossfadeSeconds * kRate) + latency + 64; + const std::vector src = pattern(4 * settle, 0.3f); // under the ceiling throughout + + std::vector y = src; + lim.setEnabled(true); + lim.process(y.data(), nullptr, static_cast(y.size())); + // Past the crossfade the engaged path is exactly the delayed input again. + bool exact = true; + for (std::size_t i = static_cast(settle); i < y.size(); ++i) { + if (y[i] != src[i - static_cast(latency)]) { exact = false; break; } + } + CHECK(exact); + + std::vector z = src; + lim.setEnabled(false); + lim.process(z.data(), nullptr, static_cast(z.size())); + bool passthrough = true; + for (std::size_t i = static_cast(settle); i < z.size(); ++i) { + if (z[i] != src[i]) { passthrough = false; break; } + } + CHECK(passthrough); + // And once settled bypassed, the next block is untouched again. + std::vector w = pattern(512, 1.5f); + const std::vector before = w; + CHECK(lim.process(w.data(), nullptr, static_cast(w.size())) == 1.f); + bool untouched = true; + for (std::size_t i = 0; i < w.size(); ++i) { + if (w[i] != before[i]) { untouched = false; break; } + } + CHECK(untouched); +} + +static void testGainNeverRisesAboveUnity() { + // "No makeup gain, ever, of any kind" as a property rather than an absence: across quiet, + // loud and silent material the applied gain is never above 1 and the output magnitude is + // never above the input's own. + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); + std::vector in = pattern(16000, 2.0f); + for (std::size_t i = 4000; i < 8000; ++i) in[i] = 0.f; // a silent stretch + for (std::size_t i = 8000; i < 12000; ++i) in[i] *= 0.001f; // and a very quiet one + float minGain = 0.f; + const std::vector out = runMono(lim, in, 200, &minGain); + CHECK(minGain <= 1.f); + float inPeak = 0.f, outPeak = 0.f; + for (std::size_t i = 0; i < in.size(); ++i) { + inPeak = std::max(inPeak, std::fabs(in[i])); + outPeak = std::max(outPeak, std::fabs(out[i])); + } + CHECK(outPeak <= inPeak); +} + +static void testBakedConstants() { + CHECK(kLimiterCeilingDbTp == -0.3); + CHECK(std::fabs(limiterCeilingLinear() - std::pow(10.0, -0.3 / 20.0)) < 1e-12); + CHECK(limiterCeilingLinear() < 1.0); + // 2 ms at the common rates, and never below the detector's own group delay. + CHECK(limiterLookaheadSamples(48000.0) == 96); + CHECK(limiterLookaheadSamples(44100.0) == 88); + CHECK(limiterLookaheadSamples(96000.0) == 192); + CHECK(limiterLookaheadSamples(0.0) == 0); + CHECK(limiterLookaheadSamples(-1.0) == 0); + CHECK(limiterLookaheadSamples(100.0) > kLimiterOsDelay); +} + +int main() { + testBypassedLeavesEveryByteUntouched(); + testEngagedBelowThresholdIsTheInputDelayedBitExactly(); + testEngagedHoldsTheCeilingOnProgramTwelveDbOver(); + testTruePeakDetectionEngagesWhereSamplePeakWouldNot(); + testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle(); + testToggleEmitsNoStepLargerThanTheSignalsOwn(); + testCrossfadeSettlesToTheExactEngagedAndBypassedPaths(); + testGainNeverRisesAboveUnity(); + testBakedConstants(); + if (g_fail) { + std::printf("%d FAILURE(S)\n", g_fail); + return 1; + } + std::printf("limiter tests passed\n"); + return 0; +} diff --git a/tests/test_meter_ballistics.cpp b/tests/test_meter_ballistics.cpp new file mode 100644 index 0000000..8a889dd --- /dev/null +++ b/tests/test_meter_ballistics.cpp @@ -0,0 +1,140 @@ +// Standalone tests for reasampler::instrument::engine::meter_ballistics — no VST3, no REAPER, +// no framework. The meter's whole behaviour is asserted here without a host: instantaneous +// rise, the 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip +// latch and its clear, and the dB -> normalized map the bar is drawn against. + +#include "../src/core/instrument/engine/meter_ballistics.h" + +#include +#include + +using namespace reasampler::instrument::engine; + +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 bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; } + +static double linearFromDb(double db) { return std::pow(10.0, db / 20.0); } + +static void testDbFromLinear() { + CHECK(near(meterDbFromLinear(1.0), 0.0)); + CHECK(near(meterDbFromLinear(0.5), -6.0205999132796239, 1e-9)); + CHECK(near(meterDbFromLinear(2.0), 6.0205999132796239, 1e-9)); + // Silence and anything under the floor read the floor, not -inf: the ballistics subtract + // from this value, so it has to stay finite. + CHECK(meterDbFromLinear(0.0) == kMeterFloorDb); + CHECK(meterDbFromLinear(-1.0) == kMeterFloorDb); + CHECK(meterDbFromLinear(1e-9) == kMeterFloorDb); +} + +static void testNormFromDbIsLinearInDbAndClamped() { + CHECK(meterNormFromDb(kMeterFloorDb) == 0.0); + CHECK(meterNormFromDb(kMeterTopDb) == 1.0); + CHECK(meterNormFromDb(-1000.0) == 0.0); + CHECK(meterNormFromDb(1000.0) == 1.0); + // Linear in dB: equal dB steps are equal normalized steps anywhere in the span. + const double a = meterNormFromDb(-48.0) - meterNormFromDb(-54.0); + const double b = meterNormFromDb(-6.0) - meterNormFromDb(-12.0); + CHECK(near(a, b, 1e-12)); + CHECK(near(a, 6.0 / (kMeterTopDb - kMeterFloorDb), 1e-12)); + // The 0 dB tick, which the scale's heavier rule is drawn on. + CHECK(near(meterNormFromDb(0.0), 60.0 / 66.0, 1e-12)); +} + +static void testRiseIsInstantaneous() { + MeterState s; + s = advanceMeter(s, linearFromDb(-12.0), 1.0 / 30.0); + CHECK(near(s.levelDb, -12.0, 1e-9)); + // A louder block on the very next frame displays at once, however short the frame. + s = advanceMeter(s, linearFromDb(-3.0), 1e-6); + CHECK(near(s.levelDb, -3.0, 1e-9)); +} + +static void testFallIsTwentyDbPerSecond() { + MeterState s; + s = advanceMeter(s, 1.0, 0.0); // 0 dBFS + CHECK(near(s.levelDb, 0.0, 1e-9)); + s = advanceMeter(s, 0.0, 0.5); + CHECK(near(s.levelDb, -10.0, 1e-9)); + s = advanceMeter(s, 0.0, 0.25); + CHECK(near(s.levelDb, -15.0, 1e-9)); + // The fall never takes the bar under the peak the block itself carried. + s = advanceMeter(s, linearFromDb(-14.0), 1.0); + CHECK(near(s.levelDb, -14.0, 1e-9)); + // And it stops at the floor. + for (int i = 0; i < 20; ++i) s = advanceMeter(s, 0.0, 0.5); + CHECK(near(s.levelDb, kMeterFloorDb, 1e-9)); +} + +static void testPeakHoldLatchesForOnePointFiveSecondsThenFallsAtTheSameRate() { + MeterState s; + s = advanceMeter(s, 1.0, 0.0); + CHECK(near(s.holdDb, 0.0, 1e-9)); + CHECK(near(s.holdRemainingSeconds, kMeterPeakHoldSeconds, 1e-12)); + + // 1.4 s of silence: the bar has long fallen away, the tick has not moved. + for (int i = 0; i < 14; ++i) s = advanceMeter(s, 0.0, 0.1); + CHECK(near(s.holdDb, 0.0, 1e-9)); + CHECK(s.levelDb < -20.0); + + // Past 1.5 s it releases at the bar's own rate — 0.2 s past the latch is 4 dB down. + s = advanceMeter(s, 0.0, 0.3); + CHECK(near(s.holdDb, -4.0, 1e-9)); + s = advanceMeter(s, 0.0, 0.1); + CHECK(near(s.holdDb, -6.0, 1e-9)); + + // A new peak re-latches it and restarts the hold. + s = advanceMeter(s, linearFromDb(-2.0), 0.1); + CHECK(near(s.holdDb, -2.0, 1e-9)); + CHECK(near(s.holdRemainingSeconds, kMeterPeakHoldSeconds, 1e-12)); +} + +static void testHoldNeverFallsBelowTheBar() { + MeterState s; + s = advanceMeter(s, 1.0, 0.0); + for (int i = 0; i < 40; ++i) s = advanceMeter(s, linearFromDb(-20.0), 0.1); + CHECK(near(s.levelDb, -20.0, 1e-9)); + CHECK(near(s.holdDb, -20.0, 1e-9)); +} + +static void testClipLatchesAtFullScaleAndOnlyClearsOnRequest() { + MeterState s; + s = advanceMeter(s, linearFromDb(-0.01), 0.1); + CHECK(!s.clip); // under 0 dBFS does not latch + s = advanceMeter(s, 1.0, 0.1); + CHECK(s.clip); // exactly 0 dBFS does + for (int i = 0; i < 100; ++i) s = advanceMeter(s, 0.0, 0.1); + CHECK(s.clip); // and silence does not unlatch it + s = clearMeterClip(s); + CHECK(!s.clip); + s = advanceMeter(s, linearFromDb(-6.0), 0.1); + CHECK(!s.clip); // cleared stays cleared while nothing reaches full scale + CHECK(near(s.levelDb, -6.0, 1e-9)); // and clearing left the ballistics alone +} + +static void testNonPositiveElapsedFreezesTheBallistics() { + MeterState s; + s = advanceMeter(s, 1.0, 0.0); + const MeterState frozen = advanceMeter(s, 0.0, -1.0); + CHECK(near(frozen.levelDb, s.levelDb, 1e-12)); + CHECK(near(frozen.holdDb, s.holdDb, 1e-12)); +} + +int main() { + testDbFromLinear(); + testNormFromDbIsLinearInDbAndClamped(); + testRiseIsInstantaneous(); + testFallIsTwentyDbPerSecond(); + testPeakHoldLatchesForOnePointFiveSecondsThenFallsAtTheSameRate(); + testHoldNeverFallsBelowTheBar(); + testClipLatchesAtFullScaleAndOnlyClearsOnRequest(); + testNonPositiveElapsedFreezesTheBallistics(); + if (g_fail) { + std::printf("%d FAILURE(S)\n", g_fail); + return 1; + } + std::printf("meter_ballistics tests passed\n"); + return 0; +} From 0612abbddb7f5eace214173e73d21277f38fc5b9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:34:08 -0400 Subject: [PATCH 13/56] =?UTF-8?q?=CE=93-W1-T2=20review:=20one=20restart=20?= =?UTF-8?q?funnel,=20tighter=20ceiling=20proof,=20effective-gain=20meter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold setLimiterEnabled's restart request into setInstrumentParams so every writer keeps the host's latency report in sync. Pin the window-sizing identity, drop the per-sample modulo, tighten the ceiling tolerance, publish the blended gain. --- src/core/instrument/engine/limiter.cpp | 19 ++++++++--- src/core/instrument/engine/limiter.h | 11 ++++-- src/shell/instrument/processor_state.cpp | 29 +++++++++------- src/shell/instrument/reasampler_processor.h | 7 ++-- tests/test_limiter.cpp | 37 +++++++++++++++++++-- 5 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/core/instrument/engine/limiter.cpp b/src/core/instrument/engine/limiter.cpp index ea602bb..5837ca6 100644 --- a/src/core/instrument/engine/limiter.cpp +++ b/src/core/instrument/engine/limiter.cpp @@ -114,18 +114,22 @@ float Limiter::detectTruePeak(float xl, float xr, bool stereo) { float Limiter::smoothGain(float target) { // Sliding minimum over `window_` via a monotonic wedge. Expiring the front BEFORE the push - // is what bounds the wedge to `window_` entries — pushing first can lap the ring. + // is what bounds the wedge to `window_` entries — pushing first can lap the ring. Wraps by + // compare-and-subtract, matching delayPos_/avgPos_: window_ is not a power of two, so `%` + // would not strength-reduce on this per-sample path. while (wedgeCount_ > 0 && wedgeIdx_[static_cast(wedgeHead_)] <= pushIndex_ - window_) { - wedgeHead_ = (wedgeHead_ + 1) % window_; + wedgeHead_ = (wedgeHead_ + 1 == window_) ? 0 : wedgeHead_ + 1; --wedgeCount_; } while (wedgeCount_ > 0) { - const int back = (wedgeHead_ + wedgeCount_ - 1) % window_; + const int backSum = wedgeHead_ + wedgeCount_ - 1; + const int back = (backSum >= window_) ? backSum - window_ : backSum; if (wedgeVal_[static_cast(back)] < target) break; --wedgeCount_; } - const int slot = (wedgeHead_ + wedgeCount_) % window_; + const int slotSum = wedgeHead_ + wedgeCount_; + const int slot = (slotSum >= window_) ? slotSum - window_ : slotSum; wedgeVal_[static_cast(slot)] = target; wedgeIdx_[static_cast(slot)] = pushIndex_; ++wedgeCount_; @@ -171,7 +175,6 @@ float Limiter::process(float* left, float* right, int frames) { const float peak = detectTruePeak(dryL, dryR, stereo); const float targetGain = peak > ceiling_ ? ceiling_ / peak : 1.f; const float gain = smoothGain(targetGain); - if (gain < blockMin) blockMin = gain; const std::size_t slot = static_cast(delayPos_); const float wetL = delayL_[slot] * gain; @@ -184,6 +187,12 @@ float Limiter::process(float* left, float* right, int frames) { // dry + (wet - dry) * 1.0f is not wet in floating point. At m <= 0 the buffer is left // untouched, which is the dry sample already in it. const float m = mix_; + // The reported minimum is the gain actually reaching the output, not the limiter's raw + // target — mid-crossfade only a fraction `m` of the reduction is audible, so the meter + // (whose contract is "smallest gain APPLIED") must blend the same way the signal does: + // unity at m=0, `gain` at m=1, linear between. + const float effectiveGain = 1.f - m + m * gain; + if (effectiveGain < blockMin) blockMin = effectiveGain; if (m >= 1.f) { left[i] = wetL; if (stereo) right[i] = wetR; diff --git a/src/core/instrument/engine/limiter.h b/src/core/instrument/engine/limiter.h index 600c666..346907d 100644 --- a/src/core/instrument/engine/limiter.h +++ b/src/core/instrument/engine/limiter.h @@ -14,7 +14,9 @@ namespace reasampler::instrument::engine { // The BAKED ceiling. A safety device with no configurable controls, so this is not a // parameter. dBTP is a TRUE-peak target, which is why the detector oversamples and the -// signal path never does. +// signal path never does — though the bound is on the detector's 4x-oversampled ESTIMATE, +// not infinite-resolution true peak (normal for any practical TP limiter, and part of why +// this ceiling sits at -0.3 rather than 0). inline constexpr double kLimiterCeilingDbTp = -0.3; // The total delay the limiter imposes while engaged, and therefore the plugin's whole reported @@ -67,8 +69,11 @@ public: bool enabled() const { return target_.load(std::memory_order_relaxed); } // Applies the limiter in place over `frames` of `left` (and `right`, which may be null for - // a mono buffer). Returns the SMALLEST gain applied in this block — 1.0 for none, and the - // value a settled bypass returns. + // a mono buffer). Returns the SMALLEST gain actually applied to the output this block — 1.0 + // for none (a settled bypass, or wherever the engage/disengage crossfade sits at dry). Mid + // crossfade this is the target gain blended by the same fraction `mix_` blends the signal, + // not the limiter's raw target — the two must agree, or the meter over-reports reduction + // that is only partially audible. float process(float* left, float* right, int frames); private: diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 95b369b..2df4c34 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -149,14 +149,25 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() { } void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { + bool limiterFlagChanged = false; { std::lock_guard lock(paramsMutex_); + limiterFlagChanged = (params_.limiterEnabled != params.limiterEnabled); params_ = params; } // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — // funnels through here, so mirroring the limiter flag at this one point is what keeps the - // audio thread's copy and the latency report from ever lagging what is persisted. + // audio thread's copy and the latency report from ever lagging what is persisted, and + // requesting the restart here (not just from setLimiterEnabled) is what keeps the host's + // PDC from lagging it too. Coalesced: writing the value already held requests nothing. publishLimiterEnabled(params.limiterEnabled); + if (limiterFlagChanged && componentHandler) { + // The SDK requires this on the UI thread and answers getLatencySamples only after the + // host's own deactivate/reactivate — so the flag above is already committed by the time + // the host asks. This is a kLatencyChanged restart with the bus untouched, NOT the + // retired per-mode kIoChanged bus renegotiation (see initialize()); do not conflate. + componentHandler->restartComponent(kLatencyChanged); + } } void ReaSamplerProcessor::publishLimiterEnabled(bool on) { @@ -165,17 +176,11 @@ void ReaSamplerProcessor::publishLimiterEnabled(bool on) { } void ReaSamplerProcessor::setLimiterEnabled(bool on) { - { - std::lock_guard lock(paramsMutex_); - if (params_.limiterEnabled == on) return; // no change: no restart to request - params_.limiterEnabled = on; - } - publishLimiterEnabled(on); - // The SDK requires this on the UI thread and answers getLatencySamples only after the host's - // own deactivate/reactivate — so the flag above is already committed by the time the host - // asks. This is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode - // kIoChanged bus renegotiation (see initialize()); do not conflate the two. - if (componentHandler) componentHandler->restartComponent(kLatencyChanged); + // Thin wrapper: setInstrumentParams is the one funnel that mirrors the flag AND requests + // the restart, so every writer of the parameter set — this one included — agrees. + InstrumentParams params = instrumentParams(); + params.limiterEnabled = on; + setInstrumentParams(params); } MasterBusMeter ReaSamplerProcessor::masterBusMeter() const { diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index e831b2f..c29ac2d 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -232,9 +232,10 @@ public: void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()] // The master-bus limiter's single enable (persisted in the parameter set). UI thread only: - // the setter requests the host's kLatencyChanged restart, which the SDK requires be issued - // from the UI thread and which process() must therefore never trigger. Setting the value it - // already holds is a no-op, so repeated clicks on one segment cost no restart. + // a thin wrapper over setInstrumentParams, the one funnel that both mirrors the flag and + // requests the host's kLatencyChanged restart, which the SDK requires be issued from the UI + // thread and which process() must therefore never trigger. Setting the value it already + // holds is a no-op, so repeated clicks on one segment cost no restart. bool limiterEnabled() const { return limiterEnabled_.load(std::memory_order_relaxed); } diff --git a/tests/test_limiter.cpp b/tests/test_limiter.cpp index a35daf3..ed65cd3 100644 --- a/tests/test_limiter.cpp +++ b/tests/test_limiter.cpp @@ -108,8 +108,10 @@ static void testEngagedHoldsTheCeilingOnProgramTwelveDbOver() { for (std::size_t i = static_cast(latency); i < out.size(); ++i) { worst = std::max(worst, std::fabs(out[i])); } - // Sample peak, so the true-peak ceiling is the bound with room to spare for float rounding. - CHECK(worst <= ceiling * 1.0001f); + // Sample peak, so the true-peak ceiling is the bound with room to spare for float rounding + // (ceiling/peak then x*gain admits at most ~2.4e-7 relative overshoot; 1e-6 stays a hard + // bound without hiding a systematic error the way a much wider tolerance would). + CHECK(worst <= ceiling * (1.f + 1e-6f)); } static void testTruePeakDetectionEngagesWhereSamplePeakWouldNot() { @@ -161,7 +163,7 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { for (std::size_t i = l.size() / 2; i < (l.size() * 3) / 4; ++i) { worstEngaged = std::max(worstEngaged, std::fabs(l[i])); } - CHECK(worstEngaged <= ceiling * 1.0001f); + CHECK(worstEngaged <= ceiling * (1.f + 1e-6f)); CHECK(worstEngaged > 0.f); } @@ -253,6 +255,34 @@ static void testGainNeverRisesAboveUnity() { CHECK(outPeak <= inPeak); } +static void testAlignmentIdentityHoldsAtTheExactWindowEdge() { + // Pins the alignment identity window_ = latency_ - kLimiterOsDelay + 1 (limiter.h's + // comment on window_, otherwise asserted nowhere): a single isolated over-ceiling impulse + // is reduced to EXACTLY the ceiling at the one output sample the identity predicts + // (impulseAt + latency), because that is the unique push index where the sliding + // min-then-average has folded in nothing but this impulse's own detected peak. Shifting + // the identity by +-1 either lets the impulse's own excess slip just outside the window + // (undershoots the reduction, sample overshoots the ceiling) or applies the full reduction + // one sample late (same overshoot at this index) — confirmed by hand-mutating window_'s + // formula in both directions and observing this assertion fail before restoring it. + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); + const int latency = limiterLookaheadSamples(kRate); + const float ceiling = static_cast(limiterCeilingLinear()); + const int impulseAt = 500; + std::vector in(static_cast(impulseAt + latency + 200), 0.f); + in[static_cast(impulseAt)] = ceiling * 4.f; // isolated, well over + float minGain = 0.f; + const std::vector out = runMono(lim, in, 37, &minGain); // odd block: crosses the edge + CHECK(minGain > 0.24f && minGain < 0.26f); // ceiling/peak == 0.25 for this impulse + const float atEdge = out[static_cast(impulseAt + latency)]; + CHECK(std::fabs(atEdge - ceiling) <= ceiling * 1e-6f); + // Every neighbor stays exactly silent — the reduction lands on this one sample, not smeared. + CHECK(out[static_cast(impulseAt + latency - 1)] == 0.f); + CHECK(out[static_cast(impulseAt + latency + 1)] == 0.f); +} + static void testBakedConstants() { CHECK(kLimiterCeilingDbTp == -0.3); CHECK(std::fabs(limiterCeilingLinear() - std::pow(10.0, -0.3 / 20.0)) < 1e-12); @@ -275,6 +305,7 @@ int main() { testToggleEmitsNoStepLargerThanTheSignalsOwn(); testCrossfadeSettlesToTheExactEngagedAndBypassedPaths(); testGainNeverRisesAboveUnity(); + testAlignmentIdentityHoldsAtTheExactWindowEdge(); testBakedConstants(); if (g_fail) { std::printf("%d FAILURE(S)\n", g_fail); From 6232851c6bfb071c737795b220ed7e731da15ab6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 20:53:36 -0400 Subject: [PATCH 14/56] =?UTF-8?q?=CE=93-W1-T2:=20the=20limiter=20toggle=20?= =?UTF-8?q?is=20a=20mute,=20not=20a=20crossfade=20=E2=80=94=20the=20ceilin?= =?UTF-8?q?g=20holds=20across=20both=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The equal-gain dry/wet blend let a peak through at (1-m) of its level. Now the fade rides only the limited path and the hard edge lands on silence. --- src/core/instrument/CLAUDE.md | 2 +- src/core/instrument/engine/limiter.cpp | 63 +++--- src/core/instrument/engine/limiter.h | 35 ++-- src/shell/instrument/reasampler_processor.cpp | 2 +- tests/test_limiter.cpp | 182 +++++++++++++++--- 5 files changed, 219 insertions(+), 65 deletions(-) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index c9fa2ee..055c9e4 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -292,7 +292,7 @@ anything for a trigger shape. - `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. -- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental; the engage/disengage crossfade is the codebase's standing ramp-every-gain-path-change rule applied to a limiter switching in. +- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle. - `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. ### `map/` diff --git a/src/core/instrument/engine/limiter.cpp b/src/core/instrument/engine/limiter.cpp index 5837ca6..7cd86b2 100644 --- a/src/core/instrument/engine/limiter.cpp +++ b/src/core/instrument/engine/limiter.cpp @@ -36,7 +36,7 @@ void Limiter::prepare(double sampleRate) { ceiling_ = static_cast(limiterCeilingLinear()); const double rate = sampleRate > 0.0 ? sampleRate : 48000.0; releaseCoeff_ = static_cast(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate))); - mixStep_ = static_cast(1.0 / (kLimiterCrossfadeSeconds * rate)); + switchStep_ = static_cast(1.0 / (kLimiterMuteSeconds * rate)); // Windowed-sinc polyphase interpolator, built here because it costs transcendentals. // Phase 0's taps all land on sinc zeros except the centre, so it is an exact delay and is @@ -77,7 +77,7 @@ void Limiter::clearState() { void Limiter::reset() { clearState(); active_ = target_.load(std::memory_order_relaxed); - mix_ = active_ ? 1.f : 0.f; + switchGain_ = active_ ? 1.f : 0.f; primeRemaining_ = 0; } @@ -158,11 +158,13 @@ float Limiter::process(float* left, float* right, int frames) { const bool want = target_.load(std::memory_order_relaxed); if (!want && !active_) return 1.f; // settled bypass: not one sample read or written if (want && !active_) { - // A live engage. Start dry, fill the delay line, then crossfade — so the wet path is - // never silence weighted above zero. + // A live engage. The dry path leaves circuit AT THIS SAMPLE rather than fading out: + // fading it would emit unlimited signal at a partial weight, which is a peak over the + // ceiling. Silence covers the delay line's prime, then the fade-in rides the limited + // path, every sample of which is already under the ceiling. clearState(); active_ = true; - mix_ = 0.f; + switchGain_ = 0.f; primeRemaining_ = latency_; } @@ -183,33 +185,40 @@ float Limiter::process(float* left, float* right, int frames) { if (stereo) delayR_[slot] = dryR; delayPos_ = (delayPos_ + 1 == latency_) ? 0 : delayPos_ + 1; - // The endpoints are branches rather than blend arithmetic so a settled state is exact: - // dry + (wet - dry) * 1.0f is not wet in floating point. At m <= 0 the buffer is left - // untouched, which is the dry sample already in it. - const float m = mix_; - // The reported minimum is the gain actually reaching the output, not the limiter's raw - // target — mid-crossfade only a fraction `m` of the reduction is audible, so the meter - // (whose contract is "smallest gain APPLIED") must blend the same way the signal does: - // unity at m=0, `gain` at m=1, linear between. - const float effectiveGain = 1.f - m + m * gain; - if (effectiveGain < blockMin) blockMin = effectiveGain; - if (m >= 1.f) { + // Settled engaged is a branch rather than `wet * 1.0f` so it is bit-exact. + const float s = switchGain_; + if (s >= 1.f) { left[i] = wetL; if (stereo) right[i] = wetR; - } else if (m > 0.f) { - left[i] = dryL + (wetL - dryL) * m; - if (stereo) right[i] = dryR + (wetR - dryR) * m; - } - - if (primeRemaining_ > 0) { - --primeRemaining_; - } else if (want) { - mix_ = (mix_ + mixStep_ >= 1.f) ? 1.f : mix_ + mixStep_; + } else if (s > 0.f) { + left[i] = wetL * s; + if (stereo) right[i] = wetR * s; } else { - mix_ = (mix_ - mixStep_ <= 0.f) ? 0.f : mix_ - mixStep_; + left[i] = 0.f; + if (stereo) right[i] = 0.f; + } + const float effectiveGain = s >= 1.f ? gain : s * gain; + if (effectiveGain < blockMin) blockMin = effectiveGain; + + // A disengage is tested FIRST so a toggle-off arriving mid-engage abandons the prime + // instead of waiting it out in silence. + if (!want) { + switchGain_ = s - switchStep_; + if (switchGain_ <= 0.f) { + // The disengage completes HERE, sample-accurately: the delay leaves circuit and + // the rest of the block is the dry buffer, untouched. Resuming from silence is + // the accepted discontinuity; fading the dry path back in instead would put + // unlimited signal at a partial weight, which is the leak the ceiling forbids. + switchGain_ = 0.f; + active_ = false; + break; + } + } else if (primeRemaining_ > 0) { + --primeRemaining_; + } else if (s < 1.f) { + switchGain_ = (s + switchStep_ >= 1.f) ? 1.f : s + switchStep_; } } - if (!want && mix_ <= 0.f && primeRemaining_ == 0) active_ = false; return blockMin; } diff --git a/src/core/instrument/engine/limiter.h b/src/core/instrument/engine/limiter.h index 346907d..41aad09 100644 --- a/src/core/instrument/engine/limiter.h +++ b/src/core/instrument/engine/limiter.h @@ -28,10 +28,11 @@ inline constexpr double kLimiterLookaheadSeconds = 0.002; // no-overshoot bound survives it unchanged. inline constexpr double kLimiterReleaseSeconds = 0.100; -// The engage/disengage crossfade. A limiter engaging is a gain-path change and this codebase -// ramps every gain-path change; it also covers the window before the host acts on the latency -// change, which is the plugin's to keep clean because the host schedules that, not us. -inline constexpr double kLimiterCrossfadeSeconds = 0.010; +// The transition mute. Long enough that the fade is not itself an edge and that it dwarfs the +// 2 ms delay-line prime it covers; short enough that the whole muted window (prime + fade) is +// ~12 ms rather than a gap. Linear in amplitude, not equal-power: this fades ONE leg to +// silence, it does not cross two. +inline constexpr double kLimiterMuteSeconds = 0.010; // 4x true-peak oversampling (ITU-R BS.1770's floor at 48 kHz) over an 8-tap-per-phase // polyphase interpolator. The 33-tap prototype's centre tap makes phase 0 an exact 4-sample @@ -53,6 +54,15 @@ int limiterLookaheadSamples(double sampleRate); // then a MOVING AVERAGE of the same width. Every term of that average is a minimum whose own // window contains the sample being gained, so the smoothed gain is <= the target gain at every // sample by construction — the ceiling is held structurally rather than by a tuned attack. +// +// SWITCHING IS A MUTE, NOT A BLEND. Unlimited signal is emitted at weight 1 (settled bypass, +// which is the untouched buffer) or at weight 0, never in between — a fraction of an unlimited +// signal is a peak above the ceiling, which is exactly the leak this design forbids. So the +// FADE always rides the limited path (any weight of it is already under the ceiling, since the +// mute only scales down) and the HARD EDGE always lands on the bypassed side, against silence: +// engaging mutes at once, holds while the delay line primes, then fades the limited path in; +// disengaging fades the limited path out and resumes the dry buffer from silence. That +// discontinuity is accepted; a spike is not. class Limiter { public: // Sizes the delay line, the detector and the smoothers, and snaps to the current enable @@ -60,7 +70,7 @@ public: void prepare(double sampleRate); // Clears the delay line and the detector and snaps to the current enable state, skipping - // the engage crossfade — an activation has nothing sounding to be continuous with. + // the transition mute — an activation has nothing sounding to be continuous with. // Main/UI thread only (the host guarantees process() is stopped at both call sites). void reset(); @@ -70,10 +80,9 @@ public: // Applies the limiter in place over `frames` of `left` (and `right`, which may be null for // a mono buffer). Returns the SMALLEST gain actually applied to the output this block — 1.0 - // for none (a settled bypass, or wherever the engage/disengage crossfade sits at dry). Mid - // crossfade this is the target gain blended by the same fraction `mix_` blends the signal, - // not the limiter's raw target — the two must agree, or the meter over-reports reduction - // that is only partially audible. + // for a settled bypass, 0.0 anywhere the transition mute is at silence. The transition mute + // counts because the contract is the gain that REACHED the output: the reported value and + // the signal are scaled by the same factor, or the meter and the bus disagree. float process(float* left, float* right, int frames); private: @@ -91,7 +100,7 @@ private: int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1 float ceiling_ = 1.f; float releaseCoeff_ = 1.f; - float mixStep_ = 1.f; + float switchStep_ = 1.f; float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay) // --- audio-thread state --- @@ -110,9 +119,9 @@ private: double avgSum_ = 0.0; // double: the running sum is added to and subtracted from forever int avgPos_ = 0; float releaseGain_ = 1.f; - bool active_ = false; // the limiter path is running (engaged, or mid-crossfade) - float mix_ = 0.f; // 0 = dry, 1 = limited - int primeRemaining_ = 0; // samples the crossfade waits on while the delay line fills + bool active_ = false; // the limited path is in circuit (engaged, or still fading out) + float switchGain_ = 0.f; // the transition mute; only ever scales the LIMITED path + int primeRemaining_ = 0; // samples held at silence while the delay line fills }; } // namespace reasampler::instrument::engine diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index 7c173d3..89c65c4 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -97,7 +97,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { reloadInstrument(); // The host performs this deactivate/reactivate whenever it acts on a kLatencyChanged // request, so the limiter starts each activation with an empty delay line and snapped - // to its persisted state — no crossfade, because there is nothing sounding to be + // to its persisted state — no transition mute, because there is nothing sounding to be // continuous with once the block above has destroyed every voice. limiter_.reset(); } else { diff --git a/tests/test_limiter.cpp b/tests/test_limiter.cpp index ed65cd3..44fe03e 100644 --- a/tests/test_limiter.cpp +++ b/tests/test_limiter.cpp @@ -10,7 +10,11 @@ // * the detection is TRUE-peak: a signal whose SAMPLES all clear the ceiling but whose // inter-sample peak does not still engages; // * the gain is stereo-linked, so a dual-mono signal stays centered across a full toggle; -// * the engage/disengage crossfade leaves no step larger than the signal's own. +// * across a toggle in EITHER direction, every output sample is under the ceiling or exactly +// the unlimited input — never a fraction of the unlimited input, which is the leak the +// retired equal-gain crossfade admitted; +// * the transition's only two discontinuities are the hard edges against silence, one per +// direction. #include "../src/core/instrument/engine/limiter.h" @@ -146,7 +150,7 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { bool centered = true; for (std::size_t i = 0; i < l.size(); i += static_cast(block)) { // Toggle on a quarter in and off three quarters in, so the run covers bypassed, - // the engage crossfade, fully engaged, the disengage crossfade, and bypassed again. + // the engage mute, fully engaged, the disengage fade, and bypassed again. if (i >= l.size() / 4 && !lim.enabled()) lim.setEnabled(true); if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); const int n = static_cast( @@ -167,42 +171,172 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { CHECK(worstEngaged > 0.f); } -static void testToggleEmitsNoStepLargerThanTheSignalsOwn() { - // A steady sine: the crossfade blends it with a copy of itself delayed by the lookahead, - // which at 440 Hz is nearly half a cycle out — switching hard instead of fading would step - // by up to twice the amplitude, so this assertion has real teeth. - const double freq = 440.0; - const double amp = 0.5; // under the ceiling: this measures the TRANSITION, not limiting - std::vector x(48000); - for (std::size_t i = 0; i < x.size(); ++i) { - x[i] = static_cast( +static void testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence() { + // Replaces the retired crossfade's "no step larger than the signal's own", which no longer + // describes the design: the mute has exactly ONE hard edge per direction, both against + // silence, and everything between them is continuous. A steady sine well under the + // ceiling, so this measures the TRANSITION and not limiting. 375 Hz is one cycle per 128 + // samples, so a block-aligned toggle lands on a phase the test can state rather than + // inherit — at a zero crossing the engage edge would be small for a reason that has + // nothing to do with the design. + const double freq = 375.0; // kRate / 128 + const double amp = 0.5; + const int block = 32; + const int engageAt = 12064; // block-aligned AND one sample past the sine's peak + const int disengageAt = 36064; + std::vector in(48000); + for (std::size_t i = 0; i < in.size(); ++i) { + in[i] = static_cast( amp * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast(i) / kRate)); } + std::vector y = in; const float naturalStep = static_cast(amp * 2.0 * 3.14159265358979323846 * freq / kRate); + // One fade step's worth of signal: the disengage's last emitted sample sits at most this + // far above zero, because the fade is stepped AFTER the sample it weighted. + const float silenceFloor = + static_cast(amp / (kLimiterMuteSeconds * kRate)) * 1.01f; + CHECK(std::fabs(in[static_cast(engageAt) - 1]) > 0.4f); // the edge has teeth Limiter lim; lim.prepare(kRate); - const int block = 32; - for (std::size_t i = 0; i < x.size(); i += static_cast(block)) { - if (i >= x.size() / 4 && !lim.enabled()) lim.setEnabled(true); - if (i >= (x.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); + for (std::size_t i = 0; i < y.size(); i += static_cast(block)) { + if (static_cast(i) >= engageAt && !lim.enabled()) lim.setEnabled(true); + if (static_cast(i) >= disengageAt && lim.enabled()) lim.setEnabled(false); const int n = static_cast( - std::min(static_cast(block), x.size() - i)); - lim.process(x.data() + i, nullptr, n); + std::min(static_cast(block), y.size() - i)); + lim.process(y.data() + i, nullptr, n); } + + // Engage: the dry path leaves circuit AT the toggle sample, in one step to silence — the + // sample before it is still the untouched dry buffer, never a partial weight of it. + CHECK(y[static_cast(engageAt) - 1] == in[static_cast(engageAt) - 1]); + CHECK(y[static_cast(engageAt)] == 0.f); + + // Disengage: one resume edge, out of near-silence straight into the untouched dry buffer, + // and nothing written after it. + std::size_t lastTouched = 0; + for (std::size_t i = 0; i < y.size(); ++i) { + if (y[i] != in[i]) lastTouched = i; + } + CHECK(static_cast(lastTouched) > disengageAt); + CHECK(std::fabs(y[lastTouched]) <= silenceFloor); + bool dryAfterResume = true; + for (std::size_t i = lastTouched + 1; i < y.size(); ++i) { + if (y[i] != in[i]) { dryAfterResume = false; break; } + } + CHECK(dryAfterResume); + + // Everything BETWEEN the two edges is continuous — both fades and the settled middle. float worstStep = 0.f; - for (std::size_t i = 1; i < x.size(); ++i) { - worstStep = std::max(worstStep, std::fabs(x[i] - x[i - 1])); + for (std::size_t i = static_cast(engageAt) + 1; i <= lastTouched; ++i) { + worstStep = std::max(worstStep, std::fabs(y[i] - y[i - 1])); } CHECK(worstStep <= naturalStep * 1.2f); + // And the run really was muted, so the continuity above is not an untouched buffer's. + bool sawSilenceOverSignal = false; + for (std::size_t i = 0; i < y.size(); ++i) { + if (y[i] == 0.f && std::fabs(in[i]) > 0.4f) { sawSilenceOverSignal = true; break; } + } + CHECK(sawSilenceOverSignal); } -static void testCrossfadeSettlesToTheExactEngagedAndBypassedPaths() { +// The one rule the transition encodes: every output sample is EITHER under the ceiling OR +// exactly the unlimited input. A fraction of the unlimited input is neither, which is why the +// retired equal-gain crossfade could pass a peak over the ceiling mid-transition. +static bool underCeilingOrExactlyDry(float y, float x, float ceiling) { + return std::fabs(y) <= ceiling * (1.f + 1e-6f) || y == x; +} + +static void testUnlimitedSignalIsNeverEmittedAtAPartialWeight() { + const float ceiling = static_cast(limiterCeilingLinear()); + // +12 dB over the ceiling for the WHOLE run, so the transition windows are driven, not + // merely crossed while quiet. + const std::vector in = pattern(48000, ceiling * 3.98f); + std::vector y = in; + + Limiter lim; + lim.prepare(kRate); + const int block = 64; + for (std::size_t i = 0; i < y.size(); i += static_cast(block)) { + if (i >= y.size() / 4 && !lim.enabled()) lim.setEnabled(true); // engage + if (i >= (y.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); // disengage + const int n = static_cast( + std::min(static_cast(block), y.size() - i)); + lim.process(y.data() + i, nullptr, n); + } + + bool held = true; + bool sawLimited = false, sawMuted = false, sawDry = false; + for (std::size_t i = 0; i < y.size(); ++i) { + if (!underCeilingOrExactlyDry(y[i], in[i], ceiling)) { held = false; break; } + if (y[i] != in[i] && y[i] != 0.f) sawLimited = true; + if (y[i] == 0.f && std::fabs(in[i]) > ceiling) sawMuted = true; + if (y[i] == in[i] && std::fabs(in[i]) > ceiling) sawDry = true; + } + CHECK(held); + // Each of the three states the rule distinguishes actually occurred, so `held` is not + // satisfied by a buffer that was only ever passed through. + CHECK(sawLimited); + CHECK(sawMuted); + CHECK(sawDry); +} + +static void testALoudTransientInFlightAtTheToggleCannotSpike() { + // The toggle flipped while a transient 18 dB over the ceiling is in flight, swept across + // the whole transition window (the 2 ms prime, the 10 ms fade, and past both) in each + // direction. Nothing anywhere may land between silence and the unlimited input. + const float ceiling = static_cast(limiterCeilingLinear()); + const int latency = limiterLookaheadSamples(kRate); + const int fade = static_cast(kLimiterMuteSeconds * kRate); + const int block = 32; + const int toggleAt = 3200; // a block boundary + const int offsets[] = {0, 1, latency - 1, latency, latency + 1, fade / 2, + fade, fade + latency, fade + 4 * latency}; + + for (bool engaging : {true, false}) { + for (int offset : offsets) { + std::vector in( + static_cast(toggleAt + 2 * fade + 8 * latency), 0.f); + in[static_cast(toggleAt + offset)] = ceiling * 8.f; + std::vector y = in; + + Limiter lim; + lim.setEnabled(!engaging); + lim.prepare(kRate); // prepare snaps to the target: the run starts settled + for (std::size_t i = 0; i < y.size(); i += static_cast(block)) { + if (static_cast(i) >= toggleAt) lim.setEnabled(engaging); + const int n = static_cast( + std::min(static_cast(block), y.size() - i)); + lim.process(y.data() + i, nullptr, n); + } + + bool held = true; + float loudestLimited = 0.f; + for (std::size_t i = 0; i < y.size(); ++i) { + if (!underCeilingOrExactlyDry(y[i], in[i], ceiling)) { held = false; break; } + if (y[i] != in[i]) loudestLimited = std::max(loudestLimited, std::fabs(y[i])); + } + CHECK(held); + // The transient reached the LIMITED path rather than being muted away entirely, + // so `held` above is not satisfied by silence. The qualifying offset differs by + // direction because the fade opens at the end of an engage and closes at the + // start of a disengage. + if (engaging && offset >= fade + latency) { + CHECK(loudestLimited > ceiling * 0.9f); + } + if (!engaging && offset == 0) CHECK(loudestLimited > ceiling * 0.5f); + } + } +} + +static void testTransitionSettlesToTheExactEngagedAndBypassedPaths() { Limiter lim; lim.prepare(kRate); const int latency = limiterLookaheadSamples(kRate); - const int settle = static_cast(kLimiterCrossfadeSeconds * kRate) + latency + 64; + // The engage costs a `latency`-sample prime, then the fade, then the delay itself. + const int settle = + static_cast(kLimiterMuteSeconds * kRate) + 2 * latency + 64; const std::vector src = pattern(4 * settle, 0.3f); // under the ceiling throughout std::vector y = src; @@ -302,8 +436,10 @@ int main() { testEngagedHoldsTheCeilingOnProgramTwelveDbOver(); testTruePeakDetectionEngagesWhereSamplePeakWouldNot(); testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle(); - testToggleEmitsNoStepLargerThanTheSignalsOwn(); - testCrossfadeSettlesToTheExactEngagedAndBypassedPaths(); + testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence(); + testUnlimitedSignalIsNeverEmittedAtAPartialWeight(); + testALoudTransientInFlightAtTheToggleCannotSpike(); + testTransitionSettlesToTheExactEngagedAndBypassedPaths(); testGainNeverRisesAboveUnity(); testAlignmentIdentityHoldsAtTheExactWindowEdge(); testBakedConstants(); From 91c1b78d5e5a4b6f02a1f050935a3ef4b049bd64 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 21:08:13 -0400 Subject: [PATCH 15/56] =?UTF-8?q?=CE=93-W1-T2:=20the=20published=20GR=20me?= =?UTF-8?q?ter=20reads=20the=20limiter,=20not=20the=20mute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the effectiveGain blend so the meter's minimum tracks smoothGain's own reduction against real input, unscaled by the transition mute — a toggle over quiet material now reads no reduction instead of pinning to 0. --- src/core/instrument/engine/limiter.cpp | 6 ++- src/core/instrument/engine/limiter.h | 10 +++-- src/shell/instrument/reasampler_processor.h | 5 ++- tests/test_limiter.cpp | 47 ++++++++++++++++++++- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/core/instrument/engine/limiter.cpp b/src/core/instrument/engine/limiter.cpp index 7cd86b2..0a1dd27 100644 --- a/src/core/instrument/engine/limiter.cpp +++ b/src/core/instrument/engine/limiter.cpp @@ -197,8 +197,10 @@ float Limiter::process(float* left, float* right, int frames) { left[i] = 0.f; if (stereo) right[i] = 0.f; } - const float effectiveGain = s >= 1.f ? gain : s * gain; - if (effectiveGain < blockMin) blockMin = effectiveGain; + // `gain` is the limiter's own reduction, computed from the real input this sample + // whether or not the mute is currently scaling it toward silence — publishing it + // unscaled is what lets the meter show "really limiting" and not "just muting". + if (gain < blockMin) blockMin = gain; // A disengage is tested FIRST so a toggle-off arriving mid-engage abandons the prime // instead of waiting it out in silence. diff --git a/src/core/instrument/engine/limiter.h b/src/core/instrument/engine/limiter.h index 41aad09..4bbabbe 100644 --- a/src/core/instrument/engine/limiter.h +++ b/src/core/instrument/engine/limiter.h @@ -79,10 +79,12 @@ public: bool enabled() const { return target_.load(std::memory_order_relaxed); } // Applies the limiter in place over `frames` of `left` (and `right`, which may be null for - // a mono buffer). Returns the SMALLEST gain actually applied to the output this block — 1.0 - // for a settled bypass, 0.0 anywhere the transition mute is at silence. The transition mute - // counts because the contract is the gain that REACHED the output: the reported value and - // the signal are scaled by the same factor, or the meter and the bus disagree. + // a mono buffer). Returns the SMALLEST gain the LIMITER ITSELF computed this block — + // smoothGain's output against the real input, at every sample including a muted one — NOT + // scaled by the transition mute. The mute is a switch, not limiting: scaling by it would + // report 0.0 (full reduction) on every toggle regardless of program content, which is a + // meter defect, not a fact about the bus. 1.0 means no detected peak exceeded the ceiling, + // whether settled bypassed or mid-mute over quiet material. float process(float* left, float* right, int frames); private: diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index c29ac2d..1154e8e 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -40,7 +40,10 @@ class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInte struct MasterBusMeter { float peakL = 0.f; // max |x| this block float peakR = 0.f; - float minGain = 1.f; // smallest limiter gain applied this block; 1 = no reduction + // Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT + // scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction) + // rather than the mute's own weight. 1 = no reduction. + float minGain = 1.f; bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it }; diff --git a/tests/test_limiter.cpp b/tests/test_limiter.cpp index 44fe03e..7628a30 100644 --- a/tests/test_limiter.cpp +++ b/tests/test_limiter.cpp @@ -14,7 +14,9 @@ // the unlimited input — never a fraction of the unlimited input, which is the leak the // retired equal-gain crossfade admitted; // * the transition's only two discontinuities are the hard edges against silence, one per -// direction. +// direction; +// * the published minimum tracks the limiter's own reduction, not the mute weight: a toggle +// over content that never crosses the ceiling publishes exactly 1.0 all the way through. #include "../src/core/instrument/engine/limiter.h" @@ -143,11 +145,19 @@ static void testTruePeakDetectionEngagesWhereSamplePeakWouldNot() { static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { Limiter lim; lim.prepare(kRate); + const int latency = limiterLookaheadSamples(kRate); const float ceiling = static_cast(limiterCeilingLinear()); const std::vector src = pattern(48000, ceiling * 2.5f); std::vector l = src, r = src; // dual mono: L and R are the same signal const int block = 64; bool centered = true; + // The prime+fade window right after the engage point: the published minimum here is the + // case Daniel named — it must read the limiter's own reduction on this loud program, not + // the mute weight (which would read exactly 0 through the prime, old contract). + const std::size_t engageAt = l.size() / 4; + const std::size_t muteWindowEnd = engageAt + static_cast(latency) + + static_cast(kLimiterMuteSeconds * kRate); + float minGainDuringMute = 1.f; for (std::size_t i = 0; i < l.size(); i += static_cast(block)) { // Toggle on a quarter in and off three quarters in, so the run covers bypassed, // the engage mute, fully engaged, the disengage fade, and bypassed again. @@ -155,7 +165,8 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); const int n = static_cast( std::min(static_cast(block), l.size() - i)); - lim.process(l.data() + i, r.data() + i, n); + const float g = lim.process(l.data() + i, r.data() + i, n); + if (i >= engageAt && i < muteWindowEnd && g < minGainDuringMute) minGainDuringMute = g; } for (std::size_t i = 0; i < l.size(); ++i) { if (l[i] != r[i]) { centered = false; break; } @@ -169,6 +180,37 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { } CHECK(worstEngaged <= ceiling * (1.f + 1e-6f)); CHECK(worstEngaged > 0.f); + CHECK(minGainDuringMute > 0.f); // never the mute's own zero weight + CHECK(minGainDuringMute < 1.f); // and it really is reduction, not a no-op read +} + +static void testToggleWithNothingOverCeilingPublishesNoReduction() { + // Daniel's ruling: only show GR when it's really limiting, not just muting. Content that + // never exceeds the ceiling must publish exactly 1.0 through the WHOLE transition — the + // prime, both fades, and the settled stretches — because the old effectiveGain contract + // read 0.0 through the mute regardless of content. + Limiter lim; + lim.prepare(kRate); + const float ceiling = static_cast(limiterCeilingLinear()); + const std::vector src = pattern(48000, ceiling * 0.5f); // comfortably under, always + std::vector y = src; + const int block = 64; + float minGain = 1.f; + for (std::size_t i = 0; i < y.size(); i += static_cast(block)) { + if (i >= y.size() / 4 && !lim.enabled()) lim.setEnabled(true); + if (i >= (y.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); + const int n = static_cast( + std::min(static_cast(block), y.size() - i)); + const float g = lim.process(y.data() + i, nullptr, n); + if (g < minGain) minGain = g; + } + CHECK(minGain == 1.f); + // And the run really did mute, so `minGain == 1.f` is not vacuous over an untouched buffer. + bool sawSilenceOverSignal = false; + for (std::size_t i = 0; i < y.size(); ++i) { + if (y[i] == 0.f && std::fabs(src[i]) > 0.1f) { sawSilenceOverSignal = true; break; } + } + CHECK(sawSilenceOverSignal); } static void testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence() { @@ -436,6 +478,7 @@ int main() { testEngagedHoldsTheCeilingOnProgramTwelveDbOver(); testTruePeakDetectionEngagesWhereSamplePeakWouldNot(); testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle(); + testToggleWithNothingOverCeilingPublishesNoReduction(); testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence(); testUnlimitedSignalIsNeverEmittedAtAPartialWeight(); testALoudTransientInFlightAtTheToggleCannotSpike(); From 93230208ff9669b33c9d0f44be536d626c40ac3c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 23:19:16 -0400 Subject: [PATCH 16/56] =?UTF-8?q?=CE=93-W1-T7:=20make=20Preserve's=20splic?= =?UTF-8?q?es=20pitch-synchronous=20=E2=80=94=20the=20jump=20is=20a=20whol?= =?UTF-8?q?e=20number=20of=20the=20source's=20own=20period,=20detected=20o?= =?UTF-8?q?nce=20at=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 30 Hz out-of-band energy 15.45% -> 0.00%; the 29 Hz rate-2.0 detune -133 -> +0 cents. An unknown period keeps the fixed-window geometry bit for bit. The detector cannot reach process(): sampler_core does not link it. --- src/core/instrument/CLAUDE.md | 12 + src/core/instrument/engine/CMakeLists.txt | 11 +- src/core/instrument/engine/period_detect.cpp | 203 ++++++++++++++ src/core/instrument/engine/period_detect.h | 54 ++++ src/core/instrument/engine/pitch_shift.cpp | 47 +++- src/core/instrument/engine/pitch_shift.h | 38 ++- src/core/instrument/engine/play_params.h | 8 + src/core/instrument/engine/time_stretch.h | 35 ++- src/core/instrument/engine/voice.cpp | 6 + src/core/instrument/map/CMakeLists.txt | 3 +- src/core/instrument/map/sample_map.cpp | 7 + tests/energy_outside_fundamental.h | 50 ---- tests/test_period_detect.cpp | 266 +++++++++++++++++++ tests/test_pitch_shift.cpp | 236 +++++++++++++++- tests/test_preserve_low_frequency.cpp | 120 +++++---- tests/test_sampler_core.cpp | 33 ++- tests/tone_metrics.h | 93 +++++++ 17 files changed, 1085 insertions(+), 137 deletions(-) create mode 100644 src/core/instrument/engine/period_detect.cpp create mode 100644 src/core/instrument/engine/period_detect.h delete mode 100644 tests/energy_outside_fundamental.h create mode 100644 tests/test_period_detect.cpp create mode 100644 tests/tone_metrics.h diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 055c9e4..e4d5757 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -289,6 +289,18 @@ anything for a trigger shape. - `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter AND time-stretcher for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. - **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher** — `writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids. + - **Splices are PITCH-SYNCHRONOUS when the source's period is known** (`setSourcePeriod`, fed from `period_detect` via the loader): the nominal jump becomes the multiple of that period nearest the window that still fits the ring's jump bound (~1.25 windows), so an aligned landing point sits at the CENTRE of the correlation search instead of possibly not existing inside it at all. The search is unchanged and still earns its keep — it absorbs the jump's rounding to whole frames and tracks a source whose period drifts. **An unknown period restores the fixed-window geometry byte for byte**; do not "simplify" that fallback into an approximation of it. +- `period_detect` — the source's own fundamental period, estimated ONCE per load (two-pass YIN: + a decimated cumulative-mean-normalized difference picks the period, the full-rate difference + function refines it to a fraction of a frame), so `pitch_shift`'s splice jump can be a whole + number of it. **It runs off the audio thread BY LINK GRAPH: `sampler_core` does not link it**, + so no TU on the render path can name `detectPeriod` — the same shape as the extension's link + graph not gaining the voice engine. Its one caller is the loader (`map/sample_map`'s + `buildSampleData`), which hands the answer down on `SampleData::sourcePeriodFrames`. A period + is DERIVED from the audio, so it is cache and not state: nothing persists it, and it takes no + rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony, + percussion and a source whose period changes mid-sample all return it, and the shifter's + fixed-window geometry is the documented fallback. - `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 96e4d39..5bb6a60 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -5,6 +5,13 @@ reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks) # specifically the compile-time proof it does not drag in the WDL chain. reasampler_test(pitch_shift LINK pitch_shift) +# Deliberately NOT linked by sampler_core, and that omission is the structural proof the +# detector cannot run on the audio thread: no TU on the render path can name detectPeriod +# without failing to link in sampler_core_tests, which links sampler_core and nothing else. +# Its one caller is the loader (map/sample_map), which runs off-thread by construction. +reasampler_pure_library(period_detect SOURCES period_detect.cpp LINK PUBLIC peaks) +reasampler_test(period_detect LINK period_detect) + reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp) # Links only velocity_curve, deliberately not editor_geometry: the proof the engine can # depend on the curve without inheriting the editor's layout types. @@ -58,7 +65,9 @@ reasampler_test(staged_envelopes LINK sampler_core) # Release, when the question is what Preserve does to a given frequency. add_executable(preserve_low_frequency_tests ${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp) -target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core) +# period_detect beside sampler_core, not through it: the harness plays the role the loader +# does, which is exactly the seam under measurement. +target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect) # The Preserve read's source-feed schedule — the TIME half beside pitch_shift's PITCH half. # Header-only (it sits on the per-sample feed), hence INTERFACE. diff --git a/src/core/instrument/engine/period_detect.cpp b/src/core/instrument/engine/period_detect.cpp new file mode 100644 index 0000000..266e414 --- /dev/null +++ b/src/core/instrument/engine/period_detect.cpp @@ -0,0 +1,203 @@ +// period_detect — pure implementation. See period_detect.h for the contract. +// +// YIN (de Cheveigne & Kawahara 2002), two-pass: a cumulative-mean-normalized difference +// function on a 4x box-decimated copy picks the period, then the raw difference function at +// full rate refines it to a fraction of a frame. The decimated pass is what makes the cost +// bounded; the full-rate pass is what makes the estimate precise enough to multiply — the +// splice jump is n periods, so an error of e frames lands as n*e frames of misalignment. +// +// Hand-rolled rather than autocorrelation-with-an-FFT: no third-party dependency, and the +// difference function's absolute threshold is what lets "no period here" be a real answer. + +#include "core/instrument/engine/period_detect.h" + +#include +#include +#include + +namespace reasampler::instrument::engine { + +namespace { + +constexpr int kDecimate = 4; +// Below this RMS a block carries no signal to find a period in; its difference function is +// numerically degenerate rather than merely inconclusive. +constexpr double kSilenceRms = 1e-5; + +// Box-decimate `src[from, from+count)` by kDecimate. The averaging is the anti-alias filter: +// a plain stride would fold high partials onto the low lags the coarse pass searches. +std::vector decimate(const std::vector& src, std::size_t from, + std::size_t count) { + std::vector out(count / kDecimate); + for (std::size_t i = 0; i < out.size(); ++i) { + double s = 0.0; + for (int k = 0; k < kDecimate; ++k) { + s += static_cast(src[from + i * kDecimate + static_cast(k)]); + } + out[i] = s / kDecimate; + } + return out; +} + +// The cumulative-mean-normalized difference d'(tau) over lags [1, lagHi], analysis window W: +// d(tau) = sum_{j cmndf(const std::vector& x, std::size_t W, std::size_t lagHi) { + std::vector dp(lagHi + 1, 1.0); + double running = 0.0; + for (std::size_t tau = 1; tau <= lagHi; ++tau) { + double d = 0.0; + for (std::size_t j = 0; j < W; ++j) { + const double diff = x[j] - x[j + tau]; + d += diff * diff; + } + running += d; + dp[tau] = running > 0.0 ? d * static_cast(tau) / running : 1.0; + } + return dp; +} + +// Parabolic vertex through (i-1, i, i+1) as an offset in [-0.5, 0.5] from i. Zero at an end +// point or a non-minimum, which leaves the integer lag — benign, and the full-rate pass +// refines it again anyway. +double parabolicOffset(const std::vector& y, std::size_t i) { + if (i == 0 || i + 1 >= y.size()) return 0.0; + const double den = y[i - 1] - 2.0 * y[i] + y[i + 1]; + if (!(den > 0.0)) return 0.0; // a minimum has positive curvature + double f = 0.5 * (y[i - 1] - y[i + 1]) / den; + if (f > 0.5) f = 0.5; + if (f < -0.5) f = -0.5; + return f; +} + +// YIN's absolute-threshold rule: take the FIRST dip below the threshold, walked down to its +// local bottom — not the global minimum. A periodic signal dips at every multiple of its +// period, so the global minimum is as likely to be 2P or 3P; taking the first dip is what +// makes the answer the fundamental period rather than some harmonic of it. +bool pickPeriod(const std::vector& dp, std::size_t lagLo, double& tauOut, + double& dissimilarity) { + for (std::size_t tau = lagLo; tau + 1 < dp.size(); ++tau) { + if (dp[tau] >= kPeriodDetectThreshold) continue; + std::size_t t = tau; + while (t + 1 < dp.size() && dp[t + 1] < dp[t]) ++t; + tauOut = static_cast(t) + parabolicOffset(dp, t); + dissimilarity = dp[t]; + return true; + } + return false; +} + +// The raw difference function over [lo, hi] at FULL rate, minimized parabolically. The coarse +// pass already chose which dip; this only says exactly where its bottom is. Amplitude drift +// over the few frames spanned here is negligible, so the unnormalized d() suffices. +double refineFullRate(const std::vector& pcm, std::size_t from, std::size_t W, + std::size_t lo, std::size_t hi) { + std::vector d(hi - lo + 1, 0.0); + for (std::size_t tau = lo; tau <= hi; ++tau) { + double s = 0.0; + for (std::size_t j = 0; j < W; ++j) { + const double diff = static_cast(pcm[from + j]) - + static_cast(pcm[from + j + tau]); + s += diff * diff; + } + d[tau - lo] = s; + } + const std::size_t best = + static_cast(std::min_element(d.begin(), d.end()) - d.begin()); + return static_cast(lo + best) + parabolicOffset(d, best); +} + +double blockRms(const std::vector& pcm, std::size_t from, std::size_t count) { + double e = 0.0; + for (std::size_t i = 0; i < count; ++i) { + const double x = static_cast(pcm[from + i]); + e += x * x; + } + return std::sqrt(e / static_cast(count)); +} + +} // namespace + +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) { + if (sampleRate <= 0 || pcm.empty()) return {}; + const double rate = static_cast(sampleRate); + std::size_t lagHi = static_cast(rate / kPeriodDetectMinHz); + const std::size_t lagLo = static_cast(rate / kPeriodDetectMaxHz); + if (lagLo < 2) return {}; // a rate so low the whole search band collapses + + // One probe block is W + lagHi frames with W == lagHi (YIN's usual sizing: the analysis + // window must cover the longest lag being tested). A short sample shortens the search + // rather than refusing outright — a 200 ms one-shot still has a period worth finding. + if (pcm.size() < 2 * lagHi) lagHi = pcm.size() / 2; + if (lagHi <= lagLo + 2) return {}; + const std::size_t block = 2 * lagHi; + const std::size_t probes = + std::min(kPeriodDetectProbes, std::max(1, pcm.size() / block)); + // Room to spare after the last probe's block is spread between them, so the probes sample + // the whole sample rather than only its opening. + const std::size_t stride = probes > 1 ? (pcm.size() - block) / (probes - 1) : 0; + + std::vector periods; + std::vector confidences; + for (std::size_t p = 0; p < probes; ++p) { + const std::size_t from = p * stride; + if (from + block > pcm.size()) break; + if (blockRms(pcm, from, block) < kSilenceRms) continue; + + const std::vector small = decimate(pcm, from, block); + const std::size_t smallHi = lagHi / kDecimate; + const std::size_t smallW = small.size() - smallHi; + if (smallHi <= lagLo / kDecimate + 2 || smallW == 0) continue; + const std::vector dp = cmndf(small, smallW, smallHi); + + double coarseTau = 0.0, dissimilarity = 1.0; + if (!pickPeriod(dp, std::max(2, lagLo / kDecimate), coarseTau, + dissimilarity)) { + continue; // no dip below threshold: this block has no single period + } + + // Bracket the full-rate refinement at +/- 2 decimated samples around the coarse pick: + // the decimated parabola is already sub-decimated-sample accurate, so this is margin, + // not a second search. + const double centre = coarseTau * kDecimate; + const std::size_t lo = static_cast( + std::max(static_cast(lagLo), centre - 2.0 * kDecimate)); + const std::size_t hi = static_cast( + std::min(static_cast(lagHi), centre + 2.0 * kDecimate)); + if (hi <= lo) continue; + periods.push_back(refineFullRate(pcm, from, block - hi, lo, hi)); + confidences.push_back(1.0 - dissimilarity); + } + + if (periods.empty()) return {}; + + std::vector sorted = periods; + std::sort(sorted.begin(), sorted.end()); + const double median = sorted[sorted.size() / 2]; + + // Average the probes that agree with the median rather than taking the median outright: + // averaging cancels each probe's own estimation jitter, and the jump multiplies whatever + // error survives by n. + double sum = 0.0, confSum = 0.0; + std::size_t agree = 0; + for (std::size_t i = 0; i < periods.size(); ++i) { + if (std::fabs(periods[i] - median) > kPeriodDetectAgreeTolerance * median) continue; + sum += periods[i]; + confSum += confidences[i]; + ++agree; + } + // A STRICT MAJORITY of the valid probes must agree, not merely two of them: a source whose + // first half is one period and second half another gives two probes each way, and taking + // either as "the" period would misalign every splice in the other half. Refusing is the + // right answer there — the fixed-window fallback is what a source with no ONE period gets. + if (agree * 2 <= periods.size()) return {}; + + PeriodEstimate est; + est.frames = sum / static_cast(agree); + est.confidence = confSum / static_cast(agree); + return est; +} + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/period_detect.h b/src/core/instrument/engine/period_detect.h new file mode 100644 index 0000000..515c0ff --- /dev/null +++ b/src/core/instrument/engine/period_detect.h @@ -0,0 +1,54 @@ +#pragma once +// period_detect — the source's own fundamental period, estimated ONCE per load from decoded +// PCM, for the Preserve splice's pitch-synchronous jump (pitch_shift.h's periodAlignedJump). +// +// Runs off the audio thread BY LINK GRAPH: sampler_core does not link this module, so no +// translation unit on the render path can name detectPeriod. A sampler's source is fixed and +// fully known at load, which is the whole reason a detector is affordable here at all. + +#include +#include + +#include "core/audio/peaks.h" // AudioSample (float) + +namespace reasampler::instrument::engine { + +using audio::AudioSample; + +// The period the source repeats at, in SOURCE frames, or none. Derived from the audio, never +// authored and never persisted — this is a cache, not state. +struct PeriodEstimate { + double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise) + double confidence = 0.0; // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0 + + bool valid() const { return frames > 0.0; } +}; + +// Fundamental bounds the search runs over. The LOW bound is the load-bearing one: a period +// only buys anything while it fits the splice's reachable jump (~1.25 windows, i.e. ~16 Hz at +// the product's 50 ms window), so searching below it would return periods the shifter must +// reject anyway. The high bound is generous — a period that short already has dozens of +// aligned landing points inside the search interval, so alignment was never in question there. +inline constexpr double kPeriodDetectMinHz = 15.0; +inline constexpr double kPeriodDetectMaxHz = 2000.0; + +// YIN's absolute threshold: the first dissimilarity dip below this IS the period. A source +// that never dips below it has no single period, and detection returns none rather than the +// global minimum — the difference between "quiet but real" and "the least bad of nothing". +inline constexpr double kPeriodDetectThreshold = 0.12; + +// How many blocks across the sample are estimated independently, and how far apart two of them +// may land and still be called the same period. Agreement is what separates a genuinely +// periodic source from one whose opening happens to look periodic. +inline constexpr int kPeriodDetectProbes = 4; +inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median + +// Estimates `pcm`'s fundamental period at `sampleRate`. Cost is bounded by the constants above, +// not by the sample length: at most kPeriodDetectProbes blocks of ~2 x the longest searched lag +// are analysed however long the source is. Allocates; never call from process(). +// +// Returns an invalid estimate (frames == 0) for silence, noise, and anything whose probes +// disagree — the caller's documented fallback is the fixed-window splice geometry. +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate); + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp index 39db550..34f46e4 100644 --- a/src/core/instrument/engine/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -4,8 +4,9 @@ // frame the caller feeds; the active read tap advances by the shift `ratio_` per OUTPUT frame, // so its delay behind the writer drifts at (feedRate - ratio) per frame — one frame in, one // frame out (`feedRate == 1`) preserves duration, and any other feed cadence stretches it. When -// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of -// one window — clamped to the filled span so it never lands in unwritten silence — refined +// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump (one +// window, or the nearest whole number of source periods to it once setSourcePeriod names one) +// — clamped to the filled span so it never lands in unwritten silence — refined // by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a // sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the // splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames @@ -26,6 +27,23 @@ constexpr double kPi = 3.14159265358979323846; } // namespace +std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames, + double periodFrames) { + if (windowFrames <= 1 || maxJumpFrames < 1) return windowFrames; + if (!(periodFrames > 0.0)) return windowFrames; + if (periodFrames > static_cast(maxJumpFrames)) return windowFrames; + std::int64_t n = static_cast( + static_cast(windowFrames) / periodFrames + 0.5); + if (n < 1) n = 1; + std::int64_t jump = static_cast(periodFrames * static_cast(n) + 0.5); + while (jump > maxJumpFrames && n > 1) { + --n; + jump = static_cast(periodFrames * static_cast(n) + 0.5); + } + if (jump < 1 || jump > maxJumpFrames) return windowFrames; + return jump; +} + void PitchShifter::configure(std::int64_t windowFrames) { window_ = windowFrames; if (window_ <= 1) { @@ -37,6 +55,8 @@ void PitchShifter::configure(std::int64_t windowFrames) { fading_ = false; fadePos_ = 0; fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0; + period_ = 0.0; + jump_ = jumpMax_ = 0; filled_ = 0; ratio_ = 1.0; feedRate_ = 1.0; @@ -62,6 +82,9 @@ void PitchShifter::configure(std::int64_t windowFrames) { dLow_ = window_ / 4; dHigh_ = ringLen_ - window_ / 4; corrFrames_ = std::max(1, std::min(dLow_ - 1, 512)); + // The delay band is (dHigh_ - dLow_) wide and the search can add up to maxLag_ on either + // side; one frame more than that and a jump could land exactly ON a trigger boundary. + jumpMax_ = std::max(1, dHigh_ - dLow_ - maxLag_ - 1); fadeLen_ = 0; reset(); } @@ -88,10 +111,17 @@ void PitchShifter::reset() { filled_ = 0; ratio_ = 1.0; feedRate_ = 1.0; + period_ = 0.0; + jump_ = window_ > 1 ? window_ : 0; tailFrozen_ = false; lastSplice_ = SpliceEvent{}; } +void PitchShifter::setSourcePeriod(double periodFrames) { + period_ = periodFrames > 0.0 ? periodFrames : 0.0; + jump_ = window_ > 1 ? periodAlignedJump(window_, jumpMax_, period_) : 0; +} + void PitchShifter::freezeTail() { if (window_ <= 1 || tailFrozen_) return; tailFrozen_ = true; @@ -199,7 +229,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { std::int64_t jump = nominalJump; if (jump > 0) { const std::int64_t maxJump = filled_ - d - maxLag_ - 1; - if (jump > maxJump) jump = maxJump; + // Shortening a period-aligned jump to fit must land on a SHORTER MULTIPLE, not on the + // raw bound — a clamped jump is an unaligned one, which is the whole failure this + // module now avoids. With no period known (or none fitting) this is the bare clamp. + if (jump > maxJump) jump = periodAlignedJump(maxJump, maxJump, period_); if (jump < 1) jump = 1; } // The correlation reference reads FORWARD from the tap; keep it strictly behind the @@ -379,9 +412,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, while (d < 0.0) d += len; while (d >= len) d -= len; if (d <= static_cast(dLow_)) { - splice(+window_, d); + splice(+jump_, d); } else if (d >= static_cast(dHigh_)) { - splice(-window_, d); + splice(-jump_, d); } } } else { @@ -394,9 +427,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, while (d < 0.0) d += len; while (d >= len) d -= len; if (d <= static_cast(dLow_)) { - splice(+window_, d); + splice(+jump_, d); } else if (d >= static_cast(dHigh_)) { - splice(-window_, d); + splice(-jump_, d); } } diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index 832a181..72ae0fb 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -2,8 +2,10 @@ // pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core). // Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap // chases the write head at the shift ratio; when it drifts out of its safe delay band it is -// relocated by a nominal window jump, refined by a cross-correlation search so the new read -// point is waveform-aligned, then old/new taps crossfade (raised-cosine). +// relocated by a nominal jump, refined by a cross-correlation search so the new read point is +// waveform-aligned, then old/new taps crossfade (raised-cosine). The nominal jump is a whole +// number of the SOURCE's own periods when setSourcePeriod names one (pitch-synchronous OLA), +// and the fixed window otherwise. // // The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio = // pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via @@ -61,6 +63,21 @@ struct SpliceEvent { std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen }; +// The nominal splice jump for a source whose period is known: the multiple of `periodFrames` +// nearest `windowFrames` that still fits `maxJumpFrames`. Falls back to `windowFrames` — the +// pre-PSOLA geometry, exactly — whenever the period is unknown (<= 0) or too long for even one +// whole period to fit, which is the documented degradation for inharmonic, polyphonic, +// percussive and noise sources. +// +// Why this is the whole fix: a splice can only phase-align on a landing point that is a whole +// number of source periods away, and the correlation search only reaches [0.75, 1.25] windows. +// Periods with no multiple in that one interval — f < ~16 Hz, and 26.7-32 Hz at a 50 ms +// window — could never align, however good the search was. Making the NOMINAL a multiple puts +// an aligned point at the centre of the search rather than hoping one falls inside it. The +// jump is rounded to whole frames; the search's own sub-sample refinement absorbs the residue. +std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames, + double periodFrames); + // A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked: // channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the // two rings stay sample-aligned. @@ -101,6 +118,17 @@ public: // Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit. void setFeedRate(double rate); + // The period of the source being fed, in SOURCE frames, making every splice jump a whole + // number of it (see periodAlignedJump). <= 0 means "unknown" and restores the fixed-window + // geometry byte for byte — the default, so a caller that never calls this sees no change. + // Detection itself is off-thread and elsewhere (period_detect, which the engine deliberately + // does not link); this is a couple of divisions and is safe to call at note-on. + // Cleared by configure()/reset(); NOT by prime()/warm(), which do not change the source. + void setSourcePeriod(double periodFrames); + + // The nominal jump splices currently use — window() unless a source period narrowed it. + std::int64_t spliceJump() const { return jump_; } + // Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the // pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise // writes `in` at the write head, reads the active tap (crossfading against the outgoing tap @@ -180,6 +208,12 @@ private: // ratio-scaled at splice time so an up-shift's outgoing // tap can never drain into the writer mid-fade std::int64_t maxLag_ = 0; // correlation search half-range (window_/4) + double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window) + std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ narrows it + std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY + // inside [dLow_, dHigh_] at the worst search lag, so a + // period-sized jump can never land back on a trigger and + // thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_) std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so // the reference read forward from the tap stays behind // the writer by construction at an up-splice) diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index 1ba35f1..6fd2af8 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -264,6 +264,14 @@ struct SampleData { // (never per frame). Default flat y=1 — every velocity plays at unity. VelocityCurve velocityCurve = VelocityCurve::flat(); + // The source's own fundamental period in SOURCE frames, which makes Preserve's splices + // pitch-synchronous (pitch_shift.h). DERIVED from the PCM at load, not authored and never + // persisted — a cache, not state, so it takes no rung of the payload ladder. 0 means + // unknown (nothing detected it, or the source has no single period) and restores the + // fixed-window splice geometry byte for byte, which is why a hand-built SampleData is + // still exactly the bare engine. + double sourcePeriodFrames = 0.0; + PlayParams play; // The live-parameter block a sounding voice tracks, or null for the bare latched engine diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index c661dae..d259fd8 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -32,20 +32,27 @@ namespace reasampler::instrument::engine { // actually fine. (The pre-stretch rate-1.0 engine's floor by the same inequality is P > 735, // ~60 Hz — what this range raises the floor from, not what it removes.) // -// A SECOND, INDEPENDENT limit binds the same material, and no rate bound touches it. A splice -// relocates the tap by the nominal window refined by a search over +/- window/4, so the -// reachable relocation distances are exactly [0.75, 1.25] * window; a phase-aligned splice -// needs a WHOLE NUMBER of source periods inside that one interval. The interval is 0.5*window -// wide, so any period <= window/2 always has a multiple in it — but above that, coverage -// breaks into disjoint bands (n=1 covers periods [0.75, 1.25]*window, n=2 covers -// [0.375, 0.625]*window) and the gap between them is reachable by nothing. Because both the -// interval and the period scale with the sample rate, the unalignable set is fixed in Hz by -// the window's MILLISECONDS: at 50 ms that is f < 16 Hz and 26.7 Hz < f < 32 Hz. Measured -// (Release, 44.1k and 48k) at 30 Hz: the rendered pitch stays correct, but energy outside the -// fundamental is 3.6% at +2 st / rate 1.0 and 15.5% at rate 2.0, against 0.00% at 34 Hz under -// identical conditions; at 29 Hz / rate 2.0 the tone itself lands 7.4% flat. Unlike the -// cadence inequality above, this one is not about how OFTEN a splice fires — a window of at -// least two source periods removes it outright, and nothing else does. +// A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now +// CLOSED for any source whose period is detected, but the geometry is worth keeping because it +// is what the fixed-window fallback still lives under. A splice relocated the tap by the +// nominal window refined by a search over +/- window/4, so the reachable relocation distances +// were exactly [0.75, 1.25] * window; a phase-aligned splice needs a WHOLE NUMBER of source +// periods inside that interval. The interval is 0.5*window wide, so any period <= window/2 +// always has a multiple in it — but above that, coverage breaks into disjoint bands (n=1 covers +// periods [0.75, 1.25]*window, n=2 covers [0.375, 0.625]*window) and the gap between them was +// reachable by nothing. Because both the interval and the period scale with the sample rate, +// that unalignable set is fixed in Hz by the window's MILLISECONDS: at 50 ms, f < 16 Hz and +// 26.7 Hz < f < 32 Hz. Measured there (Release, 44.1k and 48k) at 30 Hz: the rendered pitch +// stayed correct, but energy outside the fundamental was 3.6% at +2 st / rate 1.0 and 15.5% at +// rate 2.0, against 0.00% at 34 Hz under identical conditions; at 29 Hz / rate 2.0 the tone +// itself landed 7.4% flat (-133 cents). +// +// The fix is not a wider window: it is a nominal jump that is a whole number of the source's +// own periods, so an aligned landing point exists by construction (pitch_shift.h's +// periodAlignedJump, fed by period_detect at load). The same measurements then read 0.00% and +// 0.00%, and 29 Hz renders at +0.0 cents. What survives: a period longer than the reachable +// jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no +// single period falls back to this fixed-window geometry by design. inline constexpr double kStretchRateMin = 0.5; inline constexpr double kStretchRateMax = 2.0; inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 5badbc4..7da8710 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -241,6 +241,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick stretch_.start(p); shiftL_.setFeedRate(stretchRate_); shiftR_.setFeedRate(stretchRate_); + // Pitch-synchronous splices: the period was detected once at load (period_detect, + // which this library deliberately does not link — the loader hands the answer down on + // SampleData). 0 restores the fixed-window geometry, so a capture with no single + // period plays exactly as it always did. + shiftL_.setSourcePeriod(sample.sourcePeriodFrames); + shiftR_.setSourcePeriod(sample.sourcePeriodFrames); if (!loopWrap && primeCount < w) { // Sub-window playable span: the source is already exhausted at prime time. shiftL_.freezeTail(); diff --git a/src/core/instrument/map/CMakeLists.txt b/src/core/instrument/map/CMakeLists.txt index 91e80d7..ad5c39a 100644 --- a/src/core/instrument/map/CMakeLists.txt +++ b/src/core/instrument/map/CMakeLists.txt @@ -40,7 +40,8 @@ target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law) reasampler_pure_library(sample_map SOURCES sample_map.cpp LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law - musical_division) + musical_division + PRIVATE period_detect) # Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning # both halves of the mapping/codec split where the frozen-format assertions live. reasampler_test(sample_map LINK sample_map component_state_io) diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index edc116d..38f1e49 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -3,6 +3,8 @@ #include "core/instrument/map/sample_map.h" +#include "core/instrument/engine/period_detect.h" // the load-time Preserve source period + #include // std::remove_if #include // assert #include // std::move @@ -325,6 +327,11 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's // actual rate; source-timeline params (trigger %-length + fades, start) carry through. data.play = resolvePlay(resolved.play, data.sampleRate); + // The one place Preserve's source period is computed: the load, off the audio thread. + // Channel 0 only — a stereo pair's two channels share a fundamental, and the splice + // schedule is linked across them anyway. + data.sourcePeriodFrames = + instrument::engine::detectPeriod(data.frames, data.sampleRate).frames; return data; } diff --git a/tests/energy_outside_fundamental.h b/tests/energy_outside_fundamental.h deleted file mode 100644 index b71ba97..0000000 --- a/tests/energy_outside_fundamental.h +++ /dev/null @@ -1,50 +0,0 @@ -#pragma once -// Out-of-band spectral energy metric: the same period-grid, Hann-windowed direct-evaluation -// approach as test_preserve_low_frequency.cpp's reportSpectrum. Chosen over zero-crossing -// counting because splice debris adds spurious crossings that make that estimator -// anti-correlated with severity (a render can read a badly wrong PERIOD while this metric -// shows it is mostly clean, or vice versa). Grid/segment sizes are smaller than the hand-run -// harness's — this one runs inside the gated suite. - -#include -#include -#include - -namespace reasampler::test_support { - -// Percentage (0..100) of the segment [from, from+len)'s spectral energy that falls outside -// +/- 6% of `wantPeriod` (frames). 0 = a clean single tone at that period; higher values mean -// harmonics, splice-cadence sidebands, or crossfade cancellation debris are present. -inline double energyOutsideFundamentalPercent(const std::vector& v, std::size_t from, - std::size_t len, double wantPeriod) { - constexpr double kPi = 3.14159265358979323846; - constexpr int kGrid = 400; - const double pLo = 30.0, pHi = 8000.0; - std::vector mag(static_cast(kGrid)); - std::vector per(static_cast(kGrid)); - for (int g = 0; g < kGrid; ++g) { - // Geometric grid: constant relative resolution across the swept period range. - const double p = pLo * std::pow(pHi / pLo, static_cast(g) / (kGrid - 1)); - per[static_cast(g)] = p; - double re = 0.0, im = 0.0; - const double w = 2.0 * kPi / p; - for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { - const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast(k) / - static_cast(len))); - const double x = v[from + k] * hann; - re += x * std::cos(w * static_cast(k)); - im += x * std::sin(w * static_cast(k)); - } - mag[static_cast(g)] = std::sqrt(re * re + im * im); - } - double eTotal = 0.0, eFund = 0.0; - for (int g = 0; g < kGrid; ++g) { - const std::size_t i = static_cast(g); - const double e = mag[i] * mag[i]; - eTotal += e; - if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e; - } - return eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0; -} - -} // namespace reasampler::test_support diff --git a/tests/test_period_detect.cpp b/tests/test_period_detect.cpp new file mode 100644 index 0000000..b42b6d9 --- /dev/null +++ b/tests/test_period_detect.cpp @@ -0,0 +1,266 @@ +// Standalone tests for reasampler::instrument::engine::detectPeriod — the offline source-period +// estimate behind Preserve's pitch-synchronous splices. No VST3, no REAPER, no test framework. +// +// Covers: +// 1. accuracy on pure tones across the searched band, at 44.1k and 48k, including the +// non-integer periods every real capture actually has — the splice jump is n periods, so +// a fractional-frame error lands multiplied by n. +// 2. the fundamental, not a harmonic: a sawtooth and a missing-fundamental stack must both +// report the repeat period, which is what a splice has to align on. +// 3. graceful degradation — noise, silence, and a source whose period changes mid-sample all +// return NONE. That is the contract the shifter's fixed-window fallback rests on: an +// estimate that is merely wrong would misalign every splice, which is worse than none. +// 4. the band edges and the short-sample path. +// 5. what the load pays, and that it does not grow with the sample length. + +#include "../src/core/instrument/engine/period_detect.h" + +#include +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::engine; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +constexpr double kPi = 3.14159265358979323846; + +static std::vector sineOfPeriod(std::size_t frames, double period, + double phase = 0.0) { + std::vector s(frames); + for (std::size_t i = 0; i < frames; ++i) { + s[i] = static_cast( + std::sin(2.0 * kPi * static_cast(i) / period + phase)); + } + return s; +} + +// --- 1. Accuracy on pure tones ------------------------------------------------------------- + +static void testPureTonePeriodIsFoundToBetterThanATenthOfAFrame() { + // Deliberately non-integer periods: an integer-only estimator passes an integer-period + // sweep and still misaligns every real capture. + const double periods[] = {23.7, 50.0, 100.25, 200.45, 441.0, 999.9, 1470.0, 2000.3, 2756.0}; + for (double p : periods) { + const std::vector src = sineOfPeriod(120000, p); + const PeriodEstimate est = detectPeriod(src, 44100); + CHECK(est.valid()); + if (!est.valid()) { + std::printf(" period %.2f: NOT DETECTED\n", p); + continue; + } + const double errFrames = std::fabs(est.frames - p); + std::printf(" period %8.2f -> %8.4f (err %.4f fr, conf %.3f)\n", p, est.frames, + errFrames, est.confidence); + CHECK(errFrames < 0.1); + CHECK(est.confidence > 0.8); + } +} + +static void testTheEstimateIsInSourceFramesSoTheRateOnlyMovesTheBand() { + // The same 30 Hz tone at two rates: the answer is frames, so it must track the rate. This + // is what lets the shifter compare it against a window that is also in frames. + for (int rate : {44100, 48000}) { + const double p = static_cast(rate) / 30.0; + const std::vector src = sineOfPeriod(160000, p); + const PeriodEstimate est = detectPeriod(src, rate); + CHECK(est.valid()); + if (est.valid()) { + std::printf(" 30 Hz @ %d: %.3f fr (want %.3f)\n", rate, est.frames, p); + CHECK(std::fabs(est.frames - p) < 0.5); + } + } +} + +// --- 2. The fundamental, not a harmonic ---------------------------------------------------- + +static void testHarmonicRichSourceReportsTheRepeatPeriodNotAPartial() { + // A sawtooth's strongest correlation dips at EVERY multiple of its period; a global-minimum + // estimator picks 2P or 3P about as often as P. YIN's first-dip rule is what makes this + // pass, and a jump quantized to 2P would splice a whole cycle out of phase half the time. + const double p = 512.0; + std::vector src(120000); + for (std::size_t i = 0; i < src.size(); ++i) { + double v = 0.0; + for (int h = 1; h <= 12; ++h) { + v += std::sin(2.0 * kPi * h * static_cast(i) / p) / h; + } + src[i] = static_cast(0.5 * v); + } + const PeriodEstimate est = detectPeriod(src, 44100); + CHECK(est.valid()); + if (est.valid()) { + std::printf(" sawtooth P=512 -> %.3f\n", est.frames); + CHECK(std::fabs(est.frames - p) < 1.0); + } +} + +static void testMissingFundamentalStillReportsTheRepeatPeriod() { + // Partials 2..6 of a 700-frame period: there is no energy AT the fundamental, but the + // waveform still repeats every 700 frames — and repetition, not spectral content, is what + // a splice has to land on. + const double p = 700.0; + std::vector src(120000); + for (std::size_t i = 0; i < src.size(); ++i) { + double v = 0.0; + for (int h = 2; h <= 6; ++h) { + v += std::sin(2.0 * kPi * h * static_cast(i) / p); + } + src[i] = static_cast(0.2 * v); + } + const PeriodEstimate est = detectPeriod(src, 44100); + CHECK(est.valid()); + if (est.valid()) { + std::printf(" missing fundamental P=700 -> %.3f\n", est.frames); + CHECK(std::fabs(est.frames - p) < 2.0); + } +} + +// --- 3. Graceful degradation --------------------------------------------------------------- + +static void testNoiseSilenceAndAPeriodChangeAllReportNone() { + // White noise: no dip below the absolute threshold anywhere. + { + std::vector src(120000); + std::uint32_t rng = 22222u; + for (auto& x : src) { + rng = rng * 1664525u + 1013904223u; + x = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); + } + const PeriodEstimate est = detectPeriod(src, 44100); + std::printf(" white noise -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none", + est.frames); + CHECK(!est.valid()); + } + // Digital silence: the difference function is degenerate, not merely inconclusive. + { + const std::vector src(120000, 0.0f); + CHECK(!detectPeriod(src, 44100).valid()); + } + // Two halves at genuinely different periods: the probes disagree, so there is no ONE + // period, and reporting either half's would misalign every splice in the other half. + { + std::vector src(160000); + double phase = 0.0; + for (std::size_t i = 0; i < src.size(); ++i) { + phase += 2.0 * kPi / (i < 80000 ? 300.0 : 700.0); + src[i] = static_cast(std::sin(phase)); + } + const PeriodEstimate est = detectPeriod(src, 44100); + std::printf(" period change 300->700 -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none", + est.frames); + CHECK(!est.valid()); + } + // Degenerate inputs. + CHECK(!detectPeriod({}, 44100).valid()); + CHECK(!detectPeriod(sineOfPeriod(120000, 441.0), 0).valid()); +} + +static void testAPercussiveDecayIsNotForcedIntoAPeriod() { + // Filtered noise with a fast decay — the shape of a one-shot drum hit. Nothing repeats, so + // the answer must be none rather than whatever the envelope's own length looks like. + std::vector src(120000); + std::uint32_t rng = 909090u; + double lp = 0.0; + for (std::size_t i = 0; i < src.size(); ++i) { + rng = rng * 1664525u + 1013904223u; + const double n = (static_cast(rng >> 8) / 8388608.0) - 1.0; + lp += 0.25 * (n - lp); + const double env = std::exp(-static_cast(i % 22050) / 2000.0); + src[i] = static_cast(lp * env); + } + const PeriodEstimate est = detectPeriod(src, 44100); + std::printf(" percussive decay -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none", + est.frames); + CHECK(!est.valid()); +} + +// --- 4. Band edges and short sources ------------------------------------------------------- + +static void testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple() { + // Below kPeriodDetectMinHz: none. This is the load-bearing edge — such a period cannot fit + // the splice jump anyway, so an answer here would only be one the shifter must reject. + const std::vector low = sineOfPeriod(200000, 44100.0 / 8.0); // 8 Hz + CHECK(!detectPeriod(low, 44100).valid()); + + // Above kPeriodDetectMaxHz the search floor sits well above the true period, so what comes + // back is a WHOLE MULTIPLE of it — which is still an exactly aligned splice target, since + // every multiple of a period is a period. That is why the high edge needs no special + // handling: being outside the band costs nothing, because alignment was never in question + // for a tone this short-period. + const double p = 44100.0 / 6000.0; // 7.35 frames + const PeriodEstimate high = detectPeriod(sineOfPeriod(120000, p), 44100); + std::printf(" 6 kHz (P=%.3f) -> %s (%.3f, = %.3f periods)\n", p, + high.valid() ? "detected" : "none", high.frames, high.frames / p); + if (high.valid()) { + const double n = high.frames / p; + CHECK(std::fabs(n - std::floor(n + 0.5)) < 0.02); + } +} + +static void testAShortSourceShortensTheSearchRatherThanRefusing() { + // A 12000-frame one-shot cannot host a full-band probe; the search band shortens to fit and + // a 200-frame period is still found. Below that the answer is none, not a guess. + const std::vector shortSrc = sineOfPeriod(12000, 200.0); + const PeriodEstimate est = detectPeriod(shortSrc, 44100); + std::printf(" 12000-frame source, P=200 -> %s (%.3f)\n", est.valid() ? "detected" : "none", + est.frames); + CHECK(est.valid()); + if (est.valid()) CHECK(std::fabs(est.frames - 200.0) < 0.5); + + // Too short for even the minimum lag: none. + CHECK(!detectPeriod(sineOfPeriod(40, 20.0), 44100).valid()); +} + +// --- 5. What the load pays ------------------------------------------------------------------ + +// The whole reason a detector is affordable in a sampler is that it runs ONCE, off the audio +// thread, on a source that is already fully known. This prints what that once costs, and +// asserts the property that makes it safe: the cost does NOT grow with the sample length — +// a fixed number of fixed-size probes is analysed however long the capture is. Meaningful +// only in a Release build; asserted as a RATIO so it holds at either optimization level. +static void testDetectionCostIsBoundedRegardlessOfSampleLength() { + double shortMs = 0.0, longMs = 0.0; + for (std::size_t frames : {std::size_t{220500}, std::size_t{4410000}}) { // 5 s and 100 s + const std::vector src = sineOfPeriod(frames, 441.0); + const int reps = 5; + const auto t0 = std::chrono::steady_clock::now(); + double guard = 0.0; + for (int r = 0; r < reps; ++r) guard += detectPeriod(src, 44100).frames; + const double ms = + 1000.0 * std::chrono::duration(std::chrono::steady_clock::now() - t0).count() + / reps; + CHECK(guard > 0.0); + std::printf(" [measure] detectPeriod over %7.1f s of source: %.3f ms\n", + static_cast(frames) / 44100.0, ms); + (frames == 220500 ? shortMs : longMs) = ms; + } + // 20x the source for well under 2x the cost — the probes are fixed-size and fixed in + // number, so the only length dependence left is the cache behaviour of reaching further + // into the buffer. + CHECK(longMs < shortMs * 2.0 + 0.5); +} + +int main() { + testPureTonePeriodIsFoundToBetterThanATenthOfAFrame(); + testTheEstimateIsInSourceFramesSoTheRateOnlyMovesTheBand(); + testHarmonicRichSourceReportsTheRepeatPeriodNotAPartial(); + testMissingFundamentalStillReportsTheRepeatPeriod(); + testNoiseSilenceAndAPeriodChangeAllReportNone(); + testAPercussiveDecayIsNotForcedIntoAPeriod(); + testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple(); + testAShortSourceShortensTheSearchRatherThanRefusing(); + testDetectionCostIsBoundedRegardlessOfSampleLength(); + + if (g_fail == 0) { + std::printf("all period_detect tests passed\n"); + return 0; + } + std::printf("%d period_detect check(s) failed\n", g_fail); + return 1; +} diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index c63cf6c..393b1ff 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -30,11 +30,17 @@ // 8. stereo linked lag (Q-W0 T1-01) — a follower channel driven via processLinked() mirrors // the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on // decorrelated stereo content where an independent per-channel search provably diverges. +// 10. pitch-synchronous splices — the nominal jump snapped to a whole number of source +// periods: the jump law itself, the bit-identical unknown-period fallback, 30 Hz and +// 29 Hz (the two symptoms of the unalignable gap), and the cadence corner, which this +// leaves where it found it. #include "../src/core/instrument/engine/pitch_shift.h" -#include "energy_outside_fundamental.h" +#include "tone_metrics.h" +#include #include +#include #include #include @@ -595,14 +601,17 @@ static void testStereoLinkedLagSharedSchedule() { // source frame due on an output frame go through writeFrame (no output), the last through // process(); an output frame with none due takes processNoInput(). Returns the output plus, // via `consumed`, how much source it ate. +// `sourcePeriod` > 0 puts the shifter on the pitch-synchronous jump the loader would have +// given it; 0 (the default) is the fixed-window fallback every pre-PSOLA call here exercises. static std::vector runStretch(const std::vector& src, std::int64_t w, double feedRate, double shift, std::size_t outFrames, - std::size_t* consumed) { + std::size_t* consumed, double sourcePeriod = 0.0) { PitchShifter ps; ps.configure(w); ps.prime(src.data(), w); ps.setShiftRatio(shift); ps.setFeedRate(feedRate); + ps.setSourcePeriod(sourcePeriod); std::size_t pos = static_cast(w); double debt = 0.0; std::vector out(outFrames); @@ -746,12 +755,23 @@ static void testStretchAndShiftComposeSafely() { // observable here: an investigation (test_preserve_low_frequency.cpp) found the P=500 // render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike, // while the zero-crossing estimator read 23% flat — splice debris adds spurious crossings -// the count cannot tell from a real detune. Energy outside the fundamental tracks the actual -// damage instead: measured here (same rate/shift/source, this file's own metric parameters) -// at 7.31% / 14.41% / 21.22% for P=500/600/700, against 0.10% on an alignable control (P=200, -// below the safe floor) at the same rate and shift — so that is what this asserts: a known, -// characterised property of the range, not a pass/fail on a period estimate. A failure on -// either bound below is a finding — report it, don't retune the thresholds to hide it. +// the count cannot tell from a real detune. Energy outside the fundamental is measured here +// (same rate/shift/source, this file's own metric parameters) at 7.31% / 14.41% / 21.22% for +// P=500/600/700, against 0.10% on an alignable control (P=200, below the safe floor) at the +// same rate and shift. Those readings are stable and are what the bounds below hold. +// +// **CORRECTED — what those three readings MEAN.** They were once read as the corner's damage. +// They are almost entirely the metric's own floor: an ideal tone at the same want-period, +// measured identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because +// a long period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6% +// band. The corner's real EXCESS over that floor is 0.23% / 0.51% / 0.30% — small, real, and +// nothing like the headline numbers. The alignable control's 0.10% is genuinely near-zero only +// because its want-period is short enough to have almost no floor. The bounds below are kept +// as a stable regression tripwire on the raw readings; read the excess, not the reading. +// +// This measures the FIXED-WINDOW path — no source period is set, which is what a capture with +// no single period (percussive, polyphonic, noise) gets. What the same corner does once the +// splice is pitch-synchronous is the test immediately after this one. static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() { using reasampler::test_support::energyOutsideFundamentalPercent; const std::int64_t w = 2205; @@ -804,6 +824,201 @@ static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() { } } +// --- 10. Pitch-synchronous splices: the nominal jump is a whole number of SOURCE periods. --- + +// The out-of-band metric's OWN floor at a given period: a mathematically perfect tone, measured +// with exactly the parameters a render is. A long period under a fixed segment leaks part of +// its own mainlobe outside the +/-6% band, and that leakage grows steeply with the period — so +// a raw reading at period 2800 is not comparable with one at period 800, and neither is +// comparable with zero. The EXCESS over this floor is the honest "how much of this render is +// not the tone" number. +static double idealToneFloorPercent(double wantPeriod, std::size_t from, std::size_t len) { + std::vector v(from + len + 2); + for (std::size_t i = 0; i < v.size(); ++i) { + v[i] = std::sin(2.0 * kPi * static_cast(i) / wantPeriod); + } + return reasampler::test_support::energyOutsideFundamentalPercent(v, from, len, wantPeriod); +} + +// A splice can only phase-align on a landing point a whole number of source periods away, and +// the search only reaches [0.75, 1.25] windows. periodAlignedJump is what puts an aligned point +// at the CENTRE of that interval instead of hoping one falls inside it. +static void testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound() { + const std::int64_t w = 2205; // the product window at 44.1k + const std::int64_t maxJump = 2756; // 1.25 * w, the shifter's own jumpMax_ + + // Unknown period, and a period too long for even ONE whole one to fit: the fixed window, + // unchanged. Both are the documented fallback, and both must be EXACTLY today's geometry. + CHECK(periodAlignedJump(w, maxJump, 0.0) == w); + CHECK(periodAlignedJump(w, maxJump, -5.0) == w); + CHECK(periodAlignedJump(w, maxJump, 3000.0) == w); + + // 30 Hz at 44.1k (P = 1470): two periods overshoot the bound, so it takes ONE — which is + // the case the whole track exists for. The pre-PSOLA geometry could reach neither 1470 nor + // 2940 from a 2205 nominal, since the search only spans [1654, 2756]. + CHECK(periodAlignedJump(w, maxJump, 1470.0) == 1470); + CHECK(2 * 1470 > maxJump); // the witness that one period is forced, not merely chosen + + // 220 Hz (P = 200.4545): eleven periods land within a frame of the window itself, so the + // splice cadence is essentially untouched while every landing is aligned. + CHECK(periodAlignedJump(w, maxJump, 44100.0 / 220.0) == 2205); + + // A period just under the bound is taken whole; the result is never over the bound, at any + // period in the band. Sweeping is what proves the shrink loop terminates correctly rather + // than one hand-picked value doing so. + for (double p = 20.0; p < 3200.0; p += 0.37) { + const std::int64_t j = periodAlignedJump(w, maxJump, p); + CHECK(j >= 1); + if (p > static_cast(maxJump)) { + CHECK(j == w); // out of reach -> fallback + } else { + CHECK(j <= maxJump); + // Aligned: the jump is a whole number of periods, to within the rounding to frames. + const double n = static_cast(j) / p; + CHECK(std::fabs(n - std::floor(n + 0.5)) * p < 0.51); + } + } +} + +// setSourcePeriod(0) and never calling it are the same state, not merely similar ones — the +// cheap half of the fallback claim. The EXPENSIVE half, that the fallback still matches the +// engine as it shipped, is testPreserveUnityRateIsBitIdenticalToTheShippedRead in +// test_sampler_core.cpp: it hashes four rendered streams (including transposed ones that +// really splice) against a baseline captured from commit 0a7778b, and it passes unmodified. +static void testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry() { + const std::int64_t w = 2205; + const std::vector src = sine(400000, 400000.0 / 196.37); + const std::vector never = runStretch(src, w, 1.0, 1.5, 40000, nullptr); + const std::vector zeroed = runStretch(src, w, 1.0, 1.5, 40000, nullptr, 0.0); + bool same = true; + for (std::size_t i = 0; i < never.size(); ++i) if (never[i] != zeroed[i]) same = false; + CHECK(same); +} + +// THE case this track exists for. 30 Hz sits in the only unalignable gap above 16 Hz at the +// product's 50 ms window: its nearest whole multiple misses the reachable interval by 184 +// frames (45 degrees of phase), and the investigation measured the resulting sidebands at +// 3.57% out-of-band at +2 st / rate 1.0 and 15.45% at rate 2.0, against 0.00% on alignable +// controls. Here the same two conditions run with and without the source period, against a +// 34 Hz control that was alignable all along. +// +// The absolute numbers are NOT the harness's: a 1470-frame period under a 32768-frame segment +// leaks part of its own mainlobe outside the +/-6% band, so every reading here carries the same +// floor. That is exactly why the control is measured at the same length — the assertion is that +// 30 Hz reaches the control's floor, not that it reaches zero. +static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() { + using reasampler::test_support::energyOutsideFundamentalPercent; + const std::int64_t w = 2205; + const std::size_t srcLen = 400000, outFrames = 60000, from = 20000, len = 32768; + + struct Row { const char* label; double freq; double rate; double semis; }; + const Row rows[] = { + {"30 Hz +2 st, rate 1.0", 30.0, 1.0, 2.0}, + {"30 Hz rate 2.0", 30.0, 2.0, 0.0}, + {"34 Hz +2 st, rate 1.0", 34.0, 1.0, 2.0}, // control: alignable without a period + {"34 Hz rate 2.0", 34.0, 2.0, 0.0}, + }; + double controlWorst = 0.0, subjectWorst = 0.0; + for (const Row& r : rows) { + const double period = 44100.0 / r.freq; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * static_cast(i) / period)); + } + const double shift = std::pow(2.0, r.semis / 12.0); + const double want = period / shift; + const std::vector off = runStretch(src, w, r.rate, shift, outFrames, nullptr); + const std::vector on = + runStretch(src, w, r.rate, shift, outFrames, nullptr, period); + for (double v : on) CHECK(std::isfinite(v)); + const double floor = idealToneFloorPercent(want, from, len); + const double pctOff = energyOutsideFundamentalPercent(off, from, len, want) - floor; + const double pctOn = energyOutsideFundamentalPercent(on, from, len, want) - floor; + std::printf(" [30 Hz] %-24s (want %6.1f fr, metric floor %.2f%%) excess energy: " + "fixed window %6.2f%% -> pitch-synchronous %6.2f%%\n", r.label, want, floor, + pctOff, pctOn); + if (r.freq == 34.0) controlWorst = std::max(controlWorst, pctOn); + else subjectWorst = std::max(subjectWorst, pctOn); + } + // 30 Hz stops being a special case: with the period known its excess over the metric's own + // floor is no worse than the alignable neighbour's, measured identically. Against the + // control rather than against a fixed number, so the assertion cannot be satisfied by a + // change that merely raised the floor everywhere. + std::printf(" [30 Hz] worst subject excess %.2f%% vs worst control excess %.2f%%\n", + subjectWorst, controlWorst); + CHECK(subjectWorst < 0.10); + CHECK(subjectWorst <= controlWorst + 0.05); // 0.05 absorbs the floor subtraction's sign noise +} + +// The sharpest single symptom of the geometry: at 29 Hz the nearest multiple misses the +// reachable interval ONE-SIDED rather than straddling, so the per-splice phase steps stop +// cancelling and accumulate into a real detune — the investigation measured -133 cents at +// rate 2.0 with NO transposition at all. Rate moves duration; it must not move pitch. +static void testTwentyNineHertzAtRateTwoKeepsItsPitch() { + using reasampler::test_support::autocorrelationPeriod; + const std::int64_t w = 2205; + const double period = 44100.0 / 29.0; // 1520.7 frames + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / period)); + } + auto centsOf = [&](double sourcePeriod) { + const std::vector out = + runStretch(src, w, /*rate=*/2.0, /*shift=*/1.0, 60000, nullptr, sourcePeriod); + const double got = autocorrelationPeriod(out, 20000, 20000, + static_cast(period * 0.5), + static_cast(period * 1.7)); + return 1200.0 * std::log2(got / period); + }; + const double centsOff = centsOf(0.0); + const double centsOn = centsOf(period); + std::printf(" [29 Hz] rate 2.0, no transposition: fixed window %+.1f cents -> " + "pitch-synchronous %+.1f cents\n", centsOff, centsOn); + CHECK(std::fabs(centsOn) < 10.0); + // The fixed-window reading is asserted too, and that is what makes the pair non-vacuous: a + // setSourcePeriod that silently did nothing would render both identically and fail here. + CHECK(std::fabs(centsOff) > 50.0); +} + +// The cadence corner (rate 2.0, -24 st, source periods above the 315-frame safe floor) is the +// OTHER mechanism — a splice landing inside a single perceived cycle. Measured against the +// metric's own floor, PSOLA moves it by nothing: 0.23/0.51/0.30% excess becomes 0.24/0.49/0.30%. +// +// That is not a shortfall, it is what the corner turned out to be. Correcting the previous +// test's reading (see its comment) shrank the corner from a 7-21% headline to a sub-1% excess, +// which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each splice +// land in phase, and these splices already did; what it cannot do is make them less frequent. +// So this asserts NO REGRESSION, not an improvement, and says so rather than claiming one. +static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() { + using reasampler::test_support::energyOutsideFundamentalPercent; + const std::int64_t w = 2205; + const double shift = std::pow(2.0, -24.0 / 12.0); + const std::size_t outFrames = 60000, from = 20000, len = 32768; + for (double period : {500.0, 600.0, 700.0}) { + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * static_cast(i) / period)); + } + const std::vector off = runStretch(src, w, 2.0, shift, outFrames, nullptr); + const std::vector on = + runStretch(src, w, 2.0, shift, outFrames, nullptr, period); + for (double v : on) CHECK(std::isfinite(v)); + const double want = period / shift; + const double floor = idealToneFloorPercent(want, from, len); + const double pctOff = energyOutsideFundamentalPercent(off, from, len, want); + const double pctOn = energyOutsideFundamentalPercent(on, from, len, want); + std::printf(" [cadence corner, PSOLA] period %.0f (want %.0f, metric floor %.2f%%): " + "excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor, + pctOn - floor); + CHECK(pctOn - floor < 1.0); // the corner's real excess, PSOLA or not + CHECK(pctOn < pctOff + 0.05); // and PSOLA costs it nothing + } +} + // The two new entry points on a shifter that was never configured (a Varispeed voice's) — // neither may touch the empty ring. static void testStretchEntryPointsOnPassThrough() { @@ -826,6 +1041,11 @@ int main() { testStretchMovesDurationNotPitch(); testStretchAndShiftComposeSafely(); testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter(); + testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound(); + testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry(); + testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown(); + testTwentyNineHertzAtRateTwoKeepsItsPitch(); + testCadenceCornerIsUnmovedByAPitchSynchronousSplice(); testStretchEntryPointsOnPassThrough(); if (g_fail == 0) { diff --git a/tests/test_preserve_low_frequency.cpp b/tests/test_preserve_low_frequency.cpp index 7c02540..8888015 100644 --- a/tests/test_preserve_low_frequency.cpp +++ b/tests/test_preserve_low_frequency.cpp @@ -14,6 +14,11 @@ // the load-bearing metric is energy outside it. Zero-crossing counting in particular reports a // wrong period on renders whose fundamental is provably correct, which is section C. // +// It now runs every frequency-dependent section TWICE — once with splices falling back to the +// fixed window (the behaviour every number above was measured on) and once pitch-synchronous, +// with the period detected from the PCM exactly as the loader would. The two columns differ in +// that one thing, so the comparison needs no second binary and no remembered baseline. +// // Measures, at both 44.1k and 48k geometry: // A. the reachable relocation interval, observed rather than derived (jump/lag/frac off // every SpliceEvent), and the alignment-reachability predicate over frequency. @@ -24,9 +29,11 @@ // D. a window sweep at 30 Hz — what a larger window would buy, and what it would cost. // E. alignable frequencies under identical conditions, without which D and B have no scale. +#include "../src/core/instrument/engine/period_detect.h" #include "../src/core/instrument/engine/pitch_shift.h" #include "../src/core/instrument/engine/time_stretch.h" #include "../src/core/instrument/engine/voice.h" +#include "tone_metrics.h" #include #include @@ -44,12 +51,20 @@ static int g_fail = 0; constexpr double kPi = 3.14159265358979323846; +// Whether a source carries its detected period into the shifter — i.e. whether splices are +// pitch-synchronous or fall back to the fixed window. Every section below runs under whichever +// is set, so main() can drive the SAME measurements both ways from one binary and the two +// columns are comparable by construction. +static bool g_pitchSynchronous = true; + // --------------------------------------------------------------------------------------- // Source + render helpers // --------------------------------------------------------------------------------------- // A pure sine at `freqHz`, phase-continuous, long enough that a rate-2.0 render never -// exhausts it (the caller sizes `frames`). +// exhausts it (the caller sizes `frames`). The period is DETECTED rather than computed from +// freqHz on purpose: that is the number the loader would actually hand the engine, so the +// measurement includes any detector error rather than assuming it away. static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames, PitchEngine engine, double phase = 0.0) { SampleData s; @@ -61,6 +76,7 @@ static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames, s.sampleRate = sampleRate; s.rootNote = 60; s.play.pitchEngine = engine; // Gate, no loop, default (fully open) AHDSR + if (g_pitchSynchronous) s.sourcePeriodFrames = detectPeriod(s.frames, sampleRate).frames; return s; } @@ -162,37 +178,10 @@ static double medianResidual(const std::vector& v, std::size_t from, std return r[mid]; } -// Period of the highest normalized-autocorrelation peak over [minLag, maxLag] — a pitch -// estimator that, unlike zero-crossing counting, is not fooled by a low-level fast component -// adding spurious crossings. The two disagreeing is itself the diagnosis. -static double autocorrPeriod(const std::vector& v, std::size_t from, std::size_t len, - std::int64_t minLag, std::int64_t maxLag) { - double e0 = 0.0; - for (std::size_t k = 0; k < len && from + k < v.size(); ++k) e0 += v[from + k] * v[from + k]; - if (e0 <= 0.0) return 0.0; - double best = -1e18; std::int64_t bestLag = 0; - std::vector score(static_cast(maxLag - minLag + 1), 0.0); - for (std::int64_t lag = minLag; lag <= maxLag; ++lag) { - double s = 0.0, e = 0.0; - for (std::size_t k = 0; k < len && from + k + static_cast(lag) < v.size(); - ++k) { - const double b = v[from + k + static_cast(lag)]; - s += v[from + k] * b; - e += b * b; - } - const double r = e > 0.0 ? s / std::sqrt(e0 * e) : 0.0; - score[static_cast(lag - minLag)] = r; - if (r > best) { best = r; bestLag = lag; } - } - // Parabolic refinement so the estimate isn't quantized to whole frames. - const std::size_t i = static_cast(bestLag - minLag); - double frac = 0.0; - if (i > 0 && i + 1 < score.size()) { - const double den = score[i - 1] - 2.0 * score[i] + score[i + 1]; - if (den < 0.0) frac = 0.5 * (score[i - 1] - score[i + 1]) / den; - } - return static_cast(bestLag) + frac; -} +// A pitch estimator that, unlike zero-crossing counting, is not fooled by a low-level fast +// component adding spurious crossings. The two disagreeing is itself the diagnosis. Shared with +// the gated tests (tone_metrics.h) so there is one estimator and not two. +using reasampler::test_support::autocorrelationPeriod; // The five strongest spectral peaks over a Hann-windowed segment, scanned on a fine period // grid (Goertzel-style direct evaluation, no FFT-bin quantization). Prints period in frames @@ -264,24 +253,28 @@ struct SpliceStats { double minReloc = 1e18, maxReloc = -1e18; std::int64_t minLag = 1LL << 40, maxLag = -(1LL << 40); double meanInterval = 0.0; - bool jumpAlwaysNominal = true; // |jump| == window on every splice (steady state) + bool jumpAlwaysNominal = true; // |jump| == the nominal on every splice (steady state) + std::int64_t nominalJump = 0; // what the shifter itself resolved the nominal to }; // Drives a bare PitchShifter over the same feed schedule Voice uses, recording every splice. // The audio is not kept — this measures the DECISIONS, not the sound. static SpliceStats spliceGeometry(const std::vector& src, std::int64_t window, double rate, double shift, std::size_t outFrames, - std::vector* audio = nullptr) { + std::vector* audio = nullptr, + double sourcePeriod = 0.0) { PitchShifter ps; ps.configure(window); ps.prime(src.data(), window); ps.setShiftRatio(shift); ps.setFeedRate(rate); + ps.setSourcePeriod(sourcePeriod); StretchCursor cur; cur.start(window); loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through SpliceStats st; + st.nominalJump = ps.spliceJump(); if (audio != nullptr) audio->assign(outFrames, 0.0); std::size_t lastSpliceAt = 0; double intervalSum = 0.0; @@ -308,7 +301,7 @@ static SpliceStats spliceGeometry(const std::vector& src, std::int6 if (reloc > st.maxReloc) st.maxReloc = reloc; if (ev.lag < st.minLag) st.minLag = ev.lag; if (ev.lag > st.maxLag) st.maxLag = ev.lag; - if (std::llabs(ev.jump) != window) st.jumpAlwaysNominal = false; + if (std::llabs(ev.jump) != st.nominalJump) st.jumpAlwaysNominal = false; if (lastSpliceAt != 0) { intervalSum += static_cast(i - lastSpliceAt); ++intervals; } lastSpliceAt = i; } @@ -503,24 +496,29 @@ static void measureRow(const char* label, double freqHz, int sr, std::int64_t wi const double resid = medianResidual(out, from, to, wantCpf); std::vector src(s.frames.begin(), s.frames.end()); - const SpliceStats st = spliceGeometry(src, window, rate, shift, outFrames); + const SpliceStats st = + spliceGeometry(src, window, rate, shift, outFrames, nullptr, s.sourcePeriodFrames); const double lo = static_cast(window - window / 4); const double hi = static_cast(window + window / 4); int n = 0; + // Reachability of the FIXED-window interval. With a source period known this is no longer + // the binding question — the nominal jump is a multiple of the period by construction — + // but it stays reported because it is what the "NO" rows below were diagnosed by. const bool reach = alignmentReachable(srcPeriod, lo, hi, &n); // Effective frequency error implied by the drift, and the phase step it works out to per // splice — the number that says whether a splice is stepping the phase or not. const double driftPerSplice = st.count > 0 ? drift / static_cast(st.count) : 0.0; - std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | period got %8.2f want %8.2f " - "(%+.2f%%) | splices %4lld every %7.0f fr | phase drift %+8.3f cyc " - "(%+7.1f deg/splice, worst step %.1f deg) | resid %.4f | peak %.3f | " - "align %s%s\n", - label, freqHz, shift, rate, gotPeriod, wantPeriod, + std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | P det %8.2f jump %5lld | " + "period got %8.2f want %8.2f (%+.2f%%) | splices %4lld every %7.0f fr | " + "phase drift %+8.3f cyc (%+7.1f deg/splice, worst step %.1f deg) | " + "resid %.4f | peak %.3f | fixed-window align %s%s\n", + label, freqHz, shift, rate, s.sourcePeriodFrames, + static_cast(st.nominalJump), gotPeriod, wantPeriod, wantPeriod > 0.0 ? 100.0 * (gotPeriod - wantPeriod) / wantPeriod : 0.0, st.count, st.meanInterval, drift, 360.0 * driftPerSplice, 360.0 * worstStep, resid, peak, reach ? "YES" : "NO", - reach ? "" : " <-- no whole period in the reachable interval"); + reach ? "" : " <-- no whole period in the fixed-window reachable interval"); CHECK(finite); } @@ -536,7 +534,7 @@ static void deepDive(const char* label, double freqHz, int sr, std::int64_t wind const double zc = periodIn(out, 40000, 280000); // Search bounded to [0.5, 1.7] x the wanted period: a pure sine autocorrelates equally at // EVERY multiple of its period, so an unbounded search reports 2P about half the time. - const double ac = autocorrPeriod(out, 60000, 60000, + const double ac = autocorrelationPeriod(out, 60000, 60000, std::max(40, static_cast(wantPeriod * 0.5)), static_cast(wantPeriod * 1.7)); @@ -617,10 +615,11 @@ static void reportFloorProbeMechanism() { std::sin(2.0 * kPi * static_cast(i) / period)); } std::vector out; - const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out); + const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out, + g_pitchSynchronous ? period : 0.0); const double want = period / shift; const double zc = periodIn(out, 20000, 50000); - const double ac = autocorrPeriod(out, 20000, 20000, + const double ac = autocorrelationPeriod(out, 20000, 20000, static_cast(want * 0.5), static_cast(want * 1.7)); std::printf(" P=%.0f: zero-crossing %.2f (%+.2f%%) | autocorrelation %.2f (%+.2f%%) " @@ -681,14 +680,39 @@ static void reportAlignableControls() { deepDive("220 Hz rate 2.0", 220.0, 44100, 2205, 60, 2.0); } -int main() { - reportReachableInterval(); - reportReachabilityByFrequency(); +// The frequency-dependent sections, run under whichever splice geometry is set. Everything +// that can differ between the two is in here; section A (the reachable interval, measured on +// noise) and the reachability arithmetic are properties of the fixed-window search alone and +// run once. +static void runFrequencySections() { testRootRateUnityIsBitIdenticalToTheDirectRead(); reportTransposedAt30Hz(); reportFrequencySweep(); reportFloorProbeMechanism(); reportAlignableControls(); +} + +int main() { + reportReachableInterval(); + reportReachabilityByFrequency(); + + // The same measurements twice, from one binary, so the two columns differ in exactly one + // thing. The FIXED-WINDOW pass reproduces the pre-PSOLA engine — it is the baseline every + // number in the investigation was taken against. + g_pitchSynchronous = false; + std::printf("\n\n##################################################################\n"); + std::printf("### FIXED-WINDOW splices (no source period) — the prior behaviour ###\n"); + std::printf("##################################################################\n"); + runFrequencySections(); + + g_pitchSynchronous = true; + std::printf("\n\n##################################################################\n"); + std::printf("### PITCH-SYNCHRONOUS splices (detected source period) ###\n"); + std::printf("##################################################################\n"); + runFrequencySections(); + + // The window sweep is about what a LARGER WINDOW would buy, which was the alternative to + // this track. Run under the shipped geometry only. reportWindowSweep(); if (g_fail == 0) { diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index d593670..9ead4e7 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -3214,7 +3214,10 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() { const std::size_t blockFrames = 44100; // one second of audio const std::size_t voiceCount = 32; const int kWarmupReps = 2; - const int kTimedReps = 7; + // 5 rather than 7: the source-period rows below doubled the row count, and run-to-run + // spread on this machine is ~5% either way, so the extra reps bought precision the number + // does not carry while costing the Debug gate real seconds. + const int kTimedReps = 5; SampleData s = stretchProbeSample(200000, true); s.loop.hasLoop = true; // held notes: all 32 sound for the whole run s.loop.start = 40000; @@ -3223,7 +3226,24 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() { // 1.0 is the reference: it is the cost the shipped Preserve read already carries, so the // two stretched rows are read as a delta against it rather than in isolation. - for (double rate : {1.0, 0.5, 2.0}) { + // + // The last two rows carry a SOURCE PERIOD, which is where the pitch-synchronous splice can + // cost something: the jump becomes a whole number of periods, and when that is shorter than + // the window the splice cadence rises in proportion — more correlation searches per second. + // 1470 (30 Hz at 44.1k) is the worst realistic case in the audible band, forcing a jump of + // 2/3 the window and therefore 1.5x the searches. 220.5 (200 Hz) is the typical one: ten + // periods land exactly on the window, so the cadence is unchanged and the row should read + // as the no-period one — which is the measurement that separates "the mechanism costs + // something" from "a shorter jump costs something". There is no per-frame cost either way: + // the jump is resolved once at note-on. + struct Row { double rate; double period; }; + const Row rows[] = { + {1.0, 0.0}, {0.5, 0.0}, {2.0, 0.0}, + {1.0, 220.5}, {1.0, 1470.0}, {2.0, 1470.0}, + }; + for (const Row& row : rows) { + const double rate = row.rate; + s.sourcePeriodFrames = row.period; std::vector nsPerVoiceFrame; nsPerVoiceFrame.reserve(kTimedReps); for (int rep = 0; rep < kWarmupReps + kTimedReps; ++rep) { @@ -3263,10 +3283,11 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() { const double medianNs = nsPerVoiceFrame[nsPerVoiceFrame.size() / 2]; const double secsAtMedian = medianNs * static_cast(blockFrames) * static_cast(voiceCount) / 1e9; - std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: median %.1f ns/voice/" - "frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime at the median)\n", - rate, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), kTimedReps, - 100.0 * secsAtMedian); + std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f, source period %6.1f: " + "median %.1f ns/voice/frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime " + "at the median)\n", + rate, row.period, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), + kTimedReps, 100.0 * secsAtMedian); } } diff --git a/tests/tone_metrics.h b/tests/tone_metrics.h new file mode 100644 index 0000000..2126894 --- /dev/null +++ b/tests/tone_metrics.h @@ -0,0 +1,93 @@ +#pragma once +// The two tone metrics the Preserve tests and the hand-run low-frequency harness share, so +// there is one of each rather than a copy per file. +// +// Zero-crossing counting is deliberately NOT among them: splice debris adds spurious crossings +// that make that estimator anti-correlated with severity (a render can read a badly wrong +// PERIOD while it is spectrally clean, or vice versa). + +#include +#include +#include +#include +#include + +namespace reasampler::test_support { + +// Percentage (0..100) of the segment [from, from+len)'s spectral energy that falls outside +// +/- 6% of `wantPeriod` (frames), evaluated directly on a geometric period grid (no FFT-bin +// quantization). 0 = a clean single tone at that period; higher values mean harmonics, +// splice-cadence sidebands, or crossfade cancellation debris are present. +// +// `grid` trades resolution for cost: the gated tests run the default, the hand-run harness +// raises it. The value is NOT comparable across grid sizes or segment lengths — a long period +// under a short segment leaks part of its own mainlobe outside the +/-6% band, so a reading is +// only meaningful against a control measured at the SAME len and grid. +inline double energyOutsideFundamentalPercent(const std::vector& v, std::size_t from, + std::size_t len, double wantPeriod, + int grid = 400) { + constexpr double kPi = 3.14159265358979323846; + const int kGrid = grid; + const double pLo = 30.0, pHi = 8000.0; + std::vector mag(static_cast(kGrid)); + std::vector per(static_cast(kGrid)); + for (int g = 0; g < kGrid; ++g) { + // Geometric grid: constant relative resolution across the swept period range. + const double p = pLo * std::pow(pHi / pLo, static_cast(g) / (kGrid - 1)); + per[static_cast(g)] = p; + double re = 0.0, im = 0.0; + const double w = 2.0 * kPi / p; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) { + const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast(k) / + static_cast(len))); + const double x = v[from + k] * hann; + re += x * std::cos(w * static_cast(k)); + im += x * std::sin(w * static_cast(k)); + } + mag[static_cast(g)] = std::sqrt(re * re + im * im); + } + double eTotal = 0.0, eFund = 0.0; + for (int g = 0; g < kGrid; ++g) { + const std::size_t i = static_cast(g); + const double e = mag[i] * mag[i]; + eTotal += e; + if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e; + } + return eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0; +} + +// Period (frames) of the highest normalized-autocorrelation peak over [minLag, maxLag], with a +// parabolic refinement so the answer is not quantized to whole frames. Bracket the caller's +// range to roughly [0.5, 1.7] x the expected period: a pure tone autocorrelates equally at +// EVERY multiple of its period, so an unbounded search reports 2P about half the time. +inline double autocorrelationPeriod(const std::vector& v, std::size_t from, + std::size_t len, std::int64_t minLag, std::int64_t maxLag) { + if (maxLag <= minLag) return 0.0; + double e0 = 0.0; + for (std::size_t k = 0; k < len && from + k < v.size(); ++k) e0 += v[from + k] * v[from + k]; + if (e0 <= 0.0) return 0.0; + std::vector score(static_cast(maxLag - minLag + 1), 0.0); + double best = -1e18; + std::int64_t bestLag = minLag; + for (std::int64_t lag = minLag; lag <= maxLag; ++lag) { + double s = 0.0, e = 0.0; + for (std::size_t k = 0; k < len && from + k + static_cast(lag) < v.size(); + ++k) { + const double b = v[from + k + static_cast(lag)]; + s += v[from + k] * b; + e += b * b; + } + const double r = e > 0.0 ? s / std::sqrt(e0 * e) : 0.0; + score[static_cast(lag - minLag)] = r; + if (r > best) { best = r; bestLag = lag; } + } + const std::size_t i = static_cast(bestLag - minLag); + double frac = 0.0; + if (i > 0 && i + 1 < score.size()) { + const double den = score[i - 1] - 2.0 * score[i] + score[i + 1]; + if (den < 0.0) frac = 0.5 * (score[i - 1] - score[i + 1]) / den; + } + return static_cast(bestLag) + frac; +} + +} // namespace reasampler::test_support From 38337229d44ff260443962472ba6bdf400d5b0d1 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 22:27:07 -0400 Subject: [PATCH 17/56] =?UTF-8?q?docs:=20correct=20=CE=93's=20payload=20ru?= =?UTF-8?q?ngs=20to=20v15/v16/v17=20after=20=CE=9E=20took=20v14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit States rung one as spent rather than derivable, so following the instruction no longer contradicts the stated number — the trap that caused the collision. --- docs/PLAN.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 3a2db48..7be35e9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1062,10 +1062,10 @@ module under `core/instrument/engine/` (each with its own `_tests` targe `shell/instrument/reasampler_processor` (the chain, the published block state, **and the `getLatencySamples` / `restartComponent(kLatencyChanged)` path**), and **the phase's FIRST params-payload rung** (the limiter enable flag). **Does not own** MASTER's deck geometry or -any drawing — that is Γ-W3-T1. **Read `kParamsPayloadVersion` on `dev` and take the next rung -above it rather than assuming the number** — Phase Ξ ran ahead of this plan's sequencing, so -the plan is not the record of what the ladder currently carries. On `dev` as of 2026-08-01 -that resolves to **v14**. +any drawing — that is Γ-W3-T1. **This track spends that rung: `kParamsPayloadVersion == 15`** +(`map/component_state_io.h:170`), confirmed on this branch as of 2026-08-01 — the full ladder +(spent / next-free / reserved) is stated in the phase summary's resequencing note near the end +of this phase's ASCII block. **Behavior.** - **Chain:** `voice mixer → master gain (existing ramped multiply) → limiter (bypassable) → @@ -1432,8 +1432,8 @@ second taper defined inside `deck_values`' binding** — one home, appended to, **The format ladder stays clean.** The loop enable maps onto the existing `SampleLoop::hasLoop`, which is already persisted and whose `start`/`end` are already written unconditionally — **no new field, no version bump** — so T1 keeps sole ownership of **the -phase's second payload rung** exactly as specced (v15 on `dev` as of 2026-08-01; read the -ladder rather than assuming the number). +phase's second payload rung** exactly as specced (v16 on this branch as of 2026-08-01, which +has already integrated Ξ; read the ladder rather than assuming the number). #### Γ-W2-T1 — `pitch-rate-deck` @@ -1445,7 +1445,8 @@ existing Varisp|Presrv toggle, with both new controls wired through the engine. **Surface boundary — owns:** `core/instrument/engine/play_params.h` + `core/instrument/map/play_seconds.h` (the two new fields), `core/instrument/map/component_state_io` + `params_payload` (**the phase's SECOND payload -rung** — v15 on `dev` as of 2026-08-01; read the ladder, do not assume the number), +rung** — v16 on this branch as of 2026-08-01, which has already integrated Ξ; read the ladder, +do not assume the number), `core/instrument/engine/voice.{h,cpp}` (the compounding and the note-on latch), `core/instrument/ui/deck_groups` (the PITCH/RATE descriptor **and** the three-state live predicate), `core/instrument/ui/deck_values` (the two new bindings). **Does not own** the @@ -4319,8 +4320,10 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends Resequenced 2026-08-01, three times: the reflow split canvas (W1-T4) from arrangement (W3-T1); preserve-time-stretch moved W4 -> W1-T5, retiring Rate's interim stand-in; then Ruling 1 added W4 and Ruling 2 grew W1-T1. - Payload rungs are RELATIVE, not absolute — read kParamsPayloadVersion on dev and take the - next three above it. On dev at 2026-08-01 that is v14 / v15 / v16-reserved. + Payload rungs are RELATIVE, not absolute. On this branch at 2026-08-01, the first is already + spent — kParamsPayloadVersion == 15 (Γ-W1-T2, landed, above) — the second is the next free + rung, v16 (Γ-W2-T1, stated there), and the third remains RESERVED as v17, spent only if + Γ-W4-T1's storage verification forces it. Shared files, named: engine/CMakeLists.txt (W1-T2 | W1-T5), ui/CMakeLists.txt (W1-T1 | W1-T3), editor_session.cpp (W2-T1 | W2-T2) — all textual adjacency, not semantic contention. W3-T2's disjointness from W3-T1 is CONDITIONAL: confirm it against From 334022c0f18b06ae181f6d9949494a8c4e394798 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 03:01:15 -0400 Subject: [PATCH 18/56] Gamma-W1-T7: gate PSOLA's two untested wires, fix the cadence test's contradictory rationale, add a vacuity guard --- src/core/instrument/engine/period_detect.h | 5 ++++- src/core/instrument/engine/time_stretch.h | 6 +++++- tests/test_pitch_shift.cpp | 24 +++++++++++++++------- tests/test_sample_map.cpp | 18 ++++++++++++++++ tests/test_sampler_core.cpp | 20 ++++++++++++++++++ 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/core/instrument/engine/period_detect.h b/src/core/instrument/engine/period_detect.h index 515c0ff..f07112b 100644 --- a/src/core/instrument/engine/period_detect.h +++ b/src/core/instrument/engine/period_detect.h @@ -19,7 +19,10 @@ using audio::AudioSample; // authored and never persisted — this is a cache, not state. struct PeriodEstimate { double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise) - double confidence = 0.0; // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0 + // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic only today: the + // accept decision is `valid()` alone, and the loader takes `.frames` without reading this — + // do not assume it is load-bearing without checking who reads it. + double confidence = 0.0; bool valid() const { return frames > 0.0; } }; diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index d259fd8..1552feb 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -50,7 +50,11 @@ namespace reasampler::instrument::engine { // The fix is not a wider window: it is a nominal jump that is a whole number of the source's // own periods, so an aligned landing point exists by construction (pitch_shift.h's // periodAlignedJump, fed by period_detect at load). The same measurements then read 0.00% and -// 0.00%, and 29 Hz renders at +0.0 cents. What survives: a period longer than the reachable +// 0.00%, and 29 Hz renders at +0.0 cents — all from `preserve_low_frequency_tests` (Release, +// hand-run; it is not in the gated ctest set), the same harness/config as the 3.6%/15.5%/-133 +// cents readings above. The gated suite's own number for this is the floor-relative excess in +// pitch_shift_tests' testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown, a different +// quantity from the raw percentages here. What survives: a period longer than the reachable // jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no // single period falls back to this fixed-window geometry by design. inline constexpr double kStretchRateMin = 0.5; diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index 393b1ff..99706c7 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -780,10 +780,11 @@ static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() { const std::size_t outFrames = 60000; const std::size_t from = 20000, len = 32768; - // Below the safe floor (P > 315 frames): the cadence inequality predicts real damage, - // measured at 7-21% (see above). The threshold (5%) sits above the alignable control's - // near-zero floor and under the observed range, so it discriminates a genuine cadence hit - // from a clean render; the ceiling (30%) is a generous margin above the highest measured + // Below the safe floor (P > 315 frames): per the header's CORRECTED note, this raw reading + // is mostly the metric's own mainlobe-leakage floor, not cadence damage (the real excess is + // ~0.2-0.5%, asserted in testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds + // below are a RAW-READING STABILITY TRIPWIRE, not a discriminator — they catch a large swing + // in the raw measurement; the ceiling (30%) is a generous margin above the highest measured // value, there to catch a much worse regression rather than to chase today's exact number. for (double period : {500.0, 600.0, 700.0}) { const double f0 = 1.0 / period; @@ -918,7 +919,7 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() { {"34 Hz +2 st, rate 1.0", 34.0, 1.0, 2.0}, // control: alignable without a period {"34 Hz rate 2.0", 34.0, 2.0, 0.0}, }; - double controlWorst = 0.0, subjectWorst = 0.0; + double controlWorst = 0.0, subjectWorst = 0.0, subjectOffWorst = 0.0; for (const Row& r : rows) { const double period = 44100.0 / r.freq; std::vector src(srcLen); @@ -938,8 +939,12 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() { std::printf(" [30 Hz] %-24s (want %6.1f fr, metric floor %.2f%%) excess energy: " "fixed window %6.2f%% -> pitch-synchronous %6.2f%%\n", r.label, want, floor, pctOff, pctOn); - if (r.freq == 34.0) controlWorst = std::max(controlWorst, pctOn); - else subjectWorst = std::max(subjectWorst, pctOn); + if (r.freq == 34.0) { + controlWorst = std::max(controlWorst, pctOn); + } else { + subjectWorst = std::max(subjectWorst, pctOn); + subjectOffWorst = std::max(subjectOffWorst, pctOff); + } } // 30 Hz stops being a special case: with the period known its excess over the metric's own // floor is no worse than the alignable neighbour's, measured identically. Against the @@ -949,6 +954,11 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() { subjectWorst, controlWorst); CHECK(subjectWorst < 0.10); CHECK(subjectWorst <= controlWorst + 0.05); // 0.05 absorbs the floor subtraction's sign noise + // Vacuity guard, matching testTwentyNineHertzAtRateTwoKeepsItsPitch's sibling check: the + // FIXED-WINDOW (no period set) arm is asserted too, so a setSourcePeriod that silently did + // nothing would render both arms identically and fail here rather than passing on the + // absolute bound above by luck. + CHECK(subjectOffWorst > subjectWorst + 1.0); } // The sharpest single symptom of the geometry: at 29 Hz the nearest multiple misses the diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index f96e4c9..a32aa32 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -909,6 +909,23 @@ static void testBuildSampleDataEmptyPcmIsUnplayable() { CHECK(sd.frames.empty()); } +// Major-1 remediation: buildSampleData (sample_map.cpp:333-334) is the ONE call site wiring +// load-time detection to SampleData; detectPeriod's own unit coverage (test_period_detect.cpp) +// never exercises this call, so a deleted wire passed the gated suite unnoticed. Goes through +// the real build, not a direct detectPeriod call. +static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() { + const int rate = 44100; + const std::size_t frames = 30000; + std::vector pcm(frames); + for (std::size_t i = 0; i < frames; ++i) { + pcm[i] = static_cast( + std::sin(2.0 * 3.14159265358979323846 * 30.0 * static_cast(i) / rate)); + } + const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), InstrumentParams{}), + DecodedPcm{pcm, rate, {}}); + CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz +} + static void testBuildSampleDataCarriesTheVelocityCurve() { InstrumentParams p; p.velocityCurve = VelocityCurve::linear(); @@ -976,6 +993,7 @@ int main() { testBuildSampleDataDropsMismatchedSecondChannel(); testBuildSampleDataEmptyPcmIsUnplayable(); testBuildSampleDataCarriesTheVelocityCurve(); + testBuildSampleDataDetectsThirtyHertzSourcePeriod(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 9ead4e7..3ab30ef 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -2991,6 +2991,25 @@ static void testPreserveUnityRateIsBitIdenticalToTheShippedRead() { } } +// --- The load->voice wire is load-bearing: Voice::start (voice.cpp:248-249) is the only place +// a detected period reaches the shifter. Assert the two SampleDatas actually render +// differently rather than only that setSourcePeriod is callable — dropping either of the +// wire's two lines left this whole gated suite green before this test existed. +static void testSourcePeriodChangesTheRenderedStream() { + const std::int64_t w = 2205; // the product window at 44.1k + const std::size_t n = 6000; + SampleData off = stretchProbeSample(20000, false); + SampleData on = off; + // 30 Hz @ 44.1k: periodAlignedJump takes ONE whole period (1470), which cannot fit twice + // inside the shifter's reachable jump bound — a different splice geometry from the fixed + // 2205-frame window, so a wire failure here cannot pass by coincidence. + on.sourcePeriodFrames = 1470.0; + std::vector lOff(n), lOn(n), rUnused; + renderVoice(off, /*note=*/67, /*rate=*/1.0, w, false, lOff, rUnused); // +7 st: real splices + renderVoice(on, 67, 1.0, w, false, lOn, rUnused); + CHECK(hashStream(lOff) != hashStream(lOn)); +} + // --- Rate changes DURATION only; the transposition alone sets pitch. --- static void testPreserveStretchChangesDurationNotPitch() { // Gate, no loop: the voice's life is exactly how long the source lasts, so the frame at @@ -3419,6 +3438,7 @@ int main() { // The Preserve read path's stretch generalization. testPreserveUnityRateIsBitIdenticalToTheShippedRead(); + testSourcePeriodChangesTheRenderedStream(); testPreserveStretchChangesDurationNotPitch(); testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); testPreserveStretchLoopsTheSourceSpan(); From 163ab11e05a0604f53d362776f30b27dee132aad Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 00:07:22 -0400 Subject: [PATCH 19/56] =?UTF-8?q?Handle=20Psi's=20ReaperSurface::Count=20s?= =?UTF-8?q?entinel=20in=20decideDropClass=20=E2=80=94=20Gamma's=20exhausti?= =?UTF-8?q?ve-switch=20gate=20turns=20it=20into=20a=20hard=20error=20on=20?= =?UTF-8?q?contact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentinel is not a surface, so it breaks to the existing unclassifiable-surface refusal rather than joining a real case label. --- src/core/ui/drag_out.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/ui/drag_out.cpp b/src/core/ui/drag_out.cpp index 1a0440c..725782d 100644 --- a/src/core/ui/drag_out.cpp +++ b/src/core/ui/drag_out.cpp @@ -44,6 +44,9 @@ DropClass decideDropClass(int px, int py, const PanelClientRect& client, case ReaperSurface::FxEmbed: case ReaperSurface::Other: return DropClass::Refuse; + + case ReaperSurface::Count: + break; // the sentinel is not a surface; it falls to the refusal below } return DropClass::Refuse; // an unclassifiable surface still refuses visibly, never silently } From 9228e9375004a01ec235c88932931d6429a2a131 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 03:21:06 -0400 Subject: [PATCH 20/56] Preserve's period detection: probes are placed by position, and a sustain loop is the span analysed --- src/core/instrument/CLAUDE.md | 7 +- src/core/instrument/engine/period_detect.cpp | 61 ++++++-- src/core/instrument/engine/period_detect.h | 52 ++++++- src/core/instrument/map/sample_map.cpp | 9 +- tests/test_period_detect.cpp | 152 ++++++++++++++++++- tests/test_sample_map.cpp | 40 +++++ 6 files changed, 299 insertions(+), 22 deletions(-) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index e4d5757..a852599 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -300,7 +300,12 @@ anything for a trigger shape. is DERIVED from the audio, so it is cache and not state: nothing persists it, and it takes no rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony, percussion and a source whose period changes mid-sample all return it, and the shifter's - fixed-window geometry is the documented fallback. + fixed-window geometry is the documented fallback. **Detection analyses the SUSTAIN LOOP when + the capture carries one long enough to host the full search band** (`periodAnalysisSpan`), + otherwise the whole source: the loop is what a Gate voice asymptotically plays, and a phrase + whose head is pitched differently from its sustain would otherwise disagree its way to none. + A shorter loop analyses the whole source rather than a narrowed band — a narrower span may + never buy itself a higher lowest-findable fundamental. - `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. diff --git a/src/core/instrument/engine/period_detect.cpp b/src/core/instrument/engine/period_detect.cpp index 266e414..5d9f563 100644 --- a/src/core/instrument/engine/period_detect.cpp +++ b/src/core/instrument/engine/period_detect.cpp @@ -120,30 +120,34 @@ double blockRms(const std::vector& pcm, std::size_t from, std::size } // namespace -PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) { - if (sampleRate <= 0 || pcm.empty()) return {}; +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, + std::size_t spanFrom, std::size_t spanCount) { + if (sampleRate <= 0 || spanCount == 0) return {}; + if (spanFrom > pcm.size() || spanCount > pcm.size() - spanFrom) return {}; const double rate = static_cast(sampleRate); std::size_t lagHi = static_cast(rate / kPeriodDetectMinHz); const std::size_t lagLo = static_cast(rate / kPeriodDetectMaxHz); if (lagLo < 2) return {}; // a rate so low the whole search band collapses // One probe block is W + lagHi frames with W == lagHi (YIN's usual sizing: the analysis - // window must cover the longest lag being tested). A short sample shortens the search + // window must cover the longest lag being tested). A short span shortens the search // rather than refusing outright — a 200 ms one-shot still has a period worth finding. - if (pcm.size() < 2 * lagHi) lagHi = pcm.size() / 2; + if (spanCount < 2 * lagHi) lagHi = spanCount / 2; if (lagHi <= lagLo + 2) return {}; const std::size_t block = 2 * lagHi; - const std::size_t probes = - std::min(kPeriodDetectProbes, std::max(1, pcm.size() / block)); + // Probe POSITIONS, not disjoint blocks — see kPeriodDetectProbes in the header for why + // lagHi is the separation that makes two overlapping probes independent evidence. + const std::size_t room = spanCount - block; + const std::size_t probes = std::min(kPeriodDetectProbes, 1 + room / lagHi); // Room to spare after the last probe's block is spread between them, so the probes sample - // the whole sample rather than only its opening. - const std::size_t stride = probes > 1 ? (pcm.size() - block) / (probes - 1) : 0; + // the whole span rather than only its opening. + const std::size_t stride = probes > 1 ? room / (probes - 1) : 0; std::vector periods; std::vector confidences; for (std::size_t p = 0; p < probes; ++p) { - const std::size_t from = p * stride; - if (from + block > pcm.size()) break; + const std::size_t from = spanFrom + p * stride; + if (from + block > spanFrom + spanCount) break; if (blockRms(pcm, from, block) < kSilenceRms) continue; const std::vector small = decimate(pcm, from, block); @@ -173,6 +177,22 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) if (periods.empty()) return {}; + // ONE surviving probe: the span could not host a second probe position, so there is no + // second estimate for the majority rule below to rule on — it would be deciding on an + // empty comparison. The accept rests on pickPeriod's absolute threshold, which is a real + // test and not an absence of one: the block genuinely repeats at this lag across its whole + // analysis window. Refusing instead would deny every short one-shot a period, and a period + // that turns out wrong costs a mis-centred correlation search at the splice, not an + // unrefined one (pitch_shift.cpp's splice searches +/- maxLag around whichever jump it is + // handed). Do not "unify" this back into the majority test — at size 1 that test accepts + // unconditionally, which is the same behaviour with none of the reasoning. + if (periods.size() == 1) { + PeriodEstimate lone; + lone.frames = periods[0]; + lone.confidence = confidences[0]; + return lone; + } + std::vector sorted = periods; std::sort(sorted.begin(), sorted.end()); const double median = sorted[sorted.size() / 2]; @@ -192,6 +212,7 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) // first half is one period and second half another gives two probes each way, and taking // either as "the" period would misalign every splice in the other half. Refusing is the // right answer there — the fixed-window fallback is what a source with no ONE period gets. + // Reached only with two or more probes; the lone-probe case returned above. if (agree * 2 <= periods.size()) return {}; PeriodEstimate est; @@ -200,4 +221,24 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) return est; } +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) { + return detectPeriod(pcm, sampleRate, 0, pcm.size()); +} + +AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart, + std::int64_t loopEnd, bool hasLoop, int sampleRate) { + const AnalysisSpan whole{0, frameCount}; + if (!hasLoop || sampleRate <= 0) return whole; + if (loopStart < 0 || loopEnd <= loopStart) return whole; + if (static_cast(loopEnd) > frameCount) return whole; + + const std::size_t length = static_cast(loopEnd - loopStart); + // One full probe block. Below it detectPeriod shortens lagHi to fit, which raises the + // lowest findable fundamental — the one thing the narrower span may never cost. + const std::size_t minimum = + 2 * static_cast(static_cast(sampleRate) / kPeriodDetectMinHz); + if (length < minimum) return whole; + return AnalysisSpan{static_cast(loopStart), length}; +} + } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/period_detect.h b/src/core/instrument/engine/period_detect.h index f07112b..1d74bf2 100644 --- a/src/core/instrument/engine/period_detect.h +++ b/src/core/instrument/engine/period_detect.h @@ -6,6 +6,7 @@ // translation unit on the render path can name detectPeriod. A sampler's source is fixed and // fully known at load, which is the whole reason a detector is affordable here at all. +#include #include #include @@ -19,9 +20,12 @@ using audio::AudioSample; // authored and never persisted — this is a cache, not state. struct PeriodEstimate { double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise) - // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic only today: the - // accept decision is `valid()` alone, and the loader takes `.frames` without reading this — - // do not assume it is load-bearing without checking who reads it. + // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic: the accept decision + // is `valid()` alone and the loader takes `.frames` without reading this — its consumers are + // the tests and the measurement harness. It is deliberately NOT a second accept gate: every + // accepted probe already cleared kPeriodDetectThreshold, so confidence > 0.88 holds by + // construction and any gate below that is a no-op while any gate above it is a tuned number + // with nothing to derive it from. double confidence = 0.0; bool valid() const { return frames > 0.0; } @@ -43,15 +47,51 @@ inline constexpr double kPeriodDetectThreshold = 0.12; // How many blocks across the sample are estimated independently, and how far apart two of them // may land and still be called the same period. Agreement is what separates a genuinely // periodic source from one whose opening happens to look periodic. +// +// Probes are placed by POSITION and may overlap: what the rule needs is estimates from +// different places in the source, and two blocks a full longest-lag apart already differ by a +// whole cycle of the lowest frequency in the band, so neither can be a trivially shifted copy +// of the other at any period searched. Requiring DISJOINT blocks instead left every source +// under ~4x the longest lag with a single probe and so with no agreement to check at all. +// One probe survives as an irreducible case below `block + longest lag` frames and is accepted +// on the absolute threshold alone — see detectPeriod's contract. inline constexpr int kPeriodDetectProbes = 4; inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median -// Estimates `pcm`'s fundamental period at `sampleRate`. Cost is bounded by the constants above, -// not by the sample length: at most kPeriodDetectProbes blocks of ~2 x the longest searched lag -// are analysed however long the source is. Allocates; never call from process(). +// Estimates the fundamental period of `pcm[from, from+count)` at `sampleRate`. Cost is bounded +// by the constants above, not by the span length: at most kPeriodDetectProbes blocks of ~2 x +// the longest searched lag are analysed however long the span is. Allocates; never call from +// process(). An out-of-range span estimates nothing and returns none. // // Returns an invalid estimate (frames == 0) for silence, noise, and anything whose probes // disagree — the caller's documented fallback is the fixed-window splice geometry. +// +// Two probes or more must reach a STRICT MAJORITY agreement. A lone probe — which only happens +// on a span too short to host a second probe position — is accepted on the absolute threshold +// alone, because there is no second estimate for a majority rule to rule on and refusing would +// deny every short one-shot a period. +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, + std::size_t from, std::size_t count); + +// The whole source. PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate); +// The frames detection should analyse for a capture that carries a sustain loop, and the reason +// the answer is not simply "all of them": under Gate the loop region is asymptotically ALL the +// splicer plays, so a phrase whose head is pitched differently from its sustain would otherwise +// disagree its way to none over the whole source. `[loopStart, loopEnd)` is used only when it +// is at least `2 * (sampleRate / kPeriodDetectMinHz)` frames — the span below which detectPeriod +// starts shortening its own search band — so choosing the narrower span never costs search-band +// width and so can never lose a low fundamental that the whole source would have found. +// Anything else (no loop, an out-of-range span, a short one) yields the whole source. +// +// The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is +// on a cache input, not a second validity rule, and it refuses rather than repairs the same way. +struct AnalysisSpan { + std::size_t from = 0; + std::size_t count = 0; +}; +AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart, + std::int64_t loopEnd, bool hasLoop, int sampleRate); + } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 38f1e49..96ceb14 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -329,9 +329,14 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) data.play = resolvePlay(resolved.play, data.sampleRate); // The one place Preserve's source period is computed: the load, off the audio thread. // Channel 0 only — a stereo pair's two channels share a fundamental, and the splice - // schedule is linked across them anyway. + // schedule is linked across them anyway. The span is the sustain loop where one is long + // enough (periodAnalysisSpan owns that rule) — every input to it commits through a full + // reload, so the cache is re-derived whenever the span it was chosen from moves. + const instrument::engine::AnalysisSpan span = instrument::engine::periodAnalysisSpan( + data.frames.size(), data.loop.start, data.loop.end, data.loop.hasLoop, data.sampleRate); data.sourcePeriodFrames = - instrument::engine::detectPeriod(data.frames, data.sampleRate).frames; + instrument::engine::detectPeriod(data.frames, data.sampleRate, span.from, span.count) + .frames; return data; } diff --git a/tests/test_period_detect.cpp b/tests/test_period_detect.cpp index b42b6d9..57a610a 100644 --- a/tests/test_period_detect.cpp +++ b/tests/test_period_detect.cpp @@ -10,15 +10,19 @@ // 3. graceful degradation — noise, silence, and a source whose period changes mid-sample all // return NONE. That is the contract the shifter's fixed-window fallback rests on: an // estimate that is merely wrong would misalign every splice, which is worse than none. -// 4. the band edges and the short-sample path. -// 5. what the load pays, and that it does not grow with the sample length. +// 4. the band edges and the short-sample path, including the lone-probe accept. +// 5. the analysis span: a sustain loop stands in for the whole source, but never at the cost +// of search-band width. +// 6. what the load pays, and that it does not grow with the sample length. #include "../src/core/instrument/engine/period_detect.h" #include #include +#include #include #include +#include #include using namespace reasampler; @@ -217,7 +221,144 @@ static void testAShortSourceShortensTheSearchRatherThanRefusing() { CHECK(!detectPeriod(sineOfPeriod(40, 20.0), 44100).valid()); } -// --- 5. What the load pays ------------------------------------------------------------------ +// A lone probe is the one case the strict-majority rule cannot rule on, so pin BOTH halves of +// the carve-out: which sources land in it, and that they are accepted rather than refused. +// 30 Hz is first-class material here, and a short low-frequency source is exactly where the +// blunt "require two probes" fix would have silently stopped detecting. +static void testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe() { + const int rate = 44100; + const std::size_t lagHi = static_cast(rate / kPeriodDetectMinHz); + const std::size_t block = 2 * lagHi; + // Derive the frame count from the public constants rather than hardcoding one, so this test + // keeps naming the lone-probe case if the geometry ever moves. One probe fits while the + // span leaves less than lagHi of room after the first block. + const std::size_t frames = block + lagHi - 1; // 8819 at 44.1k -> exactly one probe + CHECK(1 + (frames - block) / lagHi == 1); + + const double p = static_cast(rate) / 30.0; // 1470 frames + const PeriodEstimate est = detectPeriod(sineOfPeriod(frames, p), rate); + std::printf(" lone probe, %zu frames @ 30 Hz -> %s (%.3f, want %.3f)\n", frames, + est.valid() ? "detected" : "NONE", est.frames, p); + CHECK(est.valid()); + if (est.valid()) CHECK(std::fabs(est.frames - p) < 1.0); + + // One frame more buys a second probe position; the answer must not change character. + const PeriodEstimate two = detectPeriod(sineOfPeriod(frames + 1, p), rate); + CHECK(1 + (frames + 1 - block) / lagHi == 2); + CHECK(two.valid()); + if (two.valid()) CHECK(std::fabs(two.frames - p) < 1.0); +} + +static void testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected() { + // 30 Hz and 29 Hz — the pair the Preserve geometry work is measured against. 29 Hz is the + // sharper case: its period does not divide the splice window, so the shifter needs the + // detected value to be right rather than merely present. + for (double hz : {30.0, 29.0}) { + const double p = 44100.0 / hz; + const PeriodEstimate est = detectPeriod(sineOfPeriod(160000, p), 44100); + std::printf(" %.0f Hz -> %s (%.3f, want %.3f)\n", hz, est.valid() ? "detected" : "NONE", + est.frames, p); + CHECK(est.valid()); + if (est.valid()) CHECK(std::fabs(est.frames - p) < 0.5); + } +} + +// --- 5. The analysis span ------------------------------------------------------------------- + +static void testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand() { + const int rate = 44100; + const std::size_t frames = 120000; + // One full probe block — the span below which detectPeriod starts shortening its own + // longest lag, which is the only thing the narrower span may never cost. + const std::size_t minimum = 2 * static_cast(rate / kPeriodDetectMinHz); + + // No loop, an inverted span, and a span reaching past the PCM all yield the whole source. + for (const auto& [lo, hi, has] : {std::tuple{0, 0, false}, + {60000, 120000, false}, + {90000, 90000, true}, + {90000, 80000, true}, + {-1, 90000, true}, + {60000, 130000, true}}) { + const AnalysisSpan s = periodAnalysisSpan(frames, lo, hi, has, rate); + CHECK(s.from == 0 && s.count == frames); + } + + // A loop one frame under the minimum falls back to the WIDER span, not to none. + const AnalysisSpan shortLoop = + periodAnalysisSpan(frames, 60000, 60000 + static_cast(minimum) - 1, true, + rate); + CHECK(shortLoop.from == 0 && shortLoop.count == frames); + + // At the minimum exactly, the loop is taken. + const AnalysisSpan atMinimum = + periodAnalysisSpan(frames, 60000, 60000 + static_cast(minimum), true, rate); + CHECK(atMinimum.from == 60000 && atMinimum.count == minimum); + + // And the too-short loop still DETECTS through the wider span — refusing there would be a + // regression against analysing the whole source, and a short sustain loop is common. + const double p = static_cast(rate) / 30.0; + const std::vector src = sineOfPeriod(frames, p); + const PeriodEstimate est = detectPeriod(src, rate, shortLoop.from, shortLoop.count); + std::printf(" short loop -> whole source: %s (%.3f)\n", est.valid() ? "detected" : "NONE", + est.frames); + CHECK(est.valid()); + if (est.valid()) CHECK(std::fabs(est.frames - p) < 0.5); +} + +static void testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop() { + // The case the whole-source analysis cannot answer: the head sustains one pitch, the looped + // tail another. Analysed whole, two probes land each side and the strict-majority rule + // correctly refuses — there is no ONE period over the whole source. But under Gate the + // splicer lives in the loop, whose period is perfectly well defined. + const int rate = 44100; + const std::size_t frames = 120000; + const std::int64_t loopStart = 60000; + const double headPeriod = 300.0; + const double loopPeriod = static_cast(rate) / 30.0; // 1470 frames + + std::vector src(frames); + double phase = 0.0; + for (std::size_t i = 0; i < frames; ++i) { + phase += 2.0 * kPi / + (i < static_cast(loopStart) ? headPeriod : loopPeriod); + src[i] = static_cast(std::sin(phase)); + } + + // BEFORE this rule: the whole source is what was analysed, and it reports none. + const PeriodEstimate whole = detectPeriod(src, rate); + std::printf(" phrase analysed whole -> %s (%.3f)\n", whole.valid() ? "DETECTED" : "none", + whole.frames); + CHECK(!whole.valid()); + + // AFTER: the loop is long enough to host the full band, so it is the analysed span. + const AnalysisSpan span = periodAnalysisSpan(frames, loopStart, + static_cast(frames), true, rate); + CHECK(span.from == static_cast(loopStart)); + const PeriodEstimate looped = detectPeriod(src, rate, span.from, span.count); + std::printf(" phrase analysed over its loop -> %s (%.3f, want %.3f)\n", + looped.valid() ? "detected" : "NONE", looped.frames, loopPeriod); + CHECK(looped.valid()); + if (looped.valid()) CHECK(std::fabs(looped.frames - loopPeriod) < 2.0); + + // The narrowed span must not turn a genuinely aperiodic loop into a period: same geometry, + // noise in the loop region. + std::vector noisyLoop = src; + std::uint32_t rng = 777u; + for (std::size_t i = static_cast(loopStart); i < frames; ++i) { + rng = rng * 1664525u + 1013904223u; + noisyLoop[i] = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); + } + CHECK(!detectPeriod(noisyLoop, rate, span.from, span.count).valid()); +} + +static void testAnOutOfRangeSpanEstimatesNothing() { + const std::vector src = sineOfPeriod(120000, 441.0); + CHECK(!detectPeriod(src, 44100, 120001, 10).valid()); + CHECK(!detectPeriod(src, 44100, 119000, 5000).valid()); + CHECK(!detectPeriod(src, 44100, 0, 0).valid()); +} + +// --- 6. What the load pays ------------------------------------------------------------------ // The whole reason a detector is affordable in a sampler is that it runs ONCE, off the audio // thread, on a source that is already fully known. This prints what that once costs, and @@ -255,6 +396,11 @@ int main() { testAPercussiveDecayIsNotForcedIntoAPeriod(); testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple(); testAShortSourceShortensTheSearchRatherThanRefusing(); + testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe(); + testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected(); + testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand(); + testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop(); + testAnOutOfRangeSpanEstimatesNothing(); testDetectionCostIsBoundedRegardlessOfSampleLength(); if (g_fail == 0) { diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index a32aa32..e1f221b 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -926,6 +926,45 @@ static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() { CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz } +// The span half of the same wire: buildSampleData must hand detection the LOOP region when the +// capture carries one, not the whole decoded PCM. Asserted through the real build for the same +// reason as the test above — period_detect's own coverage cannot see which span the loader picks. +static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() { + const int rate = 44100; + const std::size_t frames = 120000; + const std::int64_t loopStart = 60000; + const double kPi = 3.14159265358979323846; + const double loopPeriod = static_cast(rate) / 30.0; // 1470 frames + + // Head at 147 Hz, looped tail at 30 Hz: analysed whole, the probes split two-and-two and + // detection correctly refuses. Analysed over the loop, the 30 Hz sustain is unambiguous. + std::vector pcm(frames); + double phase = 0.0; + for (std::size_t i = 0; i < frames; ++i) { + phase += 2.0 * kPi / (i < static_cast(loopStart) ? 300.0 : loopPeriod); + pcm[i] = static_cast(std::sin(phase)); + } + + InstrumentParams noLoop; + const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop), + DecodedPcm{pcm, rate, {}}); + CHECK(bare.sourcePeriodFrames == 0.0); // no loop -> whole source -> no ONE period + + InstrumentParams looped; + looped.loopOverride = SampleLoop{true, loopStart, static_cast(frames)}; + const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), looped), + DecodedPcm{pcm, rate, {}}); + CHECK(std::fabs(sd.sourcePeriodFrames - loopPeriod) < 2.0); + + // A loop too short to host the full search band falls back to the whole source rather than + // to none — here that whole source has no one period, so the answer is the bare one above. + InstrumentParams shortLoop; + shortLoop.loopOverride = SampleLoop{true, 118000, static_cast(frames)}; + const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop), + DecodedPcm{pcm, rate, {}}); + CHECK(shortSd.sourcePeriodFrames == bare.sourcePeriodFrames); +} + static void testBuildSampleDataCarriesTheVelocityCurve() { InstrumentParams p; p.velocityCurve = VelocityCurve::linear(); @@ -994,6 +1033,7 @@ int main() { testBuildSampleDataEmptyPcmIsUnplayable(); testBuildSampleDataCarriesTheVelocityCurve(); testBuildSampleDataDetectsThirtyHertzSourcePeriod(); + testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; From 79189bd316a71758f4e08a7eaff335713d1e7984 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 00:17:42 -0400 Subject: [PATCH 21/56] docs: fix stale v14 limiter rung in shell/instrument/CLAUDE.md to v15 --- src/shell/instrument/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index bf0774a..1802e8c 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -105,7 +105,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h ## Modules - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. -- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published per block as relaxed atomics (per-channel peak, latched clip, the block's smallest limiter gain). The limiter's enable is persisted in the parameter set (params payload v14) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published per block as relaxed atomics (per-channel peak, latched clip, the block's smallest limiter gain). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. From e7d7e7020155acf1f3d8c6112c786d1408f54ba8 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 02:46:46 -0400 Subject: [PATCH 22/56] docs: fix stale Gamma payload-rung numbers in instrument-control-surface (v14/v15 -> v15/v16) --- docs/product/instrument-control-surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index b406e8d..a2ba3cc 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -1522,7 +1522,7 @@ The wave boundaries are collision boundaries, not preferences: `deck_values.cpp` by W1-T1 then W2-T1; `editor_paint_waveform.cpp` by W1-T3 then W2-T2; `deck_groups.cpp` by W1-T4 (the row predicate) then W2-T1 (the descriptor) then W3-T1 (the row consumption); `voice.cpp` by W1-T5 then W2-T1; and **one params-payload version bump per wave, owned by one -track** (W1-T2 takes v14 for the limiter flag, W2-T1 takes v15 for rate + pitch offset) — the +track** (W1-T2 takes v15 for the limiter flag, W2-T1 takes v16 for rate + pitch offset) — the two new W1 tracks take **no rung at all**, so the ladder is unchanged by the resequencing. Two shared files are named rather than discovered at merge: `core/instrument/engine/CMakeLists.txt` inside W1 (T2 | T5) and `editor_session.cpp` inside From 3e4ba628c3964296b0295b4892a2448323dd29a4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 03:42:10 -0400 Subject: [PATCH 23/56] docs: record Gamma-W1-T7 in PLAN.md, the track that landed without an entry --- docs/PLAN.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/PLAN.md b/docs/PLAN.md index 7be35e9..2f7b35e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1390,6 +1390,58 @@ changes duration, so a resampled read is an explicit duration control — but th --- +#### Γ-W1-T7 — `psola-preserve` + +**Goal.** Preserve's splices become pitch-synchronous: the source's fundamental period is +detected offline at sample load, cached, and the splice jump becomes a whole number of that +period, so an aligned landing point exists by construction rather than being searched for. + +**Origin — Daniel's ruling, 2026-08-01, after Γ-W1-T5 landed.** T5's measurement pass showed +the shipped fixed 50 ms OLA window with a ±window/4 correlation search could only reach +landings spanning `[¾w, 1¼w]` — a 1.5:1 span that cannot contain a whole number of periods for +low material. A 30 Hz tone (1470 frames at 44.1 kHz) had no phase-aligned landing at all. A +second, distinct failure mode: splices recur every `window/|rate − shift|` frames and fail +when that interval is shorter than the output period. Three cheaper options were offered and +declined: widening `maxLag` to `window/2` (fixes the geometry, not the cadence); sizing the +window from the note's known fundamental at note-on (fixes both, but trusts root-note +tagging); enlarging the window to ~200 ms (fixes both, smears transients — the OLA crossfade +is `window/4`). + +**Why this is a new track and not an amendment to Γ-W1-T5.** It touches the sample-load / +analysis path, which is outside T5's stated surface boundary (`pitch_shift`, the stretcher +module, `voice`'s Preserve read path). + +**Why PSOLA is affordable here.** ReaSampler is a sampler, so the source is fixed and fully +known at load. Detection runs once during the reload that already happens, entirely off the +audio thread — the usual real-time objection to PSOLA does not apply. + +**Surface boundary — owns:** a new pure module `core/instrument/engine/period_detect` +(two-pass YIN, with its own `period_detect_tests` target), `pitch_shift`'s jump geometry, the +load-time hook in `map/sample_map`'s `buildSampleData`, and `voice`'s note-on. Adds no +`ComponentState` field and takes **no rung of the payload ladder** — a detected period is +derived from the audio, so it is cache, not state. + +**Behavior and constraints.** +- **Enforcement is by link graph, not by convention.** The detector runs off the audio thread + because `sampler_core` does not link `period_detect` — no translation unit on the render path + can name `detectPeriod`. An unknown period restores the fixed-window geometry byte for byte. +- **Dependency it discharges:** it gates the Rate control (Γ-W2-T1) on the plan's own stated + principle that Rate must not ship before its Preserve engine. + +**Status — landed, with open findings.** +- The **geometry** failure mode is closed and asserted. +- The **cadence** failure mode is **not** closed. It was re-characterized rather than fixed: + the previously-headlined 7–21 % artifact-energy readings turned out to be ~95 % the + measurement's own spectral leakage, leaving a real excess of 0.23–0.51 %. The track asserted + no-regression there rather than claiming an improvement. + +**Open questions.** +- **Unresolved review findings, not a design fork.** A later review of the follow-up fold + returned three unresolved Major findings; remediation has not yet been dispatched. The + findings themselves live in the review, not here. + +--- + ### Γ-W2 — New controls, and the overlay's marks **Depends on Γ-W1 for — four dependencies, two of them new:** From cc4967d21d304d0836a24b377d7585013de8166a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 03:43:21 -0400 Subject: [PATCH 24/56] docs: drop the findings count from T7's open-questions bullet, it drifts as remediation lands --- docs/PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 2f7b35e..8010b5a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1437,7 +1437,7 @@ derived from the audio, so it is cache, not state. **Open questions.** - **Unresolved review findings, not a design fork.** A later review of the follow-up fold - returned three unresolved Major findings; remediation has not yet been dispatched. The + returned unresolved Major findings; remediation has not yet been dispatched. The findings themselves live in the review, not here. --- From 91bd6f51a268d3104a508308e187d436b4e2ddc4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 04:19:59 -0400 Subject: [PATCH 25/56] =?UTF-8?q?Period=20detection:=20silence=20is=20not?= =?UTF-8?q?=20dissent=20but=20an=20absent=20period=20is=20=E2=80=94=20the?= =?UTF-8?q?=20agreement=20denominator=20is=20the=20probes=20that=20carried?= =?UTF-8?q?=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/instrument/engine/period_detect.cpp | 47 +++--- src/core/instrument/engine/period_detect.h | 49 ++++-- tests/test_period_detect.cpp | 151 ++++++++++++++++--- tests/test_sample_map.cpp | 6 +- 4 files changed, 195 insertions(+), 58 deletions(-) diff --git a/src/core/instrument/engine/period_detect.cpp b/src/core/instrument/engine/period_detect.cpp index 5d9f563..35622e0 100644 --- a/src/core/instrument/engine/period_detect.cpp +++ b/src/core/instrument/engine/period_detect.cpp @@ -125,7 +125,7 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, if (sampleRate <= 0 || spanCount == 0) return {}; if (spanFrom > pcm.size() || spanCount > pcm.size() - spanFrom) return {}; const double rate = static_cast(sampleRate); - std::size_t lagHi = static_cast(rate / kPeriodDetectMinHz); + std::size_t lagHi = longestLagFrames(sampleRate); const std::size_t lagLo = static_cast(rate / kPeriodDetectMaxHz); if (lagLo < 2) return {}; // a rate so low the whole search band collapses @@ -145,10 +145,14 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, std::vector periods; std::vector confidences; + // Probes that carried signal — the agreement denominator. A silent block is no evidence + // either way and is excluded; every other outcome, a period found or not, is evidence. + std::size_t evidence = 0; for (std::size_t p = 0; p < probes; ++p) { + // (probes - 1) * stride <= room by construction, so the last block always fits. const std::size_t from = spanFrom + p * stride; - if (from + block > spanFrom + spanCount) break; if (blockRms(pcm, from, block) < kSilenceRms) continue; + ++evidence; const std::vector small = decimate(pcm, from, block); const std::size_t smallHi = lagHi / kDecimate; @@ -177,16 +181,17 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, if (periods.empty()) return {}; - // ONE surviving probe: the span could not host a second probe position, so there is no - // second estimate for the majority rule below to rule on — it would be deciding on an - // empty comparison. The accept rests on pickPeriod's absolute threshold, which is a real - // test and not an absence of one: the block genuinely repeats at this lag across its whole - // analysis window. Refusing instead would deny every short one-shot a period, and a period - // that turns out wrong costs a mis-centred correlation search at the splice, not an - // unrefined one (pitch_shift.cpp's splice searches +/- maxLag around whichever jump it is - // handed). Do not "unify" this back into the majority test — at size 1 that test accepts - // unconditionally, which is the same behaviour with none of the reasoning. - if (periods.size() == 1) { + // ONE piece of evidence in the whole span — either it hosted a single probe position, or + // every other probe was silent. Nothing can rule against this estimate, so the accept rests + // on pickPeriod's absolute threshold, which is a real test and not an absence of one: the + // block genuinely repeats at this lag across its whole analysis window. Refusing instead + // would deny every short one-shot a period, and a period that turns out wrong costs a + // mis-centred correlation search at the splice, not an unrefined one (pitch_shift.cpp's + // splice searches +/- maxLag around whichever jump it is handed). Do not "unify" this back + // into the majority test — at one piece of evidence that test accepts unconditionally, which + // is the same behaviour with none of the reasoning. Nor key it on how many probes SURVIVED: + // one survivor out of four that all carried signal is not this case at all. + if (evidence == 1) { PeriodEstimate lone; lone.frames = periods[0]; lone.confidence = confidences[0]; @@ -208,12 +213,15 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, confSum += confidences[i]; ++agree; } - // A STRICT MAJORITY of the valid probes must agree, not merely two of them: a source whose - // first half is one period and second half another gives two probes each way, and taking - // either as "the" period would misalign every splice in the other half. Refusing is the - // right answer there — the fixed-window fallback is what a source with no ONE period gets. - // Reached only with two or more probes; the lone-probe case returned above. - if (agree * 2 <= periods.size()) return {}; + // A STRICT MAJORITY of the probes that carried signal must agree, not merely two of them: a + // source whose first half is one period and second half another gives two probes each way, + // and taking either as "the" period would misalign every splice in the other half. Refusing + // is the right answer there — the fixed-window fallback is what a source with no ONE period + // gets. The denominator is `evidence` and not `periods.size()` because once probes overlap + // a straddling block finds no period at all rather than a third one, and counting only the + // survivors turned that two-and-two split into a two-of-three accept. + // Reached only with two or more pieces of evidence; the lone case returned above. + if (agree * 2 <= evidence) return {}; PeriodEstimate est; est.frames = sum / static_cast(agree); @@ -235,8 +243,7 @@ AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart, const std::size_t length = static_cast(loopEnd - loopStart); // One full probe block. Below it detectPeriod shortens lagHi to fit, which raises the // lowest findable fundamental — the one thing the narrower span may never cost. - const std::size_t minimum = - 2 * static_cast(static_cast(sampleRate) / kPeriodDetectMinHz); + const std::size_t minimum = 2 * longestLagFrames(sampleRate); if (length < minimum) return whole; return AnalysisSpan{static_cast(loopStart), length}; } diff --git a/src/core/instrument/engine/period_detect.h b/src/core/instrument/engine/period_detect.h index 1d74bf2..c2605f7 100644 --- a/src/core/instrument/engine/period_detect.h +++ b/src/core/instrument/engine/period_detect.h @@ -21,8 +21,8 @@ using audio::AudioSample; struct PeriodEstimate { double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise) // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic: the accept decision - // is `valid()` alone and the loader takes `.frames` without reading this — its consumers are - // the tests and the measurement harness. It is deliberately NOT a second accept gate: every + // is `valid()` alone and the loader takes `.frames` without reading this — its only reader is + // tests/test_period_detect.cpp. It is deliberately NOT a second accept gate: every // accepted probe already cleared kPeriodDetectThreshold, so confidence > 0.88 holds by // construction and any gate below that is a no-op while any gate above it is a tuned number // with nothing to derive it from. @@ -44,6 +44,14 @@ inline constexpr double kPeriodDetectMaxHz = 2000.0; // global minimum — the difference between "quiet but real" and "the least bad of nothing". inline constexpr double kPeriodDetectThreshold = 0.12; +// The longest lag searched, in frames — THE one derivation of it. A probe block is twice this, +// and `periodAnalysisSpan`'s minimum is one block; both read this rather than re-deriving the +// same expression, so "choosing the loop never narrows the search band" is a fact and not a +// coincidence between two literals. +inline std::size_t longestLagFrames(int sampleRate) { + return static_cast(static_cast(sampleRate) / kPeriodDetectMinHz); +} + // How many blocks across the sample are estimated independently, and how far apart two of them // may land and still be called the same period. Agreement is what separates a genuinely // periodic source from one whose opening happens to look periodic. @@ -53,8 +61,6 @@ inline constexpr double kPeriodDetectThreshold = 0.12; // whole cycle of the lowest frequency in the band, so neither can be a trivially shifted copy // of the other at any period searched. Requiring DISJOINT blocks instead left every source // under ~4x the longest lag with a single probe and so with no agreement to check at all. -// One probe survives as an irreducible case below `block + longest lag` frames and is accepted -// on the absolute threshold alone — see detectPeriod's contract. inline constexpr int kPeriodDetectProbes = 4; inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median @@ -66,10 +72,21 @@ inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median // Returns an invalid estimate (frames == 0) for silence, noise, and anything whose probes // disagree — the caller's documented fallback is the fixed-window splice geometry. // -// Two probes or more must reach a STRICT MAJORITY agreement. A lone probe — which only happens -// on a span too short to host a second probe position — is accepted on the absolute threshold -// alone, because there is no second estimate for a majority rule to rule on and refusing would -// deny every short one-shot a period. +// A STRICT MAJORITY of the probes that CARRIED SIGNAL must agree. Silence is excluded from that +// denominator and a failure to find a period is not: a silent block is no evidence either way, +// whereas a block that carries signal and repeats at no lag is evidence against a single period. +// A capture with a silent head or tail therefore still detects, while a mostly-noise source with +// one pitched burst is refused rather than accepted on that burst alone. A LONE piece of +// evidence — the whole span too short for a second probe position, or every other probe silent — +// is accepted on the absolute threshold alone, because there is nothing to rule against it and +// refusing would deny every short one-shot a period. +// +// The answer is NOT monotone in span length, and cannot be made so: no rule that refuses a +// two-and-two split at four probes can also accept a lone probe unconditionally, and the probe +// count steps at 3x, 4x, 5x and 6x the longest lag before saturating. What IS pinned, by a +// length sweep in the tests, is that a STATIONARY source detects at every length — a source +// whose period varies by more than kPeriodDetectAgreeTolerance is the only class that moves +// with the count, and refusing it is this contract's own answer. PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, std::size_t from, std::size_t count); @@ -79,12 +96,20 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) // The frames detection should analyse for a capture that carries a sustain loop, and the reason // the answer is not simply "all of them": under Gate the loop region is asymptotically ALL the // splicer plays, so a phrase whose head is pitched differently from its sustain would otherwise -// disagree its way to none over the whole source. `[loopStart, loopEnd)` is used only when it -// is at least `2 * (sampleRate / kPeriodDetectMinHz)` frames — the span below which detectPeriod -// starts shortening its own search band — so choosing the narrower span never costs search-band -// width and so can never lose a low fundamental that the whole source would have found. +// disagree its way to none over the whole source. `[loopStart, loopEnd)` is used only when it is +// at least one full probe block — `2 * longestLagFrames(sampleRate)`, the span below which +// detectPeriod starts shortening its own search band — so choosing the narrower span never costs +// search-band WIDTH. It can still change the ANSWER: the agreement rule rules on content, so a +// source periodic over most of its length whose loop region is noisy detects whole and refuses +// over the loop. That is the intent — the loop is what a Gate voice plays. // Anything else (no loop, an out-of-range span, a short one) yields the whole source. // +// It takes NO play mode, deliberately, even though loop_span's resolveLoop does and refuses the +// loop outright under Trigger. A loop edit is structurally reload-bound — it moves the PCM span +// this cache was derived from — whereas play mode's exclusion from live delivery is a listed, +// reversible decision (deck_groups' isLiveDeckParam). Keying a load-time cache on it would work +// today and silently serve a stale period the day that decision is revisited. +// // The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is // on a cache input, not a second validity rule, and it refuses rather than repairs the same way. struct AnalysisSpan { diff --git a/tests/test_period_detect.cpp b/tests/test_period_detect.cpp index 57a610a..fc05199 100644 --- a/tests/test_period_detect.cpp +++ b/tests/test_period_detect.cpp @@ -10,7 +10,8 @@ // 3. graceful degradation — noise, silence, and a source whose period changes mid-sample all // return NONE. That is the contract the shifter's fixed-window fallback rests on: an // estimate that is merely wrong would misalign every splice, which is worse than none. -// 4. the band edges and the short-sample path, including the lone-probe accept. +// 4. the band edges and the short-sample path, including the lone-probe accept, the length +// sweep across every probe-count step, and what counts as evidence against a period. // 5. the analysis span: a sustain loop stands in for the whole source, but never at the cost // of search-band width. // 6. what the load pays, and that it does not grow with the sample length. @@ -221,32 +222,118 @@ static void testAShortSourceShortensTheSearchRatherThanRefusing() { CHECK(!detectPeriod(sineOfPeriod(40, 20.0), 44100).valid()); } -// A lone probe is the one case the strict-majority rule cannot rule on, so pin BOTH halves of -// the carve-out: which sources land in it, and that they are accepted rather than refused. -// 30 Hz is first-class material here, and a short low-frequency source is exactly where the -// blunt "require two probes" fix would have silently stopped detecting. +// A lone piece of evidence is the one case the strict-majority rule cannot rule on, so pin both +// halves of the carve-out: which sources land in it, and that they are accepted rather than +// refused. 30 Hz is first-class material here, and a short low-frequency source is exactly where +// the blunt "require two probes" fix would have silently stopped detecting. static void testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe() { const int rate = 44100; - const std::size_t lagHi = static_cast(rate / kPeriodDetectMinHz); - const std::size_t block = 2 * lagHi; - // Derive the frame count from the public constants rather than hardcoding one, so this test - // keeps naming the lone-probe case if the geometry ever moves. One probe fits while the - // span leaves less than lagHi of room after the first block. - const std::size_t frames = block + lagHi - 1; // 8819 at 44.1k -> exactly one probe - CHECK(1 + (frames - block) / lagHi == 1); - + const std::size_t block = 2 * longestLagFrames(rate); + // Exactly one probe BLOCK leaves zero room to place a second probe anywhere, whatever + // separation the placement uses — so this names the lone-probe case independently of the + // formula, where a length derived from that formula would only re-assert it. const double p = static_cast(rate) / 30.0; // 1470 frames - const PeriodEstimate est = detectPeriod(sineOfPeriod(frames, p), rate); - std::printf(" lone probe, %zu frames @ 30 Hz -> %s (%.3f, want %.3f)\n", frames, - est.valid() ? "detected" : "NONE", est.frames, p); - CHECK(est.valid()); - if (est.valid()) CHECK(std::fabs(est.frames - p) < 1.0); + const PeriodEstimate lone = detectPeriod(sineOfPeriod(block, p), rate); + std::printf(" lone probe, %zu frames @ 30 Hz -> %s (%.3f, want %.3f)\n", block, + lone.valid() ? "detected" : "NONE", lone.frames, p); + CHECK(lone.valid()); + if (lone.valid()) CHECK(std::fabs(lone.frames - p) < 1.0); - // One frame more buys a second probe position; the answer must not change character. - const PeriodEstimate two = detectPeriod(sineOfPeriod(frames + 1, p), rate); - CHECK(1 + (frames + 1 - block) / lagHi == 2); - CHECK(two.valid()); - if (two.valid()) CHECK(std::fabs(two.frames - p) < 1.0); + // Growing the span past the lone case must not turn the answer off, and must not change it: + // a probe-count boundary is not allowed to be a discontinuity in what a stationary source + // reports. Asserted over the answer rather than over the count, so it survives the placement + // rule moving. + for (std::size_t extra : {std::size_t{1}, block / 4, block / 2, block, 2 * block}) { + const PeriodEstimate more = detectPeriod(sineOfPeriod(block + extra, p), rate); + CHECK(more.valid()); + if (more.valid()) CHECK(std::fabs(more.frames - p) < 1.0); + } +} + +// The acceptance property behind the position-placement change: a STATIONARY source must not +// lose detection at any length. The probe count steps at 3x, 4x, 5x and 6x the longest lag and +// the agreement bar steps with it, so a length sweep is the only thing that pins the whole +// band — a single 160 000-frame case sits above every step and cannot see them. +static void testAStationarySourceDetectsAtEveryLengthAcrossTheProbeCountSteps() { + const int rate = 44100; + const std::size_t lagHi = longestLagFrames(rate); + int refused = 0; + for (double hz : {30.0, 29.0, 55.0}) { + const double p = static_cast(rate) / hz; + for (std::size_t n = 2 * lagHi; n <= 12 * lagHi; n += lagHi / 4) { + const PeriodEstimate est = detectPeriod(sineOfPeriod(n, p), rate); + if (!est.valid() || std::fabs(est.frames - p) > 1.0) { + ++refused; + std::printf(" %.0f Hz at %zu frames (%.2fx lagHi): %s (%.3f)\n", hz, n, + static_cast(n) / static_cast(lagHi), + est.valid() ? "wrong" : "NONE", est.frames); + } + } + } + CHECK(refused == 0); +} + +// The regime the position-placement change actually moved: probes overlap only while the stride +// is under one block, i.e. below 8x the longest lag. A straddling block finds NO period rather +// than a third one, so counting only the probes that survived turned a genuine two-and-two split +// into a two-of-three accept — a source with two periods reported as having the first one, which +// is the outcome the module calls worse than none. +static void testAPeriodChangeIsRefusedWhereTheProbesOverlapToo() { + const int rate = 44100; + const std::size_t lagHi = longestLagFrames(rate); + for (double mult : {3.0, 4.0, 5.0, 6.0, 7.0}) { + const std::size_t n = static_cast(mult * static_cast(lagHi)); + std::vector src(n); + double phase = 0.0; + for (std::size_t i = 0; i < n; ++i) { + phase += 2.0 * kPi / (i < n / 2 ? 300.0 : 700.0); + src[i] = static_cast(std::sin(phase)); + } + const PeriodEstimate est = detectPeriod(src, rate); + std::printf(" period change over %.0fx lagHi (%zu frames, %.2f s) -> %s (%.3f)\n", mult, + n, static_cast(n) / rate, est.valid() ? "DETECTED" : "none", + est.frames); + CHECK(!est.valid()); + } +} + +// Silence and an absent period are NOT the same evidence, and the agreement denominator has to +// tell them apart: a capture with a silent head still detects, while a source that is mostly +// aperiodic with one pitched burst must not be accepted on that burst alone. +static void testSilenceIsNotDissentButAnAbsentPeriodIs() { + const int rate = 44100; + const std::size_t lagHi = longestLagFrames(rate); + const std::size_t n = 10 * lagHi; + const double p = static_cast(rate) / 30.0; + + for (std::size_t lead : {lagHi, 2 * lagHi, 4 * lagHi}) { + std::vector src(n, 0.0f); + for (std::size_t i = lead; i < n; ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * static_cast(i - lead) / p)); + } + const PeriodEstimate est = detectPeriod(src, rate); + std::printf(" %zu frames of leading silence -> %s (%.3f)\n", lead, + est.valid() ? "detected" : "NONE", est.frames); + CHECK(est.valid()); + if (est.valid()) CHECK(std::fabs(est.frames - p) < 1.0); + } + + // Noise everywhere but the opening: three probes carry signal and find no period, one finds + // one. Counting only the survivor accepted this on a single unopposed estimate. + std::vector burst(n); + std::uint32_t rng = 4242u; + for (auto& x : burst) { + rng = rng * 1664525u + 1013904223u; + x = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); + } + for (std::size_t i = 0; i < 2 * lagHi; ++i) { + burst[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / p)); + } + const PeriodEstimate est = detectPeriod(burst, rate); + std::printf(" noise with one pitched burst -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none", + est.frames); + CHECK(!est.valid()); } static void testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected() { @@ -270,7 +357,20 @@ static void testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand() { const std::size_t frames = 120000; // One full probe block — the span below which detectPeriod starts shortening its own // longest lag, which is the only thing the narrower span may never cost. - const std::size_t minimum = 2 * static_cast(rate / kPeriodDetectMinHz); + const std::size_t minimum = 2 * longestLagFrames(rate); + + // An unusable rate and an empty capture take the same refuse-rather-than-repair path the + // loop-bounds branches do; nothing downstream may see a span it cannot analyse. + const AnalysisSpan noRate = periodAnalysisSpan(frames, 0, 90000, true, 0); + CHECK(noRate.from == 0 && noRate.count == frames); + const AnalysisSpan negRate = periodAnalysisSpan(frames, 0, 90000, true, -44100); + CHECK(negRate.from == 0 && negRate.count == frames); + const AnalysisSpan empty = periodAnalysisSpan(0, 0, 0, true, rate); + CHECK(empty.from == 0 && empty.count == 0); + // An empty capture with a loop still reaching past it refuses to the (empty) whole source + // rather than handing detection a span past the end of the PCM. + const AnalysisSpan emptyLooped = periodAnalysisSpan(0, 0, 44100, true, rate); + CHECK(emptyLooped.from == 0 && emptyLooped.count == 0); // No loop, an inverted span, and a span reaching past the PCM all yield the whole source. for (const auto& [lo, hi, has] : {std::tuple{0, 0, false}, @@ -397,6 +497,9 @@ int main() { testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple(); testAShortSourceShortensTheSearchRatherThanRefusing(); testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe(); + testAStationarySourceDetectsAtEveryLengthAcrossTheProbeCountSteps(); + testAPeriodChangeIsRefusedWhereTheProbesOverlapToo(); + testSilenceIsNotDissentButAnAbsentPeriodIs(); testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected(); testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand(); testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop(); diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index e1f221b..52c03c2 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -957,12 +957,14 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() { CHECK(std::fabs(sd.sourcePeriodFrames - loopPeriod) < 2.0); // A loop too short to host the full search band falls back to the whole source rather than - // to none — here that whole source has no one period, so the answer is the bare one above. + // to none — here that whole source has no one period, so the answer is none. Asserted + // against the literal, not against `bare`: the two agreeing would also hold if both + // regressed together, which is no evidence that the fallback ran. InstrumentParams shortLoop; shortLoop.loopOverride = SampleLoop{true, 118000, static_cast(frames)}; const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop), DecodedPcm{pcm, rate, {}}); - CHECK(shortSd.sourcePeriodFrames == bare.sourcePeriodFrames); + CHECK(shortSd.sourcePeriodFrames == 0.0); } static void testBuildSampleDataCarriesTheVelocityCurve() { From d35a55ec8e071943eabc4cfd244de4f8275415ae Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 03:57:36 -0400 Subject: [PATCH 26/56] docs: correct the Gamma-W1 track count and table, and name T7 as a Rate prerequisite --- docs/PLAN.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 8010b5a..b0a1cba 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -843,8 +843,9 @@ exact interim layout; do not "fix" it in a track that does not own it. ### Γ-W1 — Foundations -**Depends on:** nothing in this phase. **Five tracks, disjoint by surface** — re-verified -against this membership rather than carried over from the four-wave shape: +**Depends on:** nothing in this phase. **Six tracks, disjoint by surface** — re-verified +against this membership rather than carried over from the four-wave shape. The phase's track +numbering runs to T7; **T6 landed within this wave but has no entry in this document**: | Track | Owns | |---|---| @@ -853,6 +854,7 @@ against this membership rather than carried over from the four-wave shape: | **T3** `contour-trace-curves` | `shell/instrument/editor_paint_waveform.cpp`'s staged trace + a **new pure** tessellation module | | **T4** `editor-floor-and-row-law` | `ui/sample_bands.h` (the floor), `ui/knob_deck.h` (budget constants + two invalidated header notes), `ui/deck_groups` (the row predicate **only**), five test fixtures | | **T5** `preserve-time-stretch` | `engine/pitch_shift` + a new pure stretcher module, `engine/voice.{h,cpp}`'s Preserve read path | +| **T7** `psola-preserve` | a **new pure module** `engine/period_detect` (two-pass YIN, with its own `period_detect_tests` target), `pitch_shift`'s jump geometry, the load-time hook in `map/sample_map`'s `buildSampleData`, `voice`'s note-on | **Two shared files in the wave, named rather than discovered at merge.** `src/core/instrument/engine/CMakeLists.txt` — T2 declares two new pure libraries and their @@ -1503,7 +1505,8 @@ do not assume the number), `core/instrument/ui/deck_groups` (the PITCH/RATE descriptor **and** the three-state live predicate), `core/instrument/ui/deck_values` (the two new bindings). **Does not own** the deck's row layout — that is Γ-W3-T1 — nor the time-stretcher itself (Γ-W1-T5, already landed -by the time this track runs). +by the time this track runs) — nor the pitch-synchronous splice geometry that gates Preserve +Rate (Γ-W1-T7, also already landed by the time this track runs). **Behavior.** - **Rate: 50 %–200 %, default 100 % at true knob centre, exponential taper** — 50 % = −12 st, From 248f2f3842239ebc864b9a01c4cc3ceac49943a9 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 05:34:33 -0400 Subject: [PATCH 27/56] PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16 --- cmake/reasampler_targets.cmake | 2 +- src/core/instrument/CLAUDE.md | 11 +- src/core/instrument/engine/live_params.cpp | 2 + src/core/instrument/engine/live_params.h | 8 + src/core/instrument/engine/period_detect.h | 2 +- src/core/instrument/engine/play_params.h | 9 + src/core/instrument/engine/voice.cpp | 66 +++--- src/core/instrument/engine/voice.h | 78 +++++-- src/core/instrument/engine/voice_engine.cpp | 6 +- src/core/instrument/map/component_state_io.h | 22 +- src/core/instrument/map/params_payload.cpp | 28 +++ src/core/instrument/map/params_payload.h | 2 +- src/core/instrument/map/play_seconds.h | 4 + src/core/instrument/map/sample_map.cpp | 2 + src/core/instrument/ui/CMakeLists.txt | 4 +- src/core/instrument/ui/deck_groups.cpp | 36 ++-- src/core/instrument/ui/deck_groups.h | 51 +++-- src/core/instrument/ui/deck_values.cpp | 22 ++ src/core/instrument/ui/deck_values.h | 7 + src/core/instrument/ui/param_taper.cpp | 33 +++ src/core/instrument/ui/param_taper.h | 18 ++ src/shell/instrument/CLAUDE.md | 6 +- src/shell/instrument/editor_controls.cpp | 10 + src/shell/instrument/editor_paint_deck.cpp | 4 +- src/shell/instrument/editor_session.cpp | 5 +- src/shell/instrument/reasampler_editor.h | 4 +- tests/test_component_state_io.cpp | 189 +++++++++++++---- tests/test_deck_groups.cpp | 92 +++++--- tests/test_deck_values.cpp | 86 ++++++++ tests/test_live_delivery.cpp | 132 ++++++++++++ tests/test_param_taper.cpp | 112 ++++++++++ tests/test_sampler_core.cpp | 208 ++++++++++++++++++- 32 files changed, 1098 insertions(+), 163 deletions(-) diff --git a/cmake/reasampler_targets.cmake b/cmake/reasampler_targets.cmake index dfffd1a..561301a 100644 --- a/cmake/reasampler_targets.cmake +++ b/cmake/reasampler_targets.cmake @@ -15,7 +15,7 @@ function(reasampler_pure_library name) # A default-less switch missing an enumerator: MSVC's C4062 is off by its /W1 default; # GCC/Clang's -Wswitch is on by default but only warns without -Werror, and this repo # sets no -Wall/-Werror/-W4/-WX anywhere. Promoted to an error only here, on our own - # pure libraries, so a deliberately default-less switch (e.g. isLiveDeckParam, + # pure libraries, so a deliberately default-less switch (e.g. deckParamCommit, # deck_groups.cpp) is a compile error on every toolchain. NOT C4061 (fires even with # a default: present) — that would light up every defensive switch in the tree. if(MSVC) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index a852599..0fdadbe 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -171,9 +171,10 @@ Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other must live compute, latching the parameters at note on is not acceptable. long term these will be automatable parameters."* It rejects the precedent, not one instance of it. -- **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and - `liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and - why each exclusion is excluded — see there rather than restating the list here. +- **How a control reaches the audio is ONE decision, recorded in ONE place** — `deckParamCommit` + and `liveCommitFor` (`ui/deck_groups`), a THREE-state classification (`Live` / + `NoteOnLatched` / `Reload`) whose header is THE home for where each control sits and why — + see there rather than restating the list here. - **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one block the shell owns per instance. The member-ordering constraint that enforces it, and why, are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice @@ -316,7 +317,7 @@ anything for a trigger shape. - `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`. - `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping. -- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v15), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning. +- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v16), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable, v16 the playback rate + the baseline pitch offset. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning. - `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home. - `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects. - `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer. @@ -354,7 +355,7 @@ anything for a trigger shape. drag the bank model and the WAV codec in behind it. The shell keeps only the controls the parameter set does not carry (key-track, voice count, master gain, preview velocity) and the labels for them. -- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there. +- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there. - `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. diff --git a/src/core/instrument/engine/live_params.cpp b/src/core/instrument/engine/live_params.cpp index c1412a8..2153782 100644 --- a/src/core/instrument/engine/live_params.cpp +++ b/src/core/instrument/engine/live_params.cpp @@ -16,6 +16,8 @@ LiveValues foldLive(const PlayParams& params) { v.adsr = params.adsr; v.ampAhd = params.trigAhd; v.pitchEnv = params.pitchEnv; + v.playRate = params.playRate; + v.pitchOffsetSemitones = params.pitchOffsetSemitones; return v; } diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index f2e2da3..7874011 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -44,6 +44,14 @@ struct LiveValues { AdsrParams adsr{}; AhdParams ampAhd{}; PitchEnvParams pitchEnv{}; + // The block's THIRD commit class, and the reason this comment is here rather than at the + // predicate: playRate is published like any live control but read ONLY at note-on, by + // Voice::start via VoiceEngine::startVoice — never by applyLive on a sounding voice. A live + // rate would mean re-folding an already-resolved sustain loop and re-mapping a contour + // mid-note, both of which are note-on folds. pitchOffsetSemitones has no such tie and is + // ordinarily live. + double playRate = 1.0; + double pitchOffsetSemitones = 0.0; }; // The seqlock copies the block as raw bytes, which is only defensible for a plain value type. diff --git a/src/core/instrument/engine/period_detect.h b/src/core/instrument/engine/period_detect.h index c2605f7..610b93b 100644 --- a/src/core/instrument/engine/period_detect.h +++ b/src/core/instrument/engine/period_detect.h @@ -107,7 +107,7 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) // It takes NO play mode, deliberately, even though loop_span's resolveLoop does and refuses the // loop outright under Trigger. A loop edit is structurally reload-bound — it moves the PCM span // this cache was derived from — whereas play mode's exclusion from live delivery is a listed, -// reversible decision (deck_groups' isLiveDeckParam). Keying a load-time cache on it would work +// reversible decision (deck_groups' deckParamCommit). Keying a load-time cache on it would work // today and silently serve a stale period the day that decision is revisited. // // The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index 6fd2af8..8851210 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -158,6 +158,15 @@ struct PlayParams { TriggerParams trigger; // Trigger play span AhdParams trigAhd; // Trigger amp PitchEngine pitchEngine = PitchEngine::Varispeed; + // Playback RATE, as source frames consumed per output frame. Under Varispeed it is one more + // factor of the read increment, so it moves pitch and duration together; under Preserve it + // drives duration alone and the shifter holds the pitch. Latched at note-on either way (the + // loop fold and the contour scale it composes with are both note-on folds), and clamped by + // the stretcher's own clampStretchRate — never here. 1.0 is the bare engine, bit for bit. + double playRate = 1.0; + // A baseline pitch offset in semitones, folded into the note's ratio beside key-tracking and + // the velocity->pitch transpose. Live on a sounding voice under both engines. + double pitchOffsetSemitones = 0.0; PitchEnvParams pitchEnv; // Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0 // by default, so it transposes nothing until a curve is drawn. Folded into the voice's diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 7da8710..eb0e5cc 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -53,16 +53,26 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick sample_ = &sample; const PlayParams& p = sample.play; - // Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather - // than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed - // read-rate bias and Preserve shift amount both derive from it below). + // Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ rather than + // costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed read-rate + // bias and Preserve shift amount both derive from it below). velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity); - baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_; + pitchOffsetRatio_ = semitoneRatio(p.pitchOffsetSemitones); playMode_ = p.playMode; pitchEngine_ = p.pitchEngine; - // Clamped once here so the read head's increment and the feed cursor's debt accumulate the - // SAME value — they must stay exactly one window apart for the note's whole life. + // THE clamp for both engines — the taper's ends are these bounds, so a knob can never ask for + // a rate this moves. Clamped once here so the read head's increment and the feed cursor's + // debt accumulate the SAME value: they must stay exactly one window apart for the note's + // whole life. stretchRate_ = instrument::engine::clampStretchRate(stretchRate); + // Keyed on the read path this note will ACTUALLY take, which is not the same question as + // the stored engine: advanceFrame runs the Preserve branch only while the shifters are + // configured, and a Preserve voice whose shifters were never sized falls back to the + // varispeed read. Rate has to reach the increment there too, or that fallback would ignore + // the control outright — the predicate is spelled the same way advanceFrame spells it. + const bool preserveRead = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured(); + rateRatio_ = preserveRead ? 1.0 : stretchRate_; + recomputeBaseRatio(); // Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top) // rather than starting a voice already off the end. @@ -119,22 +129,19 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick if (playLen > postStart) playLen = postStart; playEnd_ = start + playLen; trigSpan = playLen; - ampAhd_.configure(playLen, p.trigAhd); + ampAhd_.configure(playLen, rateFittedAhd(p.trigAhd)); } // The pitch AHD's Hold fraction is taken against the whole playable span, so its three // stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count - // and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes - // baseRatio_ source frames per output frame — needs the span converted, or a transposed - // note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source - // rate, so its two domains already coincide. - // Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x - // envFactor — a deep pitch envelope makes this a first-order approximation, not exact. - // Strictly better than the un-converted source-frame span it replaced. - const double pitchSpan = - (pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0)) - ? static_cast(postStart) - : static_cast(postStart) / baseRatio_; + // and this envelope counts OUTPUT frames (envelopes.h), so the span has to be divided by the + // rate the read head consumes source at — baseRatio_ under Varispeed, the stretch rate under + // Preserve — or a transposed (or re-rated) note's envelope outruns the note it shapes. + // Divides by baseRatio_ alone under Varispeed, though the actual read rate is baseRatio_ x + // envFactor — a deep pitch envelope makes that a first-order approximation, not exact. + const double readRate = preserveRead ? stretchRate_ : baseRatio_; + const double pitchSpan = (readRate > 0.0) ? static_cast(postStart) / readRate + : static_cast(postStart); pitchEnv_.configure(static_cast(pitchSpan + 0.5), p.pitchEnv); pitchEnv_.noteOn(); @@ -169,7 +176,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick filterEnv_.configure(p.filter.env); filterEnv_.noteOn(); } else { - filterAhd_.configure(trigSpan, p.filter.trigEnv); + filterAhd_.configure(trigSpan, rateFittedAhd(p.filter.trigEnv)); } filter_.reset(); updateFilterCutoffBase(note); @@ -263,16 +270,25 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { // A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a // flag: a voice that has rendered nothing has no phase to hold and nothing to be // continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h). + // + // live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class, + // delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The + // latched stretchRate_ is what rateFittedAhd converts a live AHD against, so a stage-time + // move mid-note lands in this note's own rate domain rather than resetting it. const bool gate = (playMode_ == PlayMode::Gate); if (snap) { if (gate) env_.snapLive(live.adsr); - else ampAhd_.snapLive(live.ampAhd); + else ampAhd_.snapLive(rateFittedAhd(live.ampAhd)); pitchEnv_.snapLive(live.pitchEnv); } else { if (gate) env_.applyLive(live.adsr); - else ampAhd_.applyLive(sourceOffset(), live.ampAhd); + else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd)); pitchEnv_.applyLive(live.pitchEnv); } + // The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_ + // up as one more factor of next frame's read increment, Preserve as the shifter's transpose. + pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones); + recomputeBaseRatio(); // The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but // pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_, // which already glides through rModAmount_'s live ramp regardless of spline state (below), @@ -283,10 +299,10 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { if (snap) { if (gate) filterEnv_.snapLive(live.filterEnv); - else filterAhd_.snapLive(live.filterAhd); + else filterAhd_.snapLive(rateFittedAhd(live.filterAhd)); } else { if (gate) filterEnv_.applyLive(live.filterEnv); - else filterAhd_.applyLive(sourceOffset(), live.filterAhd); + else filterAhd_.applyLive(sourceOffset(), rateFittedAhd(live.filterAhd)); } filterCutoffNorm_ = static_cast(live.filterSettings.cutoffNorm); filterKeyTrack_ = live.filterKeyTrack; @@ -328,8 +344,8 @@ void Voice::retune(int note) { // baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the // first note's domain — consistent with "touch nothing else," but the drift lives here. // The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ — - // one gesture, one strike. - baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_; + // one gesture, one strike. Rate and the Pitch offset ride through too: only the note moved. + recomputeBaseRatio(); // Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it // too. The velocity offset deliberately stays the first note's, matching velocityGain_. if (filterOn_) updateFilterCutoffBase(note); diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 1349f9d..7c119ee 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -50,12 +50,17 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) { return std::pow(2.0, semis / 12.0); } -// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which -// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields -// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing. +// 2^(semitones/12). Exactly 1.0 at zero — and it SKIPS the pow there, so an unset offset +// transposes nothing and costs nothing. +inline double semitoneRatio(double semitones) { + return (semitones == 0.0) ? 1.0 : std::pow(2.0, semitones / 12.0); +} + +// The velocity->pitch transpose, which the voice folds into baseRatio_ once at note-on. A curve +// flat at 0 — the default — yields EXACTLY 1.0 at every velocity. inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) { - const double semis = curve.eval(static_cast(velocity)) * kVelocityPitchRangeSemitones; - return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0); + return semitoneRatio(curve.eval(static_cast(velocity)) * + kVelocityPitchRangeSemitones); } // One octave expressed in the cutoff control's normalized domain, read out of the filter @@ -110,11 +115,15 @@ public: // the difference-seeded declick compensation on the first frame after the restart (see // kDeclickDecay above). A fresh start never declicks. // - // `stretchRate` is the PRESERVE playback rate — source frames consumed per output frame, - // clamped to [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an - // argument, not a member set separately) because the loop fold and the contour scale it - // composes with are both note-on folds. Varispeed ignores it: there, rate is a factor of the - // read increment, not a second rate. 1.0 is the shipped Preserve read, bit for bit. + // `stretchRate` is the playback rate — source frames consumed per output frame, clamped to + // [kStretchRateMin, kStretchRateMax]. It is a note-on latch by construction (an argument, not + // a member set separately) because the loop fold and the contour scale it composes with are + // both note-on folds. Under Preserve it is the stretcher's feed rate and duration alone moves; + // under Varispeed it folds into the read increment beside key-tracking, so pitch moves with + // it. 1.0 is the bare engine, bit for bit, in both. Defaulted so a caller with no live block + // to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value — + // sample.play.playRate is NOT read here, because the published block outranks the snapshot's + // possibly-stale copy of it. void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false, double stretchRate = 1.0); @@ -184,6 +193,34 @@ public: } private: + // THE fold of every pitch factor that is constant for the note into one number, so + // advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has + // always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset, + // and the Rate ratio — which start() zeroes out of this product when the note is running the + // Preserve read, since Rate feeds stretch_ (duration) there and must never reach the + // shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame. + void recomputeBaseRatio() { + if (sample_ == nullptr) return; + baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) * + velPitchRatio_ * pitchOffsetRatio_ * rateRatio_; + } + + // A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the + // sustain-less envelopes are evaluated in (sourceOffset()). Rate stretches the source span + // those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate — multiplying by + // the read rate is exactly that conversion. The Varispeed PITCH coupling is deliberately NOT + // compensated here: it predates Rate and is the shipped behaviour. Rate 1.0 returns the + // argument untouched, which is what keeps the unity render bit-identical. + AhdParams rateFittedAhd(const AhdParams& a) const { + if (stretchRate_ == 1.0) return a; + AhdParams out = a; + out.attackFrames = + static_cast(static_cast(a.attackFrames) * stretchRate_ + 0.5); + out.decayFrames = + static_cast(static_cast(a.decayFrames) * stretchRate_ + 0.5); + return out; + } + // The read head as a fraction of the whole sample — the domain every spline EG is a pure // function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on // its opening value. @@ -192,9 +229,9 @@ private: // This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read // at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once // per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD - // is evaluated at the source offset (readPos - startFrame) — see the `ratio_ = stretchRate_` - // note below for what that means for Preserve's stage-time/rate coupling. Sets - // amplitudeDone_ on finish so advanceFrame frees the voice. + // is evaluated at the source offset (readPos - startFrame), which is why its stage frames are + // fitted to the read rate at configure time (rateFittedAhd). Sets amplitudeDone_ on finish so + // advanceFrame frees the voice. double tickAmplitude() { double amp; // playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it — @@ -532,15 +569,8 @@ private: // Everything downstream of it (the loop wrap, the Trigger span, the spline phase) // therefore stays a source-frame fact and scales by construction. // - // Consequence (§2.4 of instrument-control-surface.md is explicit that staged - // envelopes' stage times are wall-clock and do NOT scale with rate): Trigger's amp - // AHD and filter AHD are both evaluated at sourceOffset() = readPos_ - startFrame_ - // (tickAmplitude/tickFilterCutoff above), which now advances at stretchRate_ instead - // of always 1.0 — so those two envelopes will scale with a future non-unity Rate. - // This is NEW here: Preserve's ratio_ was pinned at 1.0 before this track, so those - // stage times were exact wall-clock. It is latent (nothing publishes a non-unity - // rate yet) and owned by the track that adds the Rate control, not this one — Gate's - // AHDSR (env_.tick(), per-output-frame) and every spline contour are unaffected. + // The two sustain-less envelopes are evaluated at sourceOffset(), which advances at + // this rate — rateFittedAhd is what keeps their stage times wall-clock anyway. ratio_ = stretchRate_; } else { // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the @@ -645,8 +675,10 @@ private: bool releasing_ = false; int note_ = 0; double velocityGain_ = 1.0; - double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in + double baseRatio_ = 1.0; // recomputeBaseRatio's product: every constant pitch factor double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it + double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive + double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1 double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame) double readPos_ = 0.0; // fractional frame index into the sample const SampleData* sample_ = nullptr; diff --git a/src/core/instrument/engine/voice_engine.cpp b/src/core/instrument/engine/voice_engine.cpp index 9c106c5..2417d57 100644 --- a/src/core/instrument/engine/voice_engine.cpp +++ b/src/core/instrument/engine/voice_engine.cpp @@ -54,7 +54,11 @@ void VoiceEngine::applyLiveToActive() { void VoiceEngine::startVoice(Voice& voice, int note, int velocity) { refreshLive(); - voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_); + // THE read of the note-on-latched commit class, and the only one: a published block outranks + // the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below + // never touches the rate — so a Rate move reaches the next note and no sounding one. + const double rate = haveLive_ ? live_.playRate : sample_.play.playRate; + voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate); if (haveLive_) voice.applyLive(live_, /*snap=*/true); voice.setStartOrder(nextStartOrder_++); } diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index 287d616..59eaaf1 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -8,7 +8,7 @@ // own links are velocity_curve + master_gain (wire value validation), never the engine. // // EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params -// payload v1..v15) must be preserved exactly. This header is the ONE home for both ladders +// payload v1..v16) must be preserved exactly. This header is the ONE home for both ladders // and every version constant; the payload half is IMPLEMENTED in params_payload. #include @@ -129,13 +129,23 @@ namespace reasampler::instrument::map { // A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record — // the same revive discipline the v13 hard-flag tails follow, and for the same reason. // -// v15 (CURRENT WRITE FORMAT) is v14 PLUS ONE byte: the master-bus limiter's enable, appended -// after the Hold division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed, +// v15 is v14 PLUS ONE byte: the master-bus limiter's enable, appended after the Hold +// division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed, // which is also the field's product default, so a project saved before the limiter existed // reopens with the limiter off and sounding identical. It carries the Hold's revive // discipline too: now that it, not the Hold, is the last tail, a truncation inside this byte // would otherwise reset the record the Hold's own revive just preserved. // +// v16 (CURRENT WRITE FORMAT) is v15 PLUS TWO 8-byte LE doubles, appended after the limiter +// byte: the playback RATE as a ratio, then the baseline PITCH offset in semitones. A v15-or- +// older blob is a strict prefix and lifts to 1.0 / 0.0 — unity rate and no offset, which is +// what every instance before them played, so it reopens bit-identical. Both are rate-free +// values, so nothing about them is resolved against the project rate. Same revive-and-drain +// discipline as the two tails above. The two wire GUARDS deliberately differ, and +// readRateAndPitchOffset owns why: the offset is range-checked here because nothing downstream +// bounds it, while the rate is only checked for usability because its range belongs to the +// engine's own clamp. +// // The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in // shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced // them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to @@ -167,7 +177,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // The params-payload format version and its detection marker. The marker is a high sentinel // no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so // a reader detects record shape independent of the envelope version. -inline constexpr std::uint32_t kParamsPayloadVersion = 15; // v14 + the limiter enable +inline constexpr std::uint32_t kParamsPayloadVersion = 16; // v15 + Rate and the pitch offset inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u; // The first SINGLE-RECORD payload version. Everything below it is a retired zone list and @@ -203,6 +213,10 @@ inline constexpr std::uint32_t kParamsBakeHoldVersion = 14; // kParamsPayloadVersion. inline constexpr std::uint32_t kParamsLimiterVersion = 15; +// v15 + the playback rate and the baseline pitch offset; the appended pair branches on THIS, +// never on kParamsPayloadVersion. +inline constexpr std::uint32_t kParamsRateVersion = 16; + // (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to // seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter // (frames / projectRate = seconds) — the same rate the build already receives, so the diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index 1aa523b..60081e9 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -263,6 +263,30 @@ void readLimiterEnable(ByteReader& r, InstrumentParams& p) { p.limiterEnabled = (flag != 0); } +// Read the v16 rate + pitch-offset pair. A truncation, or either value unusable, leaves the +// neutral the field already holds — unity rate, no offset — which is exactly what a pre-v16 +// blob means and what every instance before them played. +// +// The two guards are deliberately DIFFERENT. Rate gets finiteness only, because its range is the +// stretcher's and clampStretchRate is the one authority on it — a second range test here is +// exactly the second clamp that could disagree. The offset gets a real range test, because +// nothing downstream bounds it: it reaches 2^(x/12) and then a read increment, and a wild +// exponent there is UB on the per-sample path. +void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) { + const bool enteredOk = r.ok; + const double rate = bitsToDouble(r.u64()); + const double offset = bitsToDouble(r.u64()); + if (reviveTruncatedTail(r, enteredOk)) return; + if (std::isfinite(rate) && rate > 0.0) p.play.playRate = rate; + // The throw is kVelocityPitchRangeSemitones — the SAME +/-24 the pitch envelope's depth and + // the velocity->pitch curve speak (play_params.h), reached directly rather than through the + // deck's alias of it. + if (std::isfinite(offset) && offset >= -kVelocityPitchRangeSemitones && + offset <= kVelocityPitchRangeSemitones) { + p.play.pitchOffsetSemitones = offset; + } +} + // Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default, // which is what makes a v8 blob play bit-identically under the new codec. The curve reads as // bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its @@ -501,6 +525,9 @@ void putParamsPayload(std::vector& out, const InstrumentParams& p) out.push_back(static_cast(p.bakeHold.modifier())); // v15: the master-bus limiter enable. out.push_back(p.limiterEnabled ? 1 : 0); + // v16: the playback rate (a ratio) and the baseline pitch offset (semitones), both rate-free. + putLE(out, doubleToBits(pp.playRate)); + putLE(out, doubleToBits(pp.pitchOffsetSemitones)); } // Read whichever payload shape follows: the single-record shape (v8 onward, growing by @@ -556,6 +583,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) { } if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p); if (pv >= kParamsLimiterVersion) readLimiterEnable(r, p); + if (pv >= kParamsRateVersion) readRateAndPitchOffset(r, p); // A truncated record leaves whatever parsed plus construction defaults for the rest — // the same degrade-don't-throw contract the zone ladder always had. if (!r.ok) return PayloadRead{}; diff --git a/src/core/instrument/map/params_payload.h b/src/core/instrument/map/params_payload.h index 99a291b..95c69f1 100644 --- a/src/core/instrument/map/params_payload.h +++ b/src/core/instrument/map/params_payload.h @@ -5,7 +5,7 @@ // responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay // serialize/deserializeComponentState; nothing outside the codec calls these. // -// The format ladder (payload v1..v11) is documented in component_state_io.h, which stays its +// The format ladder (payload v1..v16) is documented in component_state_io.h, which stays its // one home. EVERY wire format is FROZEN. #include diff --git a/src/core/instrument/map/play_seconds.h b/src/core/instrument/map/play_seconds.h index b91cd78..61e9dcb 100644 --- a/src/core/instrument/map/play_seconds.h +++ b/src/core/instrument/map/play_seconds.h @@ -74,6 +74,10 @@ struct PlaySeconds { TriggerParams trigger; // Trigger play span (%-length) AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction) PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve + // Rate and the baseline pitch offset are both rate-FREE (a ratio and a semitone count), so + // they carry through resolvePlay untouched; play_params.h owns what each one means. + double playRate = 1.0; + double pitchOffsetSemitones = 0.0; PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default FilterSeconds filter; // per-voice filter, off by default diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 96ceb14..417eb30 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -237,6 +237,8 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) { out.trigger = stored.trigger; // fraction, unchanged out.trigAhd = resolveAhd(stored.trigAhd); out.pitchEngine = stored.pitchEngine; + out.playRate = stored.playRate; // a ratio, rate-free + out.pitchOffsetSemitones = stored.pitchOffsetSemitones; // semitones, rate-free out.pitchEnv.enabled = stored.pitchEnv.enabled; out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape); diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 9c428ed..e1cef5a 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -80,9 +80,11 @@ reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands) # from knob_deck. Links the header-only play_seconds, NOT sample_map: PlaySeconds is all a deck # knob edits, and sample_map would drag the bank model and the WAV codec in behind it. Same for # the filter's MorphLaw — an enum, so no filter symbol is linked. +# time_stretch carries Rate's range — the stretcher's own measured bounds, aliased here rather +# than restated so the knob's ends and the engine's clamp cannot disagree. reasampler_pure_library(deck_values SOURCES deck_values.cpp - LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain) + LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain time_stretch) reasampler_test(deck_values LINK deck_values) # The bake Hold knob's value domain. Links the ladder alone — it computes no geometry, so it diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 0c32e5e..a005612 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -23,11 +23,16 @@ std::vector sampleDeckGroups(PlayMode playMode) { const bool trigger = (playMode == PlayMode::Trigger); std::vector out; { + // PITCH/RATE. The three cells make the knob row 180, which is what the group measures + // from; the caption row (caption + gap + two 48px segments) must stay under it, so the + // caption reserve has a hard ceiling of 80 — past that the caption row overtakes the knob + // row and the group grows past 192. Widening the group is not the answer if the text ever + // outgrows 80: narrow the Varisp|Presrv segments to 44 instead. DeckGroupDesc pitch; pitch.id = kGroupPitch; - pitch.captionWidth = 38; + pitch.captionWidth = 70; pitch.captionToggle = {id(DeckParam::kPitchEngine), 48}; - pitch.cellIds = {id(DeckParam::kKeyTrack)}; + pitch.cellIds = {id(DeckParam::kKeyTrack), id(DeckParam::kRate), id(DeckParam::kPitch)}; out.push_back(std::move(pitch)); } { @@ -131,7 +136,7 @@ std::vector sampleDeckGroups(PlayMode playMode) { } DeckRow deckRowFor(DeckGroupId group) { - // Every enumerator listed and no default, on the same gate isLiveDeckParam below relies on. + // Every enumerator listed and no default, on the same gate deckParamCommit below relies on. switch (group) { case kGroupPitch: case kGroupFilter: @@ -180,8 +185,12 @@ DeckParam curveParamFor(DeckParam knob) { } } -bool isLiveDeckParam(DeckParam id) { +LiveCommit deckParamCommit(DeckParam id) { switch (id) { + // The one note-on-latched control; the header owns why. + case DeckParam::kRate: + return LiveCommit::NoteOnLatched; + case DeckParam::kPitch: case DeckParam::kAttack: case DeckParam::kHold: case DeckParam::kDecay: @@ -221,7 +230,7 @@ bool isLiveDeckParam(DeckParam id) { case DeckParam::kFilterEnvReleaseCurve: case DeckParam::kFilterTrigAttackCurve: case DeckParam::kFilterTrigDecayCurve: - return true; + return LiveCommit::Live; // Listed rather than defaulted so a newly added control is a COMPILE error here on // every toolchain — /we4062 on MSVC, -Werror=switch on GCC/Clang, both set on this // library alone in cmake/reasampler_targets.cmake — instead of silently defaulting @@ -249,9 +258,9 @@ bool isLiveDeckParam(DeckParam id) { case DeckParam::kMonoTrigger: case DeckParam::kMasterGain: case DeckParam::kCount: // not a control - return false; + return LiveCommit::Reload; } - return false; // unreachable for a valid enumerator; silences a warning. + return LiveCommit::Reload; // unreachable for a valid enumerator; silences a warning. } OverlayEnv overlayEnvForRadio(int radioId) { @@ -349,17 +358,18 @@ bool deckKnobInert(DeckParam id, const DeckEnableState& state) { } } -bool liveCommitFor(LiveDragKind kind, int paramId) { +LiveCommit liveCommitFor(LiveDragKind kind, int paramId) { switch (kind) { case LiveDragKind::kDeckKnob: - return paramId >= 0 && paramId < static_cast(DeckParam::kCount) && - isLiveDeckParam(static_cast(paramId)); + return (paramId >= 0 && paramId < static_cast(DeckParam::kCount)) + ? deckParamCommit(static_cast(paramId)) + : LiveCommit::Reload; case LiveDragKind::kEnvNode: - return true; + return LiveCommit::Live; case LiveDragKind::kOther: - return false; + return LiveCommit::Reload; } - return false; + return LiveCommit::Reload; } } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index 6a76cbf..c93cbb8 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -32,6 +32,8 @@ enum class DeckParam { kPitchEnvDecay, kPitchEnvDepth, // AHD pitch depth in +/- semitones kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds) + kRate, // playback rate 50..200%, linear in semitones over +/-12 + kPitch, // baseline pitch offset, +/-kPitchDepthMaxSemis, centre-expanded // Filter. The four control positions map through filter_params' own laws; the three // depths are bipolar and centred at zero. kFilterEnable, // filter on|off caption toggle @@ -131,15 +133,24 @@ std::vector sampleDeckGroups(PlayMode playMode); // ordinary knob grab. DeckParam curveParamFor(DeckParam knob); -// Whether control `id` is delivered LIVE — straight to the voices that are already sounding — -// rather than through an instrument reload. The line is drawn at continuously-valued playback -// controls, so this is a routing decision at the editor's commit site rather than a property -// of any one knob; moving a control across the line is a change here and nowhere else. +// How an edit to a control reaches the audio — THE one decision, and the home for why each +// control sits where it does. Moving a control across a line is a change here and nowhere else, +// and Γ-W4-T1 derives the host-exposed parameter set from this same predicate, so a +// misclassification here is a mis-declared parameter there. // -// THE home for why each excluded control is excluded. Three continuous controls are outside -// the live set, plus every discrete toggle and the overlay radios: -// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable) -// name a different sound rather than a different setting of one; +// Live — straight to the voices already sounding. Continuously-valued playback +// controls, and the default for anything that is a SETTING of a note rather +// than a fact about it. +// NoteOnLatched — published into the live block like a live control, but read only at +// note-on: a sounding voice keeps the value it started with, the next one +// takes the new one. NOT the reload tier — a swept knob must never trigger a +// WAV re-decode. +// Reload — a bridge read, a re-decode and a fresh engine. +// +// The exclusions from Live, each with its reason: +// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable, +// the three Staged|Spline mode toggles) name a different sound rather than a different +// setting of one; // - the three capture-anchored overrides (root, loop span, start frame) name positions in // the decoded PCM; // - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by @@ -151,18 +162,30 @@ DeckParam curveParamFor(DeckParam knob); // - the overlay radios select what the editor DRAWS and reach no parameter at all. // Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and // inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too. -bool isLiveDeckParam(DeckParam id); +// +// kRate is the one NoteOnLatched control, and the reason is a real feature rather than a +// plumbing detail: loop points and contours both scale with rate, and both are note-on folds — +// resolveLoop runs once per note-on and a contour resolves against the note's own span. A live +// rate would mean re-folding an already-resolved loop and re-mapping a contour mid-note without +// a discontinuity. kPitch is not implicated and is ordinarily Live. +enum class LiveCommit { Live, NoteOnLatched, Reload }; +LiveCommit deckParamCommit(DeckParam id); // The editor drag kinds that can commit live, in this pure module's own vocabulary (the // shell's DragKind maps onto it) so the WHOLE routing decision — not just the predicate — is // testable without a host. enum class LiveDragKind { kOther, kDeckKnob, kEnvNode }; -// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids -// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither -// reaches the enum); an envelope-node drag is live in either mode, since every stage value it -// can reach — AHDSR or AHD, on any of the three envelopes — is itself live. -bool liveCommitFor(LiveDragKind kind, int paramId); +// How a drag of `kind` commits. A deck knob answers per deckParamCommit (negative ids are the +// shell's processor-side sentinels and out-of-range ids are not controls, so neither reaches the +// enum); an envelope-node drag is Live in either mode, since every stage value it can reach — +// AHDSR or AHD, on any of the three envelopes — is itself Live. +// +// Live and NoteOnLatched take the SAME route out of the editor — one publish of the live block, +// no reload, no engine rebuild. They differ only in who reads the published value, which is the +// engine's business (live_params.h), so the shell needs the distinction only to know that +// neither reloads. +LiveCommit liveCommitFor(LiveDragKind kind, int paramId); // Which envelope the waveform overlay draws and edits. Exclusive across the three envelope // decks, and kNone is a valid resting state — the editor opens there. Transient view state: diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index 782ea2f..47d911e 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -23,6 +23,10 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) { case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0; case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0; case DeckParam::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; + case DeckParam::kRate: + return rateNormFromRatio(play.playRate, kRateMinRatio, kRateMaxRatio); + case DeckParam::kPitch: + return depthNormFromSemitones(play.pitchOffsetSemitones, kPitchDepthMaxSemis); case DeckParam::kAttack: return timeNormFromSeconds(play.adsr.attackSeconds); case DeckParam::kHold: return timeNormFromSeconds(play.adsr.holdSeconds); case DeckParam::kDecay: return timeNormFromSeconds(play.adsr.decaySeconds); @@ -109,6 +113,10 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { case DeckParam::kPitchEngine: play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; break; + case DeckParam::kRate: + play.playRate = rateRatioFromNorm(value, kRateMinRatio, kRateMaxRatio); break; + case DeckParam::kPitch: + play.pitchOffsetSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); break; case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break; case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break; case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break; @@ -202,6 +210,8 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { // and resolves to null. double* deckDoubleField(DeckParam id, PlaySeconds& p) { switch (id) { + case DeckParam::kRate: return &p.playRate; + case DeckParam::kPitch: return &p.pitchOffsetSemitones; case DeckParam::kAttack: return &p.adsr.attackSeconds; case DeckParam::kHold: return &p.adsr.holdSeconds; case DeckParam::kDecay: return &p.adsr.decaySeconds; @@ -280,6 +290,10 @@ UnitCategory deckParamUnit(DeckParam id) { case DeckParam::kFilterTrigAttack: case DeckParam::kFilterTrigDecay: return UnitCategory::Milliseconds; + // Rate DISPLAYS as a percent but its unit is the semitone — that is what puts an octave + // and a fifth under Shift, which a whole-percent snap could not reach. + case DeckParam::kRate: + case DeckParam::kPitch: case DeckParam::kPitchEnvDepth: return UnitCategory::Semitones; // The filter's four tone controls read out in Hz / Q / drive depth but snap in whole @@ -325,6 +339,14 @@ double snapDeckParamNorm(DeckParam id, double norm) { case UnitCategory::Milliseconds: return timeNormFromSeconds(snapSecondsToWholeMs(timeSecondsFromNorm(norm))); case UnitCategory::Semitones: + // Rate's semitones live in the ratio domain, so its snap round-trips through the rate + // taper rather than the depth one; the other two share the depth throw. + if (id == DeckParam::kRate) { + return rateNormFromRatio( + snapRateRatioToWholeSemitone( + rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio)), + kRateMinRatio, kRateMaxRatio); + } return depthNormFromSemitones( snapSemitonesToWhole(depthSemitonesFromNorm(norm, kPitchDepthMaxSemis)), kPitchDepthMaxSemis); diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index b27b46f..4e52258 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -8,6 +8,7 @@ #include +#include "core/instrument/engine/time_stretch.h" // kStretchRateMin/Max (Rate's own range) #include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target) #include "core/instrument/ui/deck_groups.h" // DeckParam #include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds @@ -29,6 +30,12 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones; // Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls. inline constexpr double kKeyTrackMax = 2.0; +// Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the +// engine's clamp rather than a second opinion of it. The taper takes them as arguments for the +// same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers. +inline constexpr double kRateMinRatio = engine::kStretchRateMin; +inline constexpr double kRateMaxRatio = engine::kStretchRateMax; + // The normalized [0,1] a control shows: stage times through the shared time taper, levels and // fractions as-is, signed depths through the centre-expanded depth taper, curve exponents over // their logarithmic travel. Controls backed by per-instance state rather than the parameter set diff --git a/src/core/instrument/ui/param_taper.cpp b/src/core/instrument/ui/param_taper.cpp index 59cc49f..cec5549 100644 --- a/src/core/instrument/ui/param_taper.cpp +++ b/src/core/instrument/ui/param_taper.cpp @@ -32,6 +32,10 @@ double depthSpan(double maxSemitones) { return std::log1p(maxSemitones / kDepthOffsetSemitones); } +double rateSpanOctaves(double minRatio, double maxRatio) { + return std::log2(maxRatio / minRatio); +} + } // namespace double timeNormFromSeconds(double seconds) { @@ -66,6 +70,27 @@ double depthSemitonesFromNorm(double norm, double maxSemitones) { return norm > 0.5 ? mag : -mag; } +double rateNormFromRatio(double ratio, double minRatio, double maxRatio) { + if (!(maxRatio > minRatio && minRatio > 0.0)) return 0.5; // degenerate bounds: park at unity + if (!(ratio > minRatio)) return 0.0; // also catches NaN + if (ratio >= maxRatio) return 1.0; + if (ratio == 1.0) return 0.5; // the centre detent is EXACT, so unity persists as unity + return std::log2(ratio / minRatio) / rateSpanOctaves(minRatio, maxRatio); +} + +double rateRatioFromNorm(double norm, double minRatio, double maxRatio) { + if (!(maxRatio > minRatio && minRatio > 0.0)) return 1.0; + if (!(norm > 0.0)) return minRatio; // also catches NaN + if (norm >= 1.0) return maxRatio; + if (norm == 0.5) return 1.0; + // NOT resolved onto a decimal quantum, unlike the two maps above, and the difference is + // principled rather than an omission: this control's only default is unity, which the exact + // centre case above already delivers bitwise, so a grid would buy no preimage it does not + // already have — while costing accuracy at every whole semitone, none of which is a decimal + // ratio. Left as the plain exponential, accurate to an ulp. + return minRatio * std::exp2(norm * rateSpanOctaves(minRatio, maxRatio)); +} + double snapSecondsToWholeMs(double seconds) { if (!(seconds > 0.0)) return 0.0; return std::round(seconds * 1000.0) / 1000.0; @@ -83,4 +108,12 @@ double snapExponentToWhole(double exponent) { return util::clampCurve(std::round(util::clampCurve(exponent))); } +double snapRateRatioToWholeSemitone(double ratio) { + if (!(ratio > 0.0)) return 1.0; // also catches NaN: an unusable rate snaps to unity + // exp2 of a whole number of twelfths: 0 gives exactly 1.0 and +/-12 exactly halving/doubling, + // so a snap to the detent or to either end lands on the taper's own endpoint doubles. An + // in-range input stays in range, which is why this takes no bounds. + return std::exp2(std::round(12.0 * std::log2(ratio)) / 12.0); +} + } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/param_taper.h b/src/core/instrument/ui/param_taper.h index cbfc703..a0f7f22 100644 --- a/src/core/instrument/ui/param_taper.h +++ b/src/core/instrument/ui/param_taper.h @@ -73,6 +73,20 @@ double timeSecondsFromNorm(double norm); double depthNormFromSemitones(double semitones, double maxSemitones); double depthSemitonesFromNorm(double norm, double maxSemitones); +// Playback RATE as a ratio, exponential across the travel — i.e. LINEAR IN SEMITONES, the one +// exception to the centre expansion above. Centre expansion applies to a semitone knob whose +// throw exceeds +/-12; this throw IS +/-12 (half rate to double rate), already 0.19 st per drag +// pixel, so expanding it would buy resolution nothing needs and cost the extremes. +// +// The bounds are PARAMETERS for the same reason the depth throw is: they belong to the engine's +// stretcher, which owns the measurement they came from, and a second copy here could drift from +// it. The map is monotone and hits them exactly at norm 0 and 1, so a norm in [0,1] cannot reach +// a ratio the engine's own clamp would then move — ONE clamp, at the stretcher, not two. +// Exactly 1.0 at norm 0.5 whenever the bounds bracket it, which is this control's whole +// preimage obligation — see rateRatioFromNorm for why it carries no output quantum. +double rateNormFromRatio(double ratio, double minRatio, double maxRatio); +double rateRatioFromNorm(double norm, double minRatio, double maxRatio); + // --- Shift's whole-unit snaps, in the VALUE domain ------------------------------------------- // // Stated over values rather than norms because "whole unit" means whole unit of what the control @@ -83,5 +97,9 @@ double snapSecondsToWholeMs(double seconds); double snapFractionToWholePercent(double fraction); // 1.0 == 100 % double snapSemitonesToWhole(double semitones); double snapExponentToWhole(double exponent); // clamped into curve_law's own domain +// Rate's unit is the SEMITONE even though it displays as a percent, so its whole unit is one of +// the 25 semitone steps between the bounds — which is what puts an octave and a fifth under the +// hand. Stated over the ratio because that is what the control stores. +double snapRateRatioToWholeSemitone(double ratio); } // namespace reasampler::instrument::ui diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 1802e8c..449dac3 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -69,14 +69,16 @@ scattered `#ifdef`s in the VST shell, except the one described below). (`DEF_CLASS2` / `INLINE_UID` / `FUID` from `pluginfactory.h` + `funknown.h`). **The three commit tiers (Θ-W3).** An edit reaches the audio by exactly one of three routes, and -which route a control takes is decided once, by the pure `isLiveDeckParam` / `liveCommitFor` pair +which route a control takes is decided once, by the pure `deckParamCommit` / `liveCommitFor` pair (`core/instrument/ui/deck_groups`) that the editor's `dragCommitsLive` only maps onto — see `core/instrument/CLAUDE.md`'s "Live parameter delivery" for the rule and its rationale. 1. **Full reload** — `reloadInstrument`: bridge read, WAV re-decode, fresh engine, snapshot swap. 2. **Engine rebuild** — `rebuildVoiceEngine`: same drain-slot swap around the already-decoded `SampleData`. Voice count / mode / mono trigger. 3. **Live** — `publishLiveParams` (and `masterGain_`, the original of the shape): a lock-free - publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk. + publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk. The + pure predicate splits this tier by WHO READS the published value (`Live` vs `NoteOnLatched`); + the route out of the editor is the same one either way. The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still writes the parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 6d623db..16c0052 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -211,6 +211,16 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const { snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; case ParamControl::kKeyTrack: snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break; + case ParamControl::kRate: { + // One decimal below 100 % only: the taper is linear in semitones, so the lower half + // spends 50 percentage points on the same twelve semitones the upper half spends + // 100 on — a whole percent is twice as coarse a step down there. + const double pct = play.playRate * 100.0; + snprintf(buf, sizeof(buf), pct < 100.0 ? "%.1f%%" : "%.0f%%", pct); + break; + } + case ParamControl::kPitch: + snprintf(buf, sizeof(buf), "%+.1fst", play.pitchOffsetSemitones); break; case ParamControl::kVoiceCount: snprintf(buf, sizeof(buf), "%d", voiceCount_); break; case ParamControl::kMasterGain: diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index 3a7bfbf..96af2d5 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -73,6 +73,8 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { case ParamControl::kTrigHold: return "Hold"; case ParamControl::kTrigDecay: return "Decay"; case ParamControl::kKeyTrack: return "Key Trk"; + case ParamControl::kRate: return "Rate"; + case ParamControl::kPitch: return "Pitch"; case ParamControl::kPitchEnvAttack: return "P.Att"; case ParamControl::kPitchEnvHold: return "P.Hold"; case ParamControl::kPitchEnvDecay: return "P.Dec"; @@ -109,7 +111,7 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { const char* caption = ""; switch (g.id) { case kGroupAmpEnv: caption = "AMP ENVELOPE"; break; - case kGroupPitch: caption = "PITCH"; break; + case kGroupPitch: caption = "PITCH/RATE"; break; case kGroupPitchEnv: caption = "PITCH ENV"; break; case kGroupFilter: caption = "FILTER"; break; case kGroupFilterEnv: caption = "FILTER ENV"; break; diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 7c51cad..e6b0ec4 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -190,7 +190,10 @@ bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const { const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob : kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode : LiveDragKind::kOther; - return instrument::ui::liveCommitFor(k, paramId); + // Live and NoteOnLatched take the SAME route out of here — one publish, no reload — so the + // shell's question is only "does this reload?". Which voices then read the published value + // is the engine's business (deck_groups.h). + return instrument::ui::liveCommitFor(k, paramId) != instrument::ui::LiveCommit::Reload; } void ReaSamplerEditor::closeCurvePopup() { diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index df050eb..b16f0ce 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -276,14 +276,14 @@ private: // instrument off the audio thread. UI thread only. void commitAndReload(); - // The live peer of commitAndReload for a continuously-valued control (isLiveDeckParam): + // The live peer of commitAndReload for a continuously-valued control (deckParamCommit): // the same parameter-set write — so a saved project carries the edit exactly as before — // followed by a live publish instead of a rebuild, so the note already sounding follows // the knob. Does not repaint; callers already do. UI thread only. void commitLive(); // Whether an in-flight drag commits live rather than through a reload. A deck knob is - // live per isLiveDeckParam; an envelope-node drag is live in EITHER mode — see + // live per deckParamCommit; an envelope-node drag is live in EITHER mode — see // liveCommitFor (deck_groups.h) for why. bool dragCommitsLive(DragKind kind, int paramId = -1) const; diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 276734c..5bc8ae9 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -453,7 +453,7 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, - 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0f,0x00,0x00, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x10,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, @@ -551,6 +551,9 @@ static void testGoldenFullBlobFixture() { 0x00, // Straight // --- payload v15 limiter enable --- 0x00, // bypassed (the default) + // --- payload v16 rate + baseline pitch offset --- + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // playRate 1.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // pitchOffsetSemitones 0.0 }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); @@ -598,10 +601,10 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(bytes[4] == 0); // ChannelMode::Mono } CHECK(kComponentStateVersion == 11); - CHECK(kParamsPayloadVersion == 15); + CHECK(kParamsPayloadVersion == 16); CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); - // The filter, staged-curve, loop, velocity, spline, bake-Hold and limiter tails rode + // The filter, staged-curve, loop, velocity, spline, bake-Hold, limiter and rate tails rode // PAYLOAD bumps, not envelope ones — the two axes stay independent, so a future envelope // field cannot collide with any of them on one number. This pins the NUMBERS only; that // each tail's bytes sit in the order its number implies is @@ -613,7 +616,8 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(kParamsSplineVersion > kParamsVelocityVersion); CHECK(kParamsBakeHoldVersion > kParamsSplineVersion); CHECK(kParamsLimiterVersion > kParamsBakeHoldVersion); - CHECK(kParamsPayloadVersion == kParamsLimiterVersion); + CHECK(kParamsRateVersion > kParamsLimiterVersion); + CHECK(kParamsPayloadVersion == kParamsRateVersion); } // --- The filter tail (payload v9) -------------------------------------------- @@ -798,9 +802,14 @@ static void testNonFiniteAhdSecondsLiftToZero() { static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2; static constexpr std::size_t kBakeHoldTailBytes = 4 + 1; static constexpr std::size_t kLimiterTailBytes = 1; +static constexpr std::size_t kRateTailBytes = 8 + 8; // v16: rate + pitch offset, two doubles +// Everything past the hard flags, as ONE unit — the splice tests cut back over all of it, so a +// new rung is one edit here rather than a hand-counted sum at each of them. +static constexpr std::size_t kTrailingTailBytes = + kBakeHoldTailBytes + kLimiterTailBytes + kRateTailBytes; -// The v14/v15 tails, re-appended after a splice so the record still ends where the reader -// expects. They go back in wire order: Hold first, then the limiter byte. +// The v14/v15/v16 tails, re-appended after a splice so the record still ends where the reader +// expects. They go back in wire order: Hold, then the limiter byte, then the rate pair. static void putBakeHoldTail(std::vector& out, int quarterExponent, note::DivisionModifier modifier) { legacy::u32v(out, static_cast(static_cast(quarterExponent))); @@ -811,9 +820,15 @@ static void putLimiterTail(std::vector& out, bool enabled) { legacy::u8v(out, enabled ? 1 : 0); } +static void putRateTail(std::vector& out, double rate, double pitchOffset) { + legacy::f64v(out, rate); + legacy::f64v(out, pitchOffset); +} + static void putDefaultTrailingTails(std::vector& out) { putBakeHoldTail(out, 2, note::DivisionModifier::Straight); // 1/1, the field's default putLimiterTail(out, false); // bypassed, the field's default + putRateTail(out, 1.0, 0.0); // unity rate, no offset } // A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still @@ -848,8 +863,8 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() { // order in params_payload.cpp) is deterministic and this test can splice it exactly. std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes); legacy::u32v(bytes, 5); // amp: bogus count... for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts legacy::u32v(bytes, 2); // filter: correct count, unchanged @@ -896,8 +911,8 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() { in.params.loopCrossfadeFrames = 321; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes); legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry // …and nothing at all after it, so the blob simply ends inside the v13 tail. @@ -947,7 +962,7 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric // Everything after it — the amp flags, both well-formed neighbour blocks, the Hold and the // limiter byte — is exactly what the serializer wrote, which is the whole hazard. constexpr std::size_t kThreePointFlagTail = (4 + 3) + (4 + 2) + (4 + 2); - constexpr std::size_t kTrailingTails = kBakeHoldTailBytes + kLimiterTailBytes; + constexpr std::size_t kTrailingTails = kTrailingTailBytes; std::vector bytes = serializeComponentState(in); CHECK(bytes.size() >= kThreePointFlagTail + kTrailingTails); const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kTrailingTails; @@ -1044,10 +1059,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord() in.params.loopCrossfadeFrames = 5; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes); // Drops the bake-Hold and limiter tails with the flags: the truncation strands everything // after it, which is the whole point — both lift to their defaults alongside the flags. - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes); legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing legacy::u8v(bytes, 0x00); @@ -1255,18 +1270,18 @@ static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact() CHECK(out.params.keyTrack == 0.25); CHECK(out.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted)); - // The same state stamped v14, with exactly the one appended byte cut away: byte-for-byte + // The same state stamped v14, with the two rungs appended after it cut away: byte-for-byte // what the Ξ binary wrote. Its Hold must survive in full. const ComponentState v14 = deserializeComponentState( - payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes), 48000.0); + payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes + kRateTailBytes), + 48000.0); CHECK(!v14.params.limiterEnabled); CHECK(v14.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted)); CHECK(v14.params.keyTrack == 0.25); // And a v13 blob, one rung further back, lifts to BOTH defaults. const ComponentState v13 = deserializeComponentState( - payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes), - 48000.0); + payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes), 48000.0); CHECK(!v13.params.limiterEnabled); CHECK(v13.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(v13.params.keyTrack == 0.25); @@ -1280,37 +1295,49 @@ static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact() // The ORDERING proof at the WRITER, stated in bytes rather than in prose: the payload's whole // discipline is that each version's fields are a strict suffix on the previous version's, so -// v14's Hold pair must be emitted BEFORE v15's limiter byte or every v14 blob already saved -// mis-parses. Asserted at absolute offsets from the end of the blob, with both fields off -// their defaults, so transposing the two writes fails on the values and not just the layout. +// v14's Hold pair must be emitted BEFORE v15's limiter byte, and both before v16's rate pair, +// or every blob already saved at those rungs mis-parses. Asserted at absolute offsets from the +// end of the blob, with every field off its default, so transposing any two writes fails on the +// values and not just the layout. static void testAppendedTailsSitInVersionOrderOnTheWire() { ComponentState in; in.selectionId = "pad"; in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet); in.params.limiterEnabled = true; + in.params.play.playRate = 2.0; // 0x4000000000000000 LE + in.params.play.pitchOffsetSemitones = -12.0; // 0xC028000000000000 LE const std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() > kBakeHoldTailBytes + kLimiterTailBytes); + CHECK(bytes.size() > kTrailingTailBytes); - // The last six bytes are, in order: the v14 Hold's 4-byte LE exponent, its 1-byte - // modifier, then the v15 limiter byte. - const std::size_t holdAt = bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes; + // In order: the v14 Hold's 4-byte LE exponent, its 1-byte modifier, the v15 limiter byte, + // then the v16 rate and pitch-offset doubles. + const std::size_t holdAt = bytes.size() - kTrailingTailBytes; CHECK(bytes[holdAt + 0] == 0xfe); // -2 as int32 LE two's-complement CHECK(bytes[holdAt + 1] == 0xff); CHECK(bytes[holdAt + 2] == 0xff); CHECK(bytes[holdAt + 3] == 0xff); CHECK(bytes[holdAt + 4] == static_cast(note::DivisionModifier::Triplet)); - CHECK(bytes[bytes.size() - 1] == 0x01); // the limiter enable, last + CHECK(bytes[holdAt + 5] == 0x01); // the limiter enable + const std::size_t rateAt = holdAt + kBakeHoldTailBytes + kLimiterTailBytes; + const std::uint8_t wantRate[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40}; + const std::uint8_t wantOffset[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0xc0}; + for (std::size_t i = 0; i < 8; ++i) { + CHECK(bytes[rateAt + i] == wantRate[i]); + CHECK(bytes[rateAt + 8 + i] == wantOffset[i]); + } - // The same claim from the other side: flipping only the limiter changes only the LAST - // byte, so the byte the limiter owns cannot be one the Hold also writes. + // The same claim from the other side: flipping only the limiter changes only the byte the + // limiter owns, so it cannot be one the Hold or the rate pair also writes. ComponentState off = in; off.params.limiterEnabled = false; const std::vector offBytes = serializeComponentState(off); CHECK(offBytes.size() == bytes.size()); if (offBytes.size() == bytes.size()) { - for (std::size_t i = 0; i + 1 < bytes.size(); ++i) CHECK(offBytes[i] == bytes[i]); - CHECK(offBytes[bytes.size() - 1] == 0x00); + for (std::size_t i = 0; i < bytes.size(); ++i) { + if (i == holdAt + 5) CHECK(offBytes[i] == 0x00); + else CHECK(offBytes[i] == bytes[i]); + } } } @@ -1408,10 +1435,10 @@ static void testV13BlobLiftsToTheDefaultHold() { in.params.loopCrossfadeFrames = 128; in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted); - // Stamp the payload back to v13 and drop the v14 and v15 tails both: byte-for-byte what - // the v13 binary would have written. - const std::vector v13 = payloadDowngradedTo( - in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes); + // Stamp the payload back to v13 and drop every tail appended since: byte-for-byte what the + // v13 binary would have written. + const std::vector v13 = + payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes); const ComponentState out = deserializeComponentState(v13, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(out.selectionId == "pad"); @@ -1427,18 +1454,22 @@ static void testBakeHoldCorruptPairClampsToTheLadder() { ComponentState in; in.selectionId = "pad"; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes); - bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes); + CHECK(bytes.size() >= kTrailingTailBytes); + bytes.resize(bytes.size() - kTrailingTailBytes); legacy::u32v(bytes, static_cast(static_cast(9999))); legacy::u8v(bytes, 200); // an unnamed modifier byte - putLimiterTail(bytes, true); // a well-formed byte after it, so the clamp is the only fault + // Well-formed, off-default tails after it, so the clamp is the only fault in the blob. + putLimiterTail(bytes, true); + putRateTail(bytes, 0.5, 7.0); const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight)); - // The tail behind the corrupt pair still lands on its own field: the clamp consumed exactly - // the five bytes it was owed, so the limiter byte was not read out of the Hold's modifier. + // The tails behind the corrupt pair still land on their own fields: the clamp consumed + // exactly the five bytes it was owed, so nothing after it was read out of alignment. CHECK(out.params.limiterEnabled); + CHECK(out.params.play.playRate == 0.5); + CHECK(out.params.play.pitchOffsetSemitones == 7.0); } // A blob truncated INSIDE the v14 tail costs the Hold alone — and, with the v15 byte stranded @@ -1451,22 +1482,98 @@ static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() { in.params.loopCrossfadeFrames = 96; in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet); in.params.limiterEnabled = true; + in.params.play.playRate = 0.75; + in.params.play.pitchOffsetSemitones = -5.0; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes); - bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes); + CHECK(bytes.size() >= kTrailingTailBytes); + bytes.resize(bytes.size() - kTrailingTailBytes); legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing legacy::u8v(bytes, 0x00); const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(!out.params.limiterEnabled); // stranded behind the Hold, and revived not wiped + // The rate pair is stranded two rungs behind the damage and must reach its own neutral + // rather than fabricating one out of the drained bytes. + CHECK(out.params.play.playRate == 1.0); + CHECK(out.params.play.pitchOffsetSemitones == 0.0); CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 71); CHECK(out.params.play.adsr.attackSeconds == 0.017); CHECK(out.params.loopCrossfadeFrames == 96); } +// --- The rate + pitch-offset tail (payload v16) ------------------------------ + +// The rung's whole contract in one test: a v16 blob round-trips BOTH fields exactly, and a v15 +// blob — a strict prefix of it, byte-for-byte what the shipped binary wrote — lifts to unity +// rate and zero offset, which is what every instance before them played. The neighbours ahead of +// the pair are checked too, so a misread that shifted the record shows up here rather than as a +// silent retune. +static void testRateAndPitchOffsetRoundTripAndV15LiftsToUnity() { + ComponentState in; + in.selectionId = "pad"; + in.params.keyTrack = 0.75; + in.params.limiterEnabled = true; + in.params.bakeHold = note::makeDivision(3, note::DivisionModifier::Dotted); + // Both off their defaults, and both exactly representable, so == is the right comparison: + // the codec stores raw doubles and must not round either one. + in.params.play.playRate = 0.75; + in.params.play.pitchOffsetSemitones = -7.5; + + const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); + CHECK(out.params.play.playRate == 0.75); + CHECK(out.params.play.pitchOffsetSemitones == -7.5); + CHECK(out.params.limiterEnabled); + CHECK(out.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted)); + CHECK(out.params.keyTrack == 0.75); + + const ComponentState v15 = deserializeComponentState( + payloadDowngradedTo(in, kParamsLimiterVersion, kRateTailBytes), 48000.0); + CHECK(v15.params.play.playRate == 1.0); + CHECK(v15.params.play.pitchOffsetSemitones == 0.0); + // Everything the v15 binary DID write survives the lift untouched. + CHECK(v15.params.limiterEnabled); + CHECK(v15.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted)); + CHECK(v15.params.keyTrack == 0.75); + + // Unity/zero is the default at the struct as well as on the wire, so a fresh instance and a + // lifted v15 one are the same sound. + CHECK(PlaySeconds{}.playRate == 1.0); + CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0); +} + +// Neither field has a clamp of its own downstream that could rescue a corrupt blob: the rate +// multiplies a read increment (the engine's own clampStretchRate is the one authority on its +// RANGE, so the codec only refuses the unusable) and the offset feeds a 2^(x/12) whose result +// reaches a per-sample cast. Both degrade to their neutral rather than through. +static void testCorruptRateOrOffsetDegradesToTheNeutral() { + const double nan = std::numeric_limits::quiet_NaN(); + const struct { double rate; double offset; double wantRate; double wantOffset; } cases[] = { + {nan, 3.0, 1.0, 3.0}, + {0.75, nan, 0.75, 0.0}, + {0.0, 3.0, 1.0, 3.0}, // a zero rate would stall the read head + {-1.0, 3.0, 1.0, 3.0}, // and a negative one would run it backwards + {std::numeric_limits::infinity(), 3.0, 1.0, 3.0}, + {0.75, 1e9, 0.75, 0.0}, // past the +/-24 st throw + {0.75, -1e9, 0.75, 0.0}, + {0.75, 24.0, 0.75, 24.0}, // the throw itself is IN range + {0.75, -24.0, 0.75, -24.0}, + }; + for (const auto& c : cases) { + ComponentState in; + in.selectionId = "pad"; + std::vector bytes = serializeComponentState(in); + CHECK(bytes.size() >= kRateTailBytes); + bytes.resize(bytes.size() - kRateTailBytes); + putRateTail(bytes, c.rate, c.offset); + const ComponentState out = deserializeComponentState(bytes, 48000.0); + CHECK(out.params.play.playRate == c.wantRate); + CHECK(out.params.play.pitchOffsetSemitones == c.wantOffset); + } +} + // The WRITER emits the CURRENT payload version, and the marker + version sit at the head of // the payload — the self-describing property every legacy branch depends on. Asserted // against the semantic constants, not literals. @@ -2079,6 +2186,8 @@ int main() { testV13BlobLiftsToTheDefaultHold(); testBakeHoldCorruptPairClampsToTheLadder(); testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord(); + testRateAndPitchOffsetRoundTripAndV15LiftsToUnity(); + testCorruptRateOrOffsetDegradesToTheNeutral(); if (failures == 0) { std::printf("component_state_io_tests: all tests passed\n"); return 0; diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 5069705..6fa95b9 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -412,10 +412,12 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() { CHECK(deckNormFromBipolar(3.0) == 1.0); } -static void testEveryDeckControlIsClassifiedLiveOrReloading() { - // The live set: the seven filter tone/modulation knobs, plus every stage time, stage level, - // hold fraction and curve exponent on all three envelopes — in BOTH mode shapes. +static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { + // The live set: the seven filter tone/modulation knobs, the baseline pitch offset, plus + // every stage time, stage level, hold fraction and curve exponent on all three envelopes — + // in BOTH mode shapes. const DeckParam live[] = { + DeckParam::kPitch, DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ, DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterVel, DeckParam::kFilterKeyTrack, @@ -434,7 +436,14 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() { DeckParam::kFilterEnvReleaseCurve, DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve, }; - for (DeckParam p : live) CHECK(isLiveDeckParam(p)); + for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live); + + // The note-on-latched tier: published like a live control, read only at note-on. Asserted as + // its OWN state rather than as "not Reload" — the whole point of widening the predicate is + // that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification + // to decide what it exposes to the host. + const DeckParam latched[] = {DeckParam::kRate}; + for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched); // Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion // is excluded. @@ -448,15 +457,16 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() { DeckParam::kVoiceCount, DeckParam::kVoiceMode, DeckParam::kMonoTrigger, DeckParam::kMasterGain, }; - for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p)); + for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload); - // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the two lists. A sum check + // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the three lists. A sum check // would stay green if an edit duplicated one id and dropped another, leaving that one // unclassified. for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { const DeckParam p = static_cast(i); int seen = 0; for (DeckParam q : live) if (q == p) ++seen; + for (DeckParam q : latched) if (q == p) ++seen; for (DeckParam q : reloads) if (q == p) ++seen; if (seen != 1) std::printf(" (deck id %d classified %d times)\n", i, seen); CHECK(seen == 1); @@ -464,26 +474,34 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() { } static void testOnlyALiveControlsDragTakesTheLiveTier() { - // isLiveDeckParam alone is not what a user experiences — liveCommitFor is, at the editor's + // deckParamCommit alone is not what a user experiences — liveCommitFor is, at the editor's // commit site. Inverting it has to FAIL a test rather than merely read wrong. - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kFilterCutoff))); - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kAttack))); + const auto knob = [](DeckParam p) { + return liveCommitFor(LiveDragKind::kDeckKnob, static_cast(p)); + }; + CHECK(knob(DeckParam::kFilterCutoff) == LiveCommit::Live); + CHECK(knob(DeckParam::kAttack) == LiveCommit::Live); + CHECK(knob(DeckParam::kPitch) == LiveCommit::Live); // The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural // consequence of that consolidation. - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigAttack))); - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigDecayCurve))); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigLength))); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kMasterGain))); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kAmpEnvSelect))); + CHECK(knob(DeckParam::kTrigAttack) == LiveCommit::Live); + CHECK(knob(DeckParam::kTrigDecayCurve) == LiveCommit::Live); + // Rate keeps its own tier through the drag site: it must not arrive as Live (which would let + // it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob). + CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched); + CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload); + CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload); + CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload); // The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id // are not parameter-set controls, so they must never reach the enum. - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2)); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1)); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kCount))); + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -2) == LiveCommit::Reload); + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -1) == LiveCommit::Reload); + CHECK(knob(DeckParam::kCount) == LiveCommit::Reload); // Every stage value an envelope node can reach is live, in either mode shape. - CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1)); + CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1) == LiveCommit::Live); // Every other drag (markers, scrollbar, curve nodes) commits through a reload. - CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff))); + CHECK(liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff)) == + LiveCommit::Reload); } // --- The overlay selection state machine --------------------------------------- @@ -582,9 +600,9 @@ static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() { // that scale either shape stay live. (Which segment knobs, per envelope, is pinned in // spline_egs_tests alongside the rest of the spline rules.) static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() { - CHECK(!isLiveDeckParam(DeckParam::kAmpEnvMode)); - CHECK(!isLiveDeckParam(DeckParam::kPitchEnvMode)); - CHECK(!isLiveDeckParam(DeckParam::kFilterEnvMode)); + CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload); + CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload); + CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload); CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp); CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch); CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter); @@ -644,6 +662,31 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { // The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned // once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here. +// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus +// padding) is what it measures from, and the caption row must stay under that. The ceiling is +// asserted by construction rather than as a comment: at a caption reserve of 80 the group is +// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the +// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is. +static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckGroupDesc* pitch = nullptr; + for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d; + CHECK(pitch != nullptr); + if (!pitch) return; + CHECK(pitch->cellIds.size() == 3); + CHECK(pitch->cellIds[0] == static_cast(DeckParam::kKeyTrack)); + CHECK(pitch->cellIds[1] == static_cast(DeckParam::kRate)); + CHECK(pitch->cellIds[2] == static_cast(DeckParam::kPitch)); + CHECK(deckGroupWidth(*pitch) == 192); + CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192); // the knob row IS the measurement + + DeckGroupDesc probe = *pitch; + probe.captionWidth = 80; + CHECK(deckGroupWidth(probe) == 192); // at the ceiling the caption row still fits under it + probe.captionWidth = 81; + CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over +} + // Gate is the common face and its group widths are what the width budget is spent against: // pin them at the floor so a later edit anywhere in the deck cannot move one silently. // (Measured from the shipped descriptors, not copied out of a failing run.) The WRAP row a @@ -652,7 +695,7 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { static void testGateModeGroupWidthsAreUnchanged() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const struct { int id; int width; } want[] = { - {kGroupPitch, 150}, {kGroupPitchEnv, 252}, {kGroupFilter, 524}, + {kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 524}, {kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192}, {kGroupVoice, 164}, {kGroupMaster, 72}, }; @@ -736,7 +779,7 @@ int main() { testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); testAModeToggleIsNeitherLiveNorAnOverlayRadio(); testTheModeTogglesCostNoGroupWidth(); - testEveryDeckControlIsClassifiedLiveOrReloading(); + testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers(); testOnlyALiveControlsDragTakesTheLiveTier(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); testVelocityGroupOwnsTheThreeCurvesExclusively(); @@ -750,6 +793,7 @@ int main() { testWrappedDeckHeightAtTheEditorFloorWidth(); testDeckFitsInsideTheEnforcedMinimumWindow(); testNoFaceLeavesSlackWhereItsDroppedControlsWere(); + testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo(); testGateModeGroupWidthsAreUnchanged(); testGateSplineGateRoundTripsToTheSameLayout(); testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index d82090b..ab2a3d4 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -14,6 +14,7 @@ using namespace reasampler; using namespace reasampler::instrument::ui; +namespace engine = reasampler::instrument::engine; // the stretcher's own rate bounds + clamp static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -88,6 +89,88 @@ static void testNormRoundTripsThroughEveryValueDomain() { CHECK(p.adsr.decaySeconds == 0.0); } +// Rate's range is the STRETCHER's, aliased rather than restated, so the knob's two ends and the +// engine's clamp cannot become two opinions. Asserted against the engine constants themselves. +static void testRateKnobEndsAreTheStretchersOwnBounds() { + CHECK(kRateMinRatio == engine::kStretchRateMin); + CHECK(kRateMaxRatio == engine::kStretchRateMax); + PlaySeconds p; + setDeckParam(DeckParam::kRate, p, 0.0, 0); + CHECK(p.playRate == engine::kStretchRateMin); + CHECK(engine::clampStretchRate(p.playRate) == p.playRate); // the clamp has nothing to do + setDeckParam(DeckParam::kRate, p, 1.0, 0); + CHECK(p.playRate == engine::kStretchRateMax); + CHECK(engine::clampStretchRate(p.playRate) == p.playRate); + // And nowhere on the travel does the knob produce a rate the engine would move. + for (int i = 0; i <= 1000; ++i) { + setDeckParam(DeckParam::kRate, p, static_cast(i) / 1000.0, 0); + CHECK(engine::clampStretchRate(p.playRate) == p.playRate); + if (engine::clampStretchRate(p.playRate) != p.playRate) return; + } +} + +// The two new bindings write the two new fields and nothing else — both are doubles on +// PlaySeconds with adjacent homes, so a getter/setter pair that crossed them would still +// round-trip. The centre detent is exact on both, which is what lets an untouched knob persist +// unity rate and zero transposition. +static void testRateAndPitchBindTheirOwnFields() { + PlaySeconds p; + setDeckParam(DeckParam::kRate, p, 0.5, 0); + CHECK(p.playRate == 1.0); + CHECK(p.pitchOffsetSemitones == 0.0); + CHECK(deckParamNorm(DeckParam::kRate, p) == 0.5); + + setDeckParam(DeckParam::kPitch, p, 0.5, 0); + CHECK(p.pitchOffsetSemitones == 0.0); + CHECK(p.playRate == 1.0); + CHECK(deckParamNorm(DeckParam::kPitch, p) == 0.5); + + // Pitch rides the SAME centre-expanded depth taper as the pitch envelope's own depth, over + // the SAME throw — a second constant here would be the defect the spec names. + setDeckParam(DeckParam::kPitch, p, 1.0, 0); + CHECK(p.pitchOffsetSemitones == kPitchDepthMaxSemis); + CHECK(kPitchDepthMaxSemis == kVelocityPitchRangeSemitones); + setDeckParam(DeckParam::kPitch, p, 0.0, 0); + CHECK(p.pitchOffsetSemitones == -kPitchDepthMaxSemis); + CHECK(p.playRate == 1.0); // untouched by every write above but its own + + // A move on Rate leaves the offset alone, in the other direction. + setDeckParam(DeckParam::kPitch, p, 0.5, 0); + setDeckParam(DeckParam::kRate, p, 0.0, 0); + CHECK(p.pitchOffsetSemitones == 0.0); +} + +// Shift's whole unit on BOTH new knobs is the semitone, not the percent their labels read in. +// Asserted through the deck's own snap entry point (the shell calls nothing else), and in +// semitones, which is the unit the rule is stated in. +static void testShiftSnapsBothNewKnobsToWholeSemitones() { + CHECK(deckParamUnit(DeckParam::kRate) == UnitCategory::Semitones); + CHECK(deckParamUnit(DeckParam::kPitch) == UnitCategory::Semitones); + + PlaySeconds p; + // Rate: a norm a third of the way up is 8 semitones below unity — snapping must land on a + // whole one, and the knob must still be able to reach an octave and a fifth by hand. + for (double norm : {0.13, 0.37, 0.5, 0.62, 0.88}) { + setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, norm), 0); + const double semis = 12.0 * std::log2(p.playRate); + CHECK(std::fabs(semis - std::round(semis)) < 1e-9); + if (!(std::fabs(semis - std::round(semis)) < 1e-9)) return; + } + // The two landmarks by name: unity, and a fifth up. + setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, 0.5), 0); + CHECK(p.playRate == 1.0); + setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, 0.5 + 7.0 / 24.0), 0); + CHECK(std::fabs(12.0 * std::log2(p.playRate) - 7.0) < 1e-9); + + // Pitch: whole semitones on the centre-expanded taper, exactly (its taper resolves onto a + // micro-semitone grid, so a whole semitone is ON that grid). + for (double norm : {0.17, 0.33, 0.71, 0.94}) { + setDeckParam(DeckParam::kPitch, p, snapDeckParamNorm(DeckParam::kPitch, norm), 0); + CHECK(p.pitchOffsetSemitones == std::round(p.pitchOffsetSemitones)); + if (p.pitchOffsetSemitones != std::round(p.pitchOffsetSemitones)) return; + } +} + // The dual-ring reset contract: the outer ring resets the stage VALUE and the inner dial resets // the EXPONENT, each leaving the other exactly as it was. Both fields are asserted in both // directions — checking only the field that changed would pass even if the reset clobbered its @@ -342,6 +425,9 @@ static void testTimeConstantsAlwaysReadInMilliseconds() { int main() { testTheTwoCeilingNamesAreOneNumber(); testNormRoundTripsThroughEveryValueDomain(); + testRateKnobEndsAreTheStretchersOwnBounds(); + testRateAndPitchBindTheirOwnFields(); + testShiftSnapsBothNewKnobsToWholeSemitones(); testResetTouchesOnlyItsOwnRingOnADualRingKnob(); testInnerResetLandsOnTheExactLinearNeutral(); testResetLandsOnTheStoredDefaultOfEachControl(); diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp index 8cfdf6b..7705d36 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -720,6 +720,136 @@ static void testOneBlockServesTwoIndependentObservers() { CHECK(seen.filterSettings.cutoffNorm == 0.2f); } +// --- The third commit class: published live, read only at note-on ------------------------- + +// A ramp source read under Varispeed, so every output frame IS the read position — a moved read +// increment shows up directly rather than as a timbre change. The claim has two halves and both +// are asserted: the sounding note is byte-identical to one that never saw the publish, AND the +// next note-on takes the new rate. Asserting only the first would pass on a rate that never +// arrived at all. +static SampleData rampForReadRate() { + SampleData s; + s.frames.resize(200000); + for (std::size_t i = 0; i < s.frames.size(); ++i) { + s.frames[i] = static_cast(static_cast(i) / 200000.0); + } + s.sampleRate = kRate; + s.rootNote = 60; + s.play.adsr.sustainLevel = 1.0; + return s; +} + +// A one-voice engine with its Preserve shifters actually SIZED, unlike renderWithLive's — the +// shared harness leaves them unconfigured, which silently routes a Preserve voice down the +// varispeed read and would make "in both engines" mean one engine twice. +static std::vector renderPreserveCapable(SampleData& s, LiveParams& block, + const LiveValues* changed, int changeAfter, + int note) { + s.live = █ + block.publish(foldLive(s.play)); + VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(note, 100); + std::vector out; + for (int b = 0; b < 24; ++b) { + if (changed && b == changeAfter) block.publish(*changed); + engine.render(out, 512); + } + return out; +} + +static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() { + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + SampleData still = rampForReadRate(); + SampleData moved = rampForReadRate(); + still.play.pitchEngine = eng; + moved.play.pitchEngine = eng; + + LiveParams blockA, blockB; + LiveValues halfRate = foldLive(moved.play); + halfRate.playRate = 0.5; + + // At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the + // output is then the source at the read head under both engines, which is what makes + // the ramp readable as a read rate at all. + const std::vector baseline = + renderPreserveCapable(still, blockA, nullptr, -1, 60); + const std::vector swept = + renderPreserveCapable(moved, blockB, &halfRate, 8, 60); + + // BYTE-identical, not merely close: the sounding voice never reads the field. + CHECK(baseline.size() == swept.size()); + bool untouched = true; + for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) { + if (baseline[i] != swept[i]) { untouched = false; break; } + } + CHECK(untouched); + + // The next note-on takes it — measured as the note's LIFETIME, which is what Rate + // controls in both engines. (The ramp's instantaneous value is a read-position probe + // under Varispeed only: under Preserve the shifter's tap sits behind the feed and + // relocates at every splice, so the value at a given output frame is not the source + // there.) The rate is carried ONLY by the published block — sample.play keeps unity — + // so a lifetime that doubles can only have come from the block. + auto blocksAlive = [&](double rate) { + SampleData fresh = rampForReadRate(); + fresh.play.pitchEngine = eng; + LiveParams block; + fresh.live = █ + LiveValues published = foldLive(fresh.play); + published.playRate = rate; + block.publish(published); + VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(60, 100); + std::vector out; + int blocks = 0; + while (engine.activeVoiceCount() > 0 && blocks < 4000) { + engine.render(out, 512); + ++blocks; + } + return blocks; + }; + const int atUnity = blocksAlive(1.0); + const int atHalf = blocksAlive(0.5); + CHECK(atUnity > 100 && atUnity < 4000); // the note really did run to its own end + CHECK(std::fabs(static_cast(atHalf) - 2.0 * atUnity) < 0.05 * atUnity); + } +} + +// Pitch is the other side of the same coin: it DOES move the note already sounding, under both +// engines — one more factor of the read increment under Varispeed, an addend to the shift under +// Preserve. Measured as a tail that departs from the untouched render while the frames before +// the publish stay byte-identical. +static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() { + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + SampleData still = periodicSine(200000, 64.0); + SampleData moved = periodicSine(200000, 64.0); + still.play.pitchEngine = eng; + moved.play.pitchEngine = eng; + + LiveParams blockA, blockB; + LiveValues target = foldLive(moved.play); + target.pitchOffsetSemitones = -12.0; + + const std::vector baseline = + renderPreserveCapable(still, blockA, nullptr, -1, kTestNote); + const std::vector swept = + renderPreserveCapable(moved, blockB, &target, 8, kTestNote); + + double tailDiff = 0.0; + for (std::size_t i = 512 * 12; i < baseline.size(); ++i) { + tailDiff += std::fabs(static_cast(swept[i]) - + static_cast(baseline[i])); + } + CHECK(tailDiff > 1.0); + + bool preChangeIdentical = true; + for (std::size_t i = 0; i < 512 * 8; ++i) { + if (swept[i] != baseline[i]) { preChangeIdentical = false; break; } + } + CHECK(preChangeIdentical); + } +} + // --- What stays latched at note-on ------------------------------------------------------- static void testPitchRatioAndVelocityGainStayLatched() { @@ -859,6 +989,8 @@ int main() { testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote(); testEveryLiveFilterControlMovesTheSoundingNote(); testOneBlockServesTwoIndependentObservers(); + testARateChangeSpareTheSoundingNoteAndReachesTheNextOne(); + testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines(); testPitchRatioAndVelocityGainStayLatched(); testVelocityGainSurvivesAHostilePublishThatReallyLands(); if (g_fail == 0) std::printf("live_delivery tests passed\n"); diff --git a/tests/test_param_taper.cpp b/tests/test_param_taper.cpp index 4cf38f5..61b35d3 100644 --- a/tests/test_param_taper.cpp +++ b/tests/test_param_taper.cpp @@ -24,6 +24,11 @@ static int g_fail = 0; std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) static constexpr double kDepth = 24.0; // the pitch-depth throw the deck passes in today +// Rate's bounds, as the deck passes them in — the stretcher's own measured range. Written as +// literals HERE on purpose: this is the module's test, and reading the engine constant would +// make the test agree with the taper by construction rather than pin the numbers. +static constexpr double kRateMin = 0.5; +static constexpr double kRateMax = 2.0; // --- modifiers ----------------------------------------------------------------------------- @@ -227,6 +232,76 @@ static void testDegenerateThrowCollapsesToCentre() { CHECK(depthSemitonesFromNorm(0.9, 0.0) == 0.0); } +// --- the rate taper ------------------------------------------------------------------------- + +// The three landmarks the range is specified by, all EXACT: half rate at norm 0, double at norm +// 1, and unity at TRUE knob centre — the last is what a detent has to be, and a map that merely +// came close to 1.0 there would persist a hair of transposition on an untouched knob. +static void testRateEndpointsAndCentreAreExact() { + CHECK(rateRatioFromNorm(0.0, kRateMin, kRateMax) == 0.5); + CHECK(rateRatioFromNorm(1.0, kRateMin, kRateMax) == 2.0); + CHECK(rateRatioFromNorm(0.5, kRateMin, kRateMax) == 1.0); + CHECK(rateNormFromRatio(0.5, kRateMin, kRateMax) == 0.0); + CHECK(rateNormFromRatio(2.0, kRateMin, kRateMax) == 1.0); + CHECK(rateNormFromRatio(1.0, kRateMin, kRateMax) == 0.5); + // Out of domain clamps rather than extrapolating — the map cannot reach a ratio the + // engine's own clamp would then have to move. + CHECK(rateRatioFromNorm(-1.0, kRateMin, kRateMax) == 0.5); + CHECK(rateRatioFromNorm(2.0, kRateMin, kRateMax) == 2.0); + CHECK(rateNormFromRatio(0.1, kRateMin, kRateMax) == 0.0); + CHECK(rateNormFromRatio(9.0, kRateMin, kRateMax) == 1.0); +} + +// The taper's defining property, and the reason it is the exception to centre expansion: equal +// travel buys equal SEMITONES, everywhere. Checked as a constant ratio-of-ratios across the +// travel rather than at the two ends, which a centre-expanded map would also pass. +static void testRateIsLinearInSemitonesAcrossTheWholeTravel() { + const double step = 1.0 / 24.0; // 24 equal steps over 24 semitones + for (int i = 0; i < 24; ++i) { + const double lo = rateRatioFromNorm(static_cast(i) * step, kRateMin, kRateMax); + const double hi = rateRatioFromNorm(static_cast(i + 1) * step, kRateMin, kRateMax); + CHECK(std::fabs(hi / lo - std::exp2(1.0 / 12.0)) < 1e-12); + if (!(std::fabs(hi / lo - std::exp2(1.0 / 12.0)) < 1e-12)) return; + } + // The named musical landmarks that buys: an octave at each end, a fifth seven steps out. + CHECK(std::fabs(rateRatioFromNorm(0.5 + 7.0 / 24.0, kRateMin, kRateMax) - + std::exp2(7.0 / 12.0)) < 1e-12); +} + +static void testRateIsMonotone() { + double prev = -1.0; + for (int i = 0; i <= 200000; ++i) { + const double v = rateRatioFromNorm(static_cast(i) / 200000.0, kRateMin, kRateMax); + CHECK(v >= prev); + if (v < prev) return; + prev = v; + } +} + +// The preimage obligation this control actually carries: its ONE default, bitwise, because a +// host's reset-to-default arrives as toPlain(defaultNorm) with no editor bypass to intercept it. +// Both endpoints are exact for the same reason. Everything between round-trips to within an ulp +// rather than bitwise — the map carries no output quantum, and the header says why. +static void testRateDefaultAndEndpointsRoundTripBitwise() { + CHECK(rateRatioFromNorm(rateNormFromRatio(1.0, kRateMin, kRateMax), kRateMin, kRateMax) == 1.0); + CHECK(rateRatioFromNorm(rateNormFromRatio(0.5, kRateMin, kRateMax), kRateMin, kRateMax) == 0.5); + CHECK(rateRatioFromNorm(rateNormFromRatio(2.0, kRateMin, kRateMax), kRateMin, kRateMax) == 2.0); + for (int milli = 500; milli <= 2000; milli += 7) { + const double ratio = static_cast(milli) / 1000.0; + const double back = + rateRatioFromNorm(rateNormFromRatio(ratio, kRateMin, kRateMax), kRateMin, kRateMax); + CHECK(std::fabs(back - ratio) < 1e-14 * ratio); + if (!(std::fabs(back - ratio) < 1e-14 * ratio)) return; + } +} + +// Degenerate bounds are a caller bug, not a crash: the map collapses to unity. +static void testDegenerateRateBoundsCollapseToUnity() { + CHECK(rateRatioFromNorm(0.3, 2.0, 0.5) == 1.0); + CHECK(rateNormFromRatio(0.9, 2.0, 0.5) == 0.5); + CHECK(rateRatioFromNorm(0.3, 0.0, 2.0) == 1.0); +} + // --- the whole-unit snaps ------------------------------------------------------------------- static void testMillisecondSnap() { @@ -255,6 +330,36 @@ static void testSemitoneSnap() { kDepth) == 7.0); } +// Rate's unit is the semitone though it displays as a percent, so Shift lands on the 25 steps +// between the bounds — which is what puts an octave and a fifth under the hand. The detent and +// both ends are reached EXACTLY, so a snap cannot leave the knob a hair off its own endpoint. +static void testRateSemitoneSnap() { + CHECK(snapRateRatioToWholeSemitone(1.0) == 1.0); + CHECK(snapRateRatioToWholeSemitone(0.5) == 0.5); + CHECK(snapRateRatioToWholeSemitone(2.0) == 2.0); + CHECK(std::fabs(snapRateRatioToWholeSemitone(1.5) - std::exp2(7.0 / 12.0)) < 1e-15); + // Just off a step in each direction resolves back onto it. + CHECK(std::fabs(snapRateRatioToWholeSemitone(std::exp2(7.0 / 12.0) * 1.005) - + std::exp2(7.0 / 12.0)) < 1e-15); + CHECK(std::fabs(snapRateRatioToWholeSemitone(std::exp2(7.0 / 12.0) * 0.995) - + std::exp2(7.0 / 12.0)) < 1e-15); + // Within a quarter-semitone of unity snaps to unity, not to a neighbouring step. + CHECK(snapRateRatioToWholeSemitone(std::exp2(0.25 / 12.0)) == 1.0); + CHECK(snapRateRatioToWholeSemitone(0.0) == 1.0); // unusable input parks at unity + CHECK(snapRateRatioToWholeSemitone(-1.0) == 1.0); + // What the knob actually stores after a Shift-drag is the snapped norm mapped back through + // the taper — so the property that matters is that THAT value is still a whole semitone. + // Measured in semitones, which is the unit the criterion is stated in. + for (int st = -12; st <= 12; ++st) { + const double norm = + rateNormFromRatio(std::exp2(static_cast(st) / 12.0), kRateMin, kRateMax); + const double stored = rateRatioFromNorm(norm, kRateMin, kRateMax); + const double semis = 12.0 * std::log2(stored); + CHECK(std::fabs(semis - static_cast(st)) < 1e-9); + if (!(std::fabs(semis - static_cast(st)) < 1e-9)) return; + } +} + // The exponent snap reaches 1.0, the linear neutral — one snap from the dial's centre — and // clamps into curve_law's own domain rather than rounding to a zero that is not an exponent. static void testExponentSnap() { @@ -285,9 +390,16 @@ int main() { testEveryWholeSemitoneRoundTripsExactly(); testDegenerateThrowCollapsesToCentre(); + testRateEndpointsAndCentreAreExact(); + testRateIsLinearInSemitonesAcrossTheWholeTravel(); + testRateIsMonotone(); + testRateDefaultAndEndpointsRoundTripBitwise(); + testDegenerateRateBoundsCollapseToUnity(); + testMillisecondSnap(); testPercentSnap(); testSemitoneSnap(); + testRateSemitoneSnap(); testExponentSnap(); if (g_fail == 0) std::printf("param_taper: all tests passed\n"); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 3ab30ef..3294b89 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -3070,12 +3070,14 @@ static void testPreserveStretchChangesDurationNotPitch() { CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0)); CHECK(approx(period(fast, 2000, 9000), srcPeriod, 8.0)); - // The non-tautology witness: VARISPEED is the engine that couples them. Reaching the same - // durations there costs exactly the pitch change Preserve refuses to make — so the three - // equal periods above are a property of the stretcher, not of the measurement. + // The non-tautology witness: VARISPEED is the engine that couples them. The SAME rate 0.5 + // reaches the same doubled duration there, and pays for it with exactly the octave Preserve + // refuses to drop — so the three equal periods above are a property of the stretcher, not of + // the measurement. std::size_t lifeVari = 0; const std::vector vari = run(0.5, PitchEngine::Varispeed, lifeVari); - CHECK(approx(static_cast(lifeVari), 24000.0, 200.0)); // rate ignored under Varispeed + CHECK(approx(static_cast(lifeVari), 48000.0, 400.0)); + CHECK(approx(period(vari, 2000, 9000), srcPeriod * 2.0, 16.0)); SampleData down = s; down.play.pitchEngine = PitchEngine::Varispeed; Voice vv; @@ -3091,6 +3093,200 @@ static void testPreserveStretchChangesDurationNotPitch() { CHECK(approx(period(variDown, 2000, 9000), srcPeriod * 2.0, 16.0)); // ...at half pitch } +// --- Rate, the Pitch offset and key-tracking compound into ONE read increment. --- + +// Proved by IDENTITY rather than by measurement: under Varispeed the three factors land in one +// multiply, so three different ways of asking for the same total ratio must render BYTE for +// BYTE the same. A per-sample stage added for either new control, or one of them applied at a +// different point in the chain, breaks this equality even where a measured pitch still looks +// right — which a period measurement alone would not catch. +static void testKeyTrackRateAndPitchOffsetResolveToOneMultiply() { + SampleData base = sineSample(20000, 100.0); + base.play.adsr = flatAdsr(); + base.play.pitchEngine = PitchEngine::Varispeed; + + const std::size_t n = 8000; + auto render = [&](int note, double rate, double offsetSemis) { + SampleData s = base; + s.play.playRate = rate; + s.play.pitchOffsetSemitones = offsetSemis; + Voice v; + v.start(note, 127, s, /*declickTakeover=*/false, rate); + std::vector out(n, 0.0f); + for (std::size_t i = 0; i < n; ++i) out[i] = v.renderFrame(); + return out; + }; + + // Three routes to a half-speed, octave-down read: through the keyboard, through Rate, and + // through the Pitch offset. + const std::vector viaNote = render(48, 1.0, 0.0); + const std::vector viaRate = render(60, 0.5, 0.0); + const std::vector viaOffset = render(60, 1.0, -12.0); + CHECK(hashStream(viaNote) == hashStream(viaRate)); + CHECK(hashStream(viaNote) == hashStream(viaOffset)); + // And they are not all trivially silent or all trivially unity — the route below differs. + CHECK(hashStream(viaNote) != hashStream(render(60, 1.0, 0.0))); + + // They MULTIPLY rather than accumulate anywhere else: an octave down at the keyboard and a + // doubled Rate cancel exactly, back to the untransposed read. + CHECK(hashStream(render(48, 2.0, 0.0)) == hashStream(render(60, 1.0, 0.0))); + // Same cancellation across the other pair, so no factor is privileged. + CHECK(hashStream(render(60, 2.0, -12.0)) == hashStream(render(60, 1.0, 0.0))); +} + +// Under PRESERVE the same three factors SPLIT: key-tracking and the Pitch offset drive the +// shifter's transpose, Rate drives duration alone. Asserted both ways round — the offset must +// move pitch WITHOUT moving duration, which is the mirror of the rate case beside it. +static void testPreserveRoutesRateToDurationAndTheOffsetToPitch() { + const std::int64_t w = 1024; + const std::size_t frames = 24000; + const double srcPeriod = 160.0; + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / srcPeriod)); + } + s.rootNote = 60; + s.play.adsr = flatAdsr(); + s.play.pitchEngine = PitchEngine::Preserve; + + auto run = [&](int note, double rate, double offsetSemis, std::size_t& life) { + SampleData local = s; + local.play.playRate = rate; + local.play.pitchOffsetSemitones = offsetSemis; + Voice v; + v.presizePreserveShifters(w); + v.start(note, 127, local, /*declickTakeover=*/false, rate); + std::vector out; + out.reserve(frames * 3); + life = 0; + for (std::size_t i = 0; i < frames * 3 && v.active(); ++i) { + out.push_back(v.renderFrame()); + ++life; + } + return out; + }; + auto period = [](const std::vector& v, std::size_t from, std::size_t to) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { + if (v[i - 1] <= 0.0f && v[i] > 0.0f) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; + }; + + std::size_t lifeFlat = 0, lifeDown = 0; + const std::vector flat = run(60, 1.0, 0.0, lifeFlat); + const std::vector down = run(60, 1.0, -12.0, lifeDown); + // Duration is untouched by the offset — only the transpose moved. + CHECK(approx(static_cast(lifeFlat), 24000.0, 200.0)); + CHECK(approx(static_cast(lifeDown), 24000.0, 200.0)); + CHECK(approx(period(flat, 2000, 9000), srcPeriod, 8.0)); + CHECK(approx(period(down, 2000, 9000), srcPeriod * 2.0, 16.0)); + + // The offset and the keyboard reach the shifter through the SAME factor, so an octave down + // from either is the identical render. + std::size_t lifeNote = 0; + const std::vector viaNote = run(48, 1.0, 0.0, lifeNote); + CHECK(hashStream(viaNote) == hashStream(down)); + // …and Rate does not reach it at all: a rate change moves duration and leaves the period. + std::size_t lifeSlow = 0; + const std::vector slow = run(60, 0.5, 0.0, lifeSlow); + CHECK(approx(static_cast(lifeSlow), 48000.0, 400.0)); + CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0)); +} + +// The loop's AUDIBLE period scales with Rate while its stored frames — the marks the waveform +// draws — are never rewritten. The source is a ramp confined to the loop span, so the rendered +// stream is a sawtooth whose period IS the loop traversed once. +static void testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames() { + constexpr std::int64_t kLoopStart = 4000; + constexpr std::int64_t kLoopEnd = 8000; + SampleData base; + base.frames.assign(20000, 0.0f); + for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) { + base.frames[static_cast(i)] = + static_cast(i - kLoopStart) / static_cast(kLoopEnd - kLoopStart); + } + base.rootNote = 60; + base.startFrame = kLoopStart; + base.loop = SampleLoop{true, kLoopStart, kLoopEnd}; + base.play.adsr = flatAdsr(); + base.play.pitchEngine = PitchEngine::Varispeed; + + // Output frames between successive mid-ramp crossings — the loop's audible period. Measured + // on the RISING half rather than on the seam: at a fractional read position the seam frame is + // interpolated across the wrap, so the drop arrives as two half-steps and an edge detector + // either misses it or counts it twice. The ramp crosses its midpoint exactly once per cycle. + auto sawPeriod = [](const std::vector& v) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = 1; i < v.size(); ++i) { + if (v[i - 1] <= 0.5f && v[i] > 0.5f) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; + }; + + for (double rate : {1.0, 0.5, 2.0}) { + SampleData s = base; + s.play.playRate = rate; + Voice v; + v.start(60, 127, s, /*declickTakeover=*/false, rate); + std::vector out(30000, 0.0f); + for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame(); + CHECK(approx(sawPeriod(out), 4000.0 / rate, 2.0)); + // The stored span is a source-frame FACT: the engine reads it and never writes it, so + // the two waveform markers sit where they sat. + CHECK(s.loop.start == kLoopStart); + CHECK(s.loop.end == kLoopEnd); + CHECK(s.startFrame == kLoopStart); + } +} + +// The asymmetry the spec is explicit about: a contour is OF THE SAMPLE and scales with Rate, a +// staged envelope is OF THE PERFORMANCE and does not. Trigger's AHD is the case that could go +// wrong — it is evaluated at the SOURCE offset, which advances at the rate — so its stage frames +// are fitted to that rate at note-on. Measured as the OUTPUT frame the attack completes on. +static void testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes() { + constexpr std::int64_t kAttack = 2000; + SampleData base = dcSample(24000); + base.play.playMode = PlayMode::Trigger; + base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; + + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + std::size_t lifeAtUnity = 0; + for (double rate : {1.0, 2.0, 0.5}) { + SampleData s = base; + s.play.pitchEngine = eng; + s.play.playRate = rate; + Voice v; + v.presizePreserveShifters(1024); + v.start(60, 127, s, /*declickTakeover=*/false, rate); + std::size_t life = 0, reachedFull = 0; + for (std::size_t i = 0; i < 80000 && v.active(); ++i) { + const double y = static_cast(v.renderFrame()); + if (reachedFull == 0 && y > 0.99) reachedFull = i; + ++life; + } + // The attack is wall clock: the same OUTPUT frame at every rate. + CHECK(approx(static_cast(reachedFull), static_cast(kAttack), 40.0)); + // …while the play span itself is source frames, so the note's length DOES scale. + if (rate == 1.0) lifeAtUnity = life; + else CHECK(approx(static_cast(life), + static_cast(lifeAtUnity) / rate, + static_cast(lifeAtUnity) * 0.02)); + } + } +} + // --- The onset is a regression surface: no added latency at ANY rate. --- static void testPreserveStretchSpeaksOnFrameZeroAtEveryRate() { const std::int64_t w = 2048; @@ -3440,6 +3636,10 @@ int main() { testPreserveUnityRateIsBitIdenticalToTheShippedRead(); testSourcePeriodChangesTheRenderedStream(); testPreserveStretchChangesDurationNotPitch(); + testKeyTrackRateAndPitchOffsetResolveToOneMultiply(); + testPreserveRoutesRateToDurationAndTheOffsetToPitch(); + testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames(); + testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes(); testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); testPreserveStretchLoopsTheSourceSpan(); testPreserveStretchThirtyTwoVoicesHoldUp(); From f1168e16eb75d5df31bd67b61e9255560b1be55e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 05:34:43 -0400 Subject: [PATCH 28/56] =?UTF-8?q?Close=20=CE=93-W1-T7=20re-review:=20pitch?= =?UTF-8?q?-sync=20cadence=20math,=20floor-model=20regression=20check,=20e?= =?UTF-8?q?vidence-count=20fix,=20one-home=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New cadence-collapse-band test at P=1470 shows PSOLA eliminates the corner rather than regressing it (18.52% -> 0.00%). --- src/core/instrument/CLAUDE.md | 8 +- src/core/instrument/engine/CMakeLists.txt | 12 ++- src/core/instrument/engine/period_detect.cpp | 15 +-- src/core/instrument/engine/pitch_shift.h | 18 +++- src/core/instrument/engine/time_stretch.h | 54 +++++++--- tests/test_period_detect.cpp | 15 ++- tests/test_period_render_integration.cpp | 106 +++++++++++++++++++ tests/test_pitch_shift.cpp | 50 +++++++++ tests/test_preserve_low_frequency.cpp | 11 +- 9 files changed, 249 insertions(+), 40 deletions(-) create mode 100644 tests/test_period_render_integration.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index a852599..d2df569 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -289,13 +289,13 @@ anything for a trigger shape. - `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter AND time-stretcher for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. - **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher** — `writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids. - - **Splices are PITCH-SYNCHRONOUS when the source's period is known** (`setSourcePeriod`, fed from `period_detect` via the loader): the nominal jump becomes the multiple of that period nearest the window that still fits the ring's jump bound (~1.25 windows), so an aligned landing point sits at the CENTRE of the correlation search instead of possibly not existing inside it at all. The search is unchanged and still earns its keep — it absorbs the jump's rounding to whole frames and tracks a source whose period drifts. **An unknown period restores the fixed-window geometry byte for byte**; do not "simplify" that fallback into an approximation of it. + - **Splices are PITCH-SYNCHRONOUS when the source's period is known** (`setSourcePeriod`, fed from `period_detect` via the loader): the nominal jump becomes the multiple of that period nearest the window that still fits the ring's jump bound (~1.25 windows), so an aligned landing point sits at the CENTRE of the correlation search instead of possibly not existing inside it at all. The search is unchanged and still earns its keep — it absorbs the jump's rounding to whole frames and tracks a source whose period drifts. **An unknown period restores the fixed-window geometry exactly** (`periodAlignedJump`, `pitch_shift.h`); do not "simplify" that fallback into an approximation of it. - `period_detect` — the source's own fundamental period, estimated ONCE per load (two-pass YIN: a decimated cumulative-mean-normalized difference picks the period, the full-rate difference function refines it to a fraction of a frame), so `pitch_shift`'s splice jump can be a whole - number of it. **It runs off the audio thread BY LINK GRAPH: `sampler_core` does not link it**, - so no TU on the render path can name `detectPeriod` — the same shape as the extension's link - graph not gaining the voice engine. Its one caller is the loader (`map/sample_map`'s + number of it. **Runs off the audio thread by link graph** (`period_detect.h` is the one home + for that invariant) — the same shape as the extension's link graph not gaining the voice + engine. Its one caller is the loader (`map/sample_map`'s `buildSampleData`), which hands the answer down on `SampleData::sourcePeriodFrames`. A period is DERIVED from the audio, so it is cache and not state: nothing persists it, and it takes no rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony, diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 5bb6a60..4954cab 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -5,10 +5,9 @@ reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks) # specifically the compile-time proof it does not drag in the WDL chain. reasampler_test(pitch_shift LINK pitch_shift) -# Deliberately NOT linked by sampler_core, and that omission is the structural proof the -# detector cannot run on the audio thread: no TU on the render path can name detectPeriod -# without failing to link in sampler_core_tests, which links sampler_core and nothing else. -# Its one caller is the loader (map/sample_map), which runs off-thread by construction. +# Deliberately NOT linked by sampler_core, enforcing period_detect.h's off-audio-thread +# invariant at build time: sampler_core_tests links sampler_core and nothing else, so no TU +# on the render path can name detectPeriod without failing to link. reasampler_pure_library(period_detect SOURCES period_detect.cpp LINK PUBLIC peaks) reasampler_test(period_detect LINK period_detect) @@ -69,6 +68,11 @@ add_executable(preserve_low_frequency_tests # does, which is exactly the seam under measurement. target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect) +# Bridges the two structural proofs above (sample_map never links the voice engine; +# sampler_core never links period_detect) for the one case that needs both: a REAL detected +# period reaching a real Preserve render. Its own target rather than extending either. +reasampler_test(period_render_integration LINK sample_map sampler_core) + # The Preserve read's source-feed schedule — the TIME half beside pitch_shift's PITCH half. # Header-only (it sits on the per-sample feed), hence INTERFACE. add_library(time_stretch INTERFACE) diff --git a/src/core/instrument/engine/period_detect.cpp b/src/core/instrument/engine/period_detect.cpp index 35622e0..0b522b6 100644 --- a/src/core/instrument/engine/period_detect.cpp +++ b/src/core/instrument/engine/period_detect.cpp @@ -145,25 +145,27 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, std::vector periods; std::vector confidences; - // Probes that carried signal — the agreement denominator. A silent block is no evidence - // either way and is excluded; every other outcome, a period found or not, is evidence. + // Probes that carried signal AND ran the real dip search — the agreement denominator. A + // silent block is no evidence either way; a block whose decimated search band or full-rate + // refine bracket collapsed to nothing (the two geometry continues below) never ran that + // search either, so it is excluded on the same footing as silence, not counted as if it had. std::size_t evidence = 0; for (std::size_t p = 0; p < probes; ++p) { // (probes - 1) * stride <= room by construction, so the last block always fits. const std::size_t from = spanFrom + p * stride; if (blockRms(pcm, from, block) < kSilenceRms) continue; - ++evidence; const std::vector small = decimate(pcm, from, block); const std::size_t smallHi = lagHi / kDecimate; const std::size_t smallW = small.size() - smallHi; - if (smallHi <= lagLo / kDecimate + 2 || smallW == 0) continue; + if (smallHi <= lagLo / kDecimate + 2 || smallW == 0) continue; // degenerate geometry const std::vector dp = cmndf(small, smallW, smallHi); double coarseTau = 0.0, dissimilarity = 1.0; if (!pickPeriod(dp, std::max(2, lagLo / kDecimate), coarseTau, dissimilarity)) { - continue; // no dip below threshold: this block has no single period + ++evidence; // the search ran and found no dip: real evidence against a period + continue; } // Bracket the full-rate refinement at +/- 2 decimated samples around the coarse pick: @@ -174,7 +176,8 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, std::max(static_cast(lagLo), centre - 2.0 * kDecimate)); const std::size_t hi = static_cast( std::min(static_cast(lagHi), centre + 2.0 * kDecimate)); - if (hi <= lo) continue; + if (hi <= lo) continue; // degenerate refine bracket + ++evidence; // the search ran and found a period: real evidence for one periods.push_back(refineFullRate(pcm, from, block - hi, lo, hi)); confidences.push_back(1.0 - dissimilarity); } diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index 72ae0fb..d1593b6 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -119,14 +119,15 @@ public: void setFeedRate(double rate); // The period of the source being fed, in SOURCE frames, making every splice jump a whole - // number of it (see periodAlignedJump). <= 0 means "unknown" and restores the fixed-window - // geometry byte for byte — the default, so a caller that never calls this sees no change. + // number of it — <= 0 means "unknown" (see periodAlignedJump for the exact fallback); the + // default, so a caller that never calls this sees no change. // Detection itself is off-thread and elsewhere (period_detect, which the engine deliberately // does not link); this is a couple of divisions and is safe to call at note-on. // Cleared by configure()/reset(); NOT by prime()/warm(), which do not change the source. void setSourcePeriod(double periodFrames); - // The nominal jump splices currently use — window() unless a source period narrowed it. + // The nominal jump splices currently use — window() unless a source period retuned it to + // the nearest whole-period multiple, which can land either narrower or wider than window(). std::int64_t spliceJump() const { return jump_; } // Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the @@ -209,11 +210,18 @@ private: // tap can never drain into the writer mid-fade std::int64_t maxLag_ = 0; // correlation search half-range (window_/4) double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window) - std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ narrows it + std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ retunes it std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY // inside [dLow_, dHigh_] at the worst search lag, so a // period-sized jump can never land back on a trigger and - // thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_) + // thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At + // jump_==jumpMax_ a DOWN-splice's correlation read comes + // within ~41 frames of the write head (measured: the + // exact ring/lag/corrFrames_ geometry at the product + // window, worst case over every lag the search reaches) — + // real margin, not zero, but tight enough that widening + // maxLag_, corrFrames_ or jumpMax_ without re-deriving + // this bound risks reading unwritten ring content. std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so // the reference read forward from the tap stays behind // the writer by construction at an up-splice) diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index 1552feb..163346c 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -15,22 +15,42 @@ namespace reasampler::instrument::engine { // source frames) — the RT-safety argument for feeding a variable count at all. // // This range NARROWS the splice-cadence failure onto the source fundamental; it does not -// eliminate it. A splice recurs every `window / |rate - shift|` output frames (the tap's -// delay drifts across one window at that per-frame rate); the shifted tone's own period is -// `sourcePeriod / shift` output frames. Whenever the recurrence interval is shorter than -// that period, a splice lands inside a single perceived cycle and the correlation search -// has less than one period to align against. Measured at rate 4.0, shift 0.25 (-24 st): -// interval 2205/3.75 ~= 588 vs period ~4*P ~= 785 frames (P ~= 196) — matches the originally -// observed 539-vs-785 failure. This range's ceiling (2.0, not 4.0) raises the safe floor, it -// does not remove it: at rate 2.0, shift 0.25, interval = 2205/1.75 = 1260 still produces -// measurable splice debris for any source period P > 315 frames (~140 Hz at 44.1k) — inside -// bass/low-vocal material, and -24 st is reachable from the Pitch knob alone. pitch_shift_tests -// (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) asserts this corner directly at -// P=500/600/700: energy outside the fundamental runs 7-21% there against ~0% on an aligned -// control at the same rate/shift — zero-crossing period is NOT what it checks, since splice -// debris fools that estimator into reading the wrong period on a render whose fundamental is -// actually fine. (The pre-stretch rate-1.0 engine's floor by the same inequality is P > 735, -// ~60 Hz — what this range raises the floor from, not what it removes.) +// eliminate it. A splice recurs every `pitch_shift.h`'s spliceJump() / |rate - shift| output +// frames (the tap's delay drifts across one nominal jump at that per-frame rate); the shifted +// tone's own period is `sourcePeriod / shift` output frames. Whenever the recurrence interval +// is shorter than that period, a splice lands inside a single perceived cycle and the +// correlation search has less than one period to align against. Measured at rate 4.0, shift +// 0.25 (-24 st), fixed-window jump (2205): interval 2205/3.75 ~= 588 vs period ~4*P ~= 785 +// frames (P ~= 196) — matches the originally observed 539-vs-785 failure. This range's ceiling +// (2.0, not 4.0) raises the safe floor, it does not remove it: at rate 2.0, shift 0.25, interval +// = 2205/1.75 = 1260 still produces measurable splice debris for any source period P > 315 +// frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -24 st is reachable from the +// Pitch knob alone. pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) +// asserts this corner directly at P=500/600/700: energy outside the fundamental runs 7-21% there +// against ~0% on an aligned control at the same rate/shift — zero-crossing period is NOT what it +// checks, since splice debris fools that estimator into reading the wrong period on a render +// whose fundamental is provably correct. (The pre-stretch rate-1.0 engine's floor by the same +// inequality is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.) +// +// The above derives the floor with jump == window(), which is only the FIXED-WINDOW half of +// the story. Once a source period is known, spliceJump() is periodAlignedJump's answer instead +// (pitch_shift.h), and that answer can land NARROWER than window() — as low as ~0.63*window for +// some periods — which SHRINKS the interval and moves the failure threshold EARLIER, not later. +// There is no single closed-form floor for this case (the jump is itself a function of P), so +// read it at the concrete corner instead: at P=1470 (30 Hz at 44.1k) the same rate 2.0/shift +// 0.25 corner's jump narrows from window (2205) to 1470, and its interval from 1260 to +// 1470/1.75 = 840. Independently, at the plain (no time-stretch) rate 1.0 case, solving this +// same inequality for shift at P=1470 puts the failure threshold at shift = P/(jump+P): 0.4 +// (-16 st) at the fixed-window jump (2205), 0.5 (-12 st) at the pitch-synchronous jump (1470) — +// the geometry fix that lets 30 Hz align AT ALL moves this unrelated cadence inequality's own +// trip point from roughly -16 st to roughly -12 st for the same source. Do NOT read this as a +// proven regression: the inequality above was calibrated for RANDOM-PHASE (unaligned) splices, +// and a pitch-synchronous splice is waveform-aligned by construction, which the inequality does +// not model — whether the shorter interval still produces audible debris once every splice +// lands in phase is what pitch_shift_tests' own P=1470 cadence-collapse-band measurement +// answers, not this derivation. Do not narrow kStretchRateMin/kStretchRateMax in response to +// this: sub-50 Hz sine material is first-class product material, not an edge case, and a +// narrower range does not fix a floor it does not reach. // // A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now // CLOSED for any source whose period is detected, but the geometry is worth keeping because it @@ -56,7 +76,7 @@ namespace reasampler::instrument::engine { // pitch_shift_tests' testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown, a different // quantity from the raw percentages here. What survives: a period longer than the reachable // jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no -// single period falls back to this fixed-window geometry by design. +// single period falls back to it by design (periodAlignedJump, pitch_shift.h). inline constexpr double kStretchRateMin = 0.5; inline constexpr double kStretchRateMax = 2.0; inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) diff --git a/tests/test_period_detect.cpp b/tests/test_period_detect.cpp index fc05199..637db4f 100644 --- a/tests/test_period_detect.cpp +++ b/tests/test_period_detect.cpp @@ -394,10 +394,21 @@ static void testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand() { periodAnalysisSpan(frames, 60000, 60000 + static_cast(minimum), true, rate); CHECK(atMinimum.from == 60000 && atMinimum.count == minimum); - // And the too-short loop still DETECTS through the wider span — refusing there would be a - // regression against analysing the whole source, and a short sustain loop is common. const double p = static_cast(rate) / 30.0; const std::vector src = sineOfPeriod(frames, p); + + // The span IS taken at the minimum, but that alone doesn't say detection BEHAVES there: at + // exactly one probe block, detectPeriod takes the lone-probe carve-out (no agreement check + // at all) — the span choice and the accept rule meet at this exact boundary, and that + // meeting point is what needs to actually detect, not just be selected. + const PeriodEstimate atMin = detectPeriod(src, rate, atMinimum.from, atMinimum.count); + std::printf(" loop at the minimum span -> %s (%.3f, want %.3f)\n", + atMin.valid() ? "detected" : "NONE", atMin.frames, p); + CHECK(atMin.valid()); + if (atMin.valid()) CHECK(std::fabs(atMin.frames - p) < 0.5); + + // And the too-short loop still DETECTS through the wider span — refusing there would be a + // regression against analysing the whole source, and a short sustain loop is common. const PeriodEstimate est = detectPeriod(src, rate, shortLoop.from, shortLoop.count); std::printf(" short loop -> whole source: %s (%.3f)\n", est.valid() ? "detected" : "NONE", est.frames); diff --git a/tests/test_period_render_integration.cpp b/tests/test_period_render_integration.cpp new file mode 100644 index 0000000..ad7bd3c --- /dev/null +++ b/tests/test_period_render_integration.cpp @@ -0,0 +1,106 @@ +// The one gated case that runs the WHOLE load->voice wire through REAL detection, closing the +// gap between two halves proven separately: testBuildSampleDataDetectsThirtyHertzSourcePeriod +// (test_sample_map.cpp, detectPeriod -> SampleData) never renders, and +// testSourcePeriodChangesTheRenderedStream (test_sampler_core.cpp, SampleData -> Voice -> audio) +// sets sourcePeriodFrames by hand rather than detecting it from PCM. Deliberately its own +// target: sample_map_tests and sampler_core_tests each keep their one-lib-only structural proof +// (map doesn't link the voice engine, the engine doesn't link period_detect), so bridging the +// two lives here instead of extending either. + +#include "../src/core/instrument/map/sample_map.h" +#include "../src/core/instrument/engine/voice_engine.h" + +#include +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::engine; +using namespace reasampler::instrument::map; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +constexpr double kPi = 3.14159265358979323846; + +// An ADSR that stays fully open (level 1) forever while held — isolates the render from +// envelope shaping (matches test_sampler_core.cpp's flatAdsr; not shared, both files stand +// alone). +static AdsrParams flatAdsr() { + AdsrParams a; + a.attackFrames = 0; + a.decayFrames = 0; + a.sustainLevel = 1.0; + a.releaseFrames = 0; + return a; +} + +// FNV-1a over the raw float bits (matches test_sampler_core.cpp's hashStream). +static std::uint64_t hashStream(const std::vector& v) { + std::uint64_t h = 1469598103934665603ull; + for (const AudioSample s : v) { + std::uint32_t bits = 0; + std::memcpy(&bits, &s, sizeof(bits)); + for (int b = 0; b < 4; ++b) { + h ^= static_cast((bits >> (8 * b)) & 0xffu); + h *= 1099511628211ull; + } + } + return h; +} + +static void renderVoice(const SampleData& s, int note, std::int64_t window, + std::size_t outFrames, std::vector& out) { + Voice v; + v.presizePreserveShifters(window); + v.start(note, 127, s, /*declickTakeover=*/false, /*rate=*/1.0); + out.resize(outFrames); + for (std::size_t i = 0; i < outFrames; ++i) out[i] = v.renderFrame(); +} + +// 30 Hz @ 44.1k through the REAL wire: buildSampleData (sample_map.cpp:333-334) calls +// detectPeriod itself, so this proves the detected period actually reaches and moves the +// Preserve render — not just that a hand-set sourcePeriodFrames does (that is the sampler_core +// half; this is the missing map->engine seam). +static void testDetectedPeriodReachesAndMovesThePreserveRender() { + const std::int64_t w = 2205; // the product window at 44.1k + const int rate = 44100; + const std::size_t frames = 30000; + std::vector pcm(frames); + for (std::size_t i = 0; i < frames; ++i) { + pcm[i] = static_cast( + std::sin(2.0 * kPi * 30.0 * static_cast(i) / rate)); + } + + SelectedSample ref; + ref.relativePath = "b/a.wav"; + ref.rootNote = 60; + SampleData on = buildSampleData(resolveCapture(ref, InstrumentParams{}), + DecodedPcm{pcm, rate, {}}); + CHECK(std::fabs(on.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz, real detection + + on.play.adsr = flatAdsr(); + on.play.pitchEngine = PitchEngine::Preserve; + SampleData off = on; + off.sourcePeriodFrames = 0.0; // the fixed-window fallback the pre-wire render used + + std::vector outOn, outOff; + renderVoice(on, /*note=*/67, w, 6000, outOn); // +7 st: real splices + renderVoice(off, 67, w, 6000, outOff); + for (AudioSample v : outOn) CHECK(std::isfinite(v)); + CHECK(hashStream(outOn) != hashStream(outOff)); // the detected period actually moved the render +} + +int main() { + testDetectedPeriodReachesAndMovesThePreserveRender(); + + if (g_fail == 0) { + std::printf("all period_render_integration tests passed\n"); + return 0; + } + std::printf("%d period_render_integration check(s) failed\n", g_fail); + return 1; +} diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index 99706c7..a3f99f6 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include using namespace reasampler; @@ -920,6 +921,9 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() { {"34 Hz rate 2.0", 34.0, 2.0, 0.0}, }; double controlWorst = 0.0, subjectWorst = 0.0, subjectOffWorst = 0.0; + // std::max alone floors a NEGATIVE floor-relative excess to 0 — but a negative excess means + // the floor model mismatches the render, not a clean one, so track the signed minimum too. + double subjectWorstMin = std::numeric_limits::infinity(); for (const Row& r : rows) { const double period = 44100.0 / r.freq; std::vector src(srcLen); @@ -943,6 +947,7 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() { controlWorst = std::max(controlWorst, pctOn); } else { subjectWorst = std::max(subjectWorst, pctOn); + subjectWorstMin = std::min(subjectWorstMin, pctOn); subjectOffWorst = std::max(subjectOffWorst, pctOff); } } @@ -953,6 +958,8 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() { std::printf(" [30 Hz] worst subject excess %.2f%% vs worst control excess %.2f%%\n", subjectWorst, controlWorst); CHECK(subjectWorst < 0.10); + CHECK(subjectWorstMin > -0.10); // a negative excess this large is a model + // mismatch, not a clean render — catch it too CHECK(subjectWorst <= controlWorst + 0.05); // 0.05 absorbs the floor subtraction's sign noise // Vacuity guard, matching testTwentyNineHertzAtRateTwoKeepsItsPitch's sibling check: the // FIXED-WINDOW (no period set) arm is asserted too, so a setSourcePeriod that silently did @@ -1029,6 +1036,48 @@ static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() { } } +// M1 (Gamma-W1-T7 re-review): the corner above sits where periodAlignedJump narrows the jump +// by only ~10% (2205 -> 2000/2400/2100 at P=500/600/700), never reaching the collapse band +// (jump_->0.63-0.67*window) time_stretch.h's own derivation flags as where the recurrence +// interval shrinks hardest. P=1470 (30 Hz at 44.1k) is the case that track exists for: n=2 +// overshoots jumpMax_ (2*1470=2940 > 2756), forcing n=1 and jump_=1470=0.667*window — 1.5x the +// splice rate of the fixed-window fallback. +// +// MEASURED (Debug, this machine): metric floor 55.86% (P=1470's want-period of 5880 fr under a +// 32768-frame segment leaks a lot of mainlobe, same effect as the P=500-700 rows, just larger), +// fixed-window excess 18.52%, pitch-synchronous excess 0.00%. The faster cadence does NOT +// regress this corner — every splice at n=1 lands exactly one source period away, so despite +// firing 1.5x as often each one is phase-perfect rather than merely aligned-on-average, and the +// corner clears rather than worsens. Recorded as read, not tuned: if a future change moves +// these numbers, update this comment to match, don't loosen the bounds to hide it. +static void testCadenceCollapseBandAtThirtyHertzUnderPitchSynchronousSplice() { + using reasampler::test_support::energyOutsideFundamentalPercent; + const std::int64_t w = 2205; + const double rate = 2.0; + const double shift = std::pow(2.0, -24.0 / 12.0); // -24 st, the same corner as above + const double period = 1470.0; // 30 Hz at 44.1k + const std::size_t outFrames = 60000, from = 20000, len = 32768; + const std::size_t srcLen = 400000; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / period)); + } + const std::vector off = runStretch(src, w, rate, shift, outFrames, nullptr); + const std::vector on = runStretch(src, w, rate, shift, outFrames, nullptr, period); + for (double v : on) CHECK(std::isfinite(v)); + const double want = period / shift; + const double floor = idealToneFloorPercent(want, from, len); + const double pctOff = energyOutsideFundamentalPercent(off, from, len, want); + const double pctOn = energyOutsideFundamentalPercent(on, from, len, want); + std::printf(" [cadence collapse band, PSOLA] period %.0f (want %.0f, metric floor %.2f%%): " + "excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor, pctOn - floor); + CHECK(pctOn - floor < 1.0); // measured 0.00%: phase-perfect at n=1, not merely aligned + // Vacuity guard (shape of testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown's :961): the + // FIXED-WINDOW arm is asserted too (measured 18.52% excess), so a setSourcePeriod that + // silently did nothing would render both arms identically and pass the bound above by luck. + CHECK(pctOff - floor > pctOn - floor + 1.0); +} + // The two new entry points on a shifter that was never configured (a Varispeed voice's) — // neither may touch the empty ring. static void testStretchEntryPointsOnPassThrough() { @@ -1056,6 +1105,7 @@ int main() { testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown(); testTwentyNineHertzAtRateTwoKeepsItsPitch(); testCadenceCornerIsUnmovedByAPitchSynchronousSplice(); + testCadenceCollapseBandAtThirtyHertzUnderPitchSynchronousSplice(); testStretchEntryPointsOnPassThrough(); if (g_fail == 0) { diff --git a/tests/test_preserve_low_frequency.cpp b/tests/test_preserve_low_frequency.cpp index 8888015..dabc67c 100644 --- a/tests/test_preserve_low_frequency.cpp +++ b/tests/test_preserve_low_frequency.cpp @@ -592,8 +592,15 @@ static void reportFloorProbeMechanism() { int n = 0; const bool reach = alignmentReachable(period, lo, hi, &n); const double freq = static_cast(sr) / period; - // The cadence inequality from time_stretch.h, evaluated for this row. - const double cadence = static_cast(w) / std::fabs(rate - shift); + // The cadence inequality from time_stretch.h, evaluated for this row against the + // ACTUAL nominal jump this geometry resolves to — under g_pitchSynchronous that is + // periodAlignedJump's answer, not always the fixed window, so the two passes of this + // report (fixed-window / pitch-synchronous) must not print the same number. + PitchShifter jumpProbe; + jumpProbe.configure(w); + jumpProbe.setSourcePeriod(g_pitchSynchronous ? period : 0.0); + const double cadence = + static_cast(jumpProbe.spliceJump()) / std::fabs(rate - shift); const double outPeriod = period / shift; std::printf(" P=%5.0f (%.1f Hz): alignable in [%.0f,%.0f]? %s%s | cadence %.0f fr vs " "output period %.0f fr -> %s\n", From cbe2369037b5c99ee28591b713355bc4cdece553 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 06:30:59 -0400 Subject: [PATCH 29/56] Bake window: derive it from the rate the voice actually reads at, so a dialled Rate or downward Pitch no longer truncates the file --- src/core/instrument/bake/CLAUDE.md | 10 +- src/core/instrument/bake/bake_plan.cpp | 48 ++++-- src/core/instrument/bake/bake_plan.h | 5 +- src/core/instrument/engine/envelopes.h | 13 +- src/core/instrument/engine/voice.cpp | 45 +++--- src/core/instrument/engine/voice.h | 55 ++++++- src/core/instrument/map/params_payload.cpp | 13 +- src/core/instrument/ui/param_taper.cpp | 16 +- src/core/instrument/ui/param_taper.h | 7 +- tests/test_bake_reset.cpp | 11 ++ tests/test_bake_window.cpp | 128 +++++++++++++++ tests/test_component_state_io.cpp | 17 +- tests/test_deck_values.cpp | 6 + tests/test_live_delivery.cpp | 98 +++++++++++- tests/test_param_taper.cpp | 34 ++++ tests/test_sampler_core.cpp | 177 +++++++++++++++++++++ 16 files changed, 609 insertions(+), 74 deletions(-) diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 5a9075a..6c3246a 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -67,10 +67,12 @@ decision about what the render made obsolete. - **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are offset from each other whenever the note and the capture window do not start together. `bake_plan.h` says which field is in which; do not read them as one clock. -- **`defaultBakeProgram`'s Varispeed bound is an upper bound, not a model.** A downward pitch - offset makes the read head take longer to cross its span, so the window is scaled by the - deepest downward offset the voice can reach — a shallower excursion leaves trailing silence - in the file. Both the Trigger span and the Gate exhaustion length take it. +- **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that + slows the read makes the head take longer to cross its span, so the window is scaled by the + slowest read the voice can reach — a shallower excursion leaves trailing silence in the file. + Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed + alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length + take the product, and the Gate-with-loop branch takes neither. - **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves are live, so the velocity is a property of the sound being printed and not a detail of the render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window). diff --git a/src/core/instrument/bake/bake_plan.cpp b/src/core/instrument/bake/bake_plan.cpp index d991ea5..04820be 100644 --- a/src/core/instrument/bake/bake_plan.cpp +++ b/src/core/instrument/bake/bake_plan.cpp @@ -6,6 +6,7 @@ #include #include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold) +#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound) #include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length) #include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula) @@ -28,22 +29,36 @@ bool toFrames(double seconds, int rate, std::int64_t& out) { return true; } -// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only -// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset -// stretches how long the source takes to play out. Preserve decouples the two, and a Gate -// release is ticked per output frame, so neither is affected. -double downwardSemitones(const PlayParams& play, int velocity) { - if (play.pitchEngine != PitchEngine::Varispeed) return 0.0; - double down = (std::min)(0.0, kVelocityPitchRangeSemitones * - play.pitchVelocityCurve.eval(velocity)); - if (play.pitchEnv.enabled) { - // A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points; - // the staged AHD only ever travels between 0 and the peak. - down += play.pitchSpline.mode == EnvMode::Spline - ? -std::fabs(play.pitchEnv.peakSemitones) - : (std::min)(0.0, play.pitchEnv.peakSemitones); +// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the +// factor a source span is scaled by to bound how long it takes to play out. Two terms: +// +// Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve +// feeds the stretcher at it, so either way the source is consumed at that many frames per +// output frame. Taken through the engine's clamp, because that is the value Voice::start +// actually plays. +// +// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head +// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read +// rate alone, which is the only sense in which the two are decoupled there. +// +// A Gate release is ticked per output frame, so neither term touches it. +double playbackStretch(const PlayParams& play, int velocity) { + double down = 0.0; + if (play.pitchEngine == PitchEngine::Varispeed) { + down = (std::min)(0.0, kVelocityPitchRangeSemitones * + play.pitchVelocityCurve.eval(velocity)); + // Taken as a bound rather than exactly, like the velocity term beside it: an upward + // offset only makes the read faster, and every term in this sum is a floor. + down += (std::min)(0.0, play.pitchOffsetSemitones); + if (play.pitchEnv.enabled) { + // A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points; + // the staged AHD only ever travels between 0 and the peak. + down += play.pitchSpline.mode == EnvMode::Spline + ? -std::fabs(play.pitchEnv.peakSemitones) + : (std::min)(0.0, play.pitchEnv.peakSemitones); + } } - return down; + return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate); } // Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top) @@ -78,8 +93,7 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate, const double rate = static_cast(renderSampleRate); const auto frameCount = static_cast(dialed.frames.size()); const std::int64_t start = effectiveStart(dialed); - const double stretch = - std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0); + const double stretch = playbackStretch(dialed.play, p.velocity.value()); const double releaseSeconds = static_cast(dialed.play.adsr.releaseFrames) / rate; double endOffsetSeconds = 0.0; diff --git a/src/core/instrument/bake/bake_plan.h b/src/core/instrument/bake/bake_plan.h index b5ea3a1..c8def2c 100644 --- a/src/core/instrument/bake/bake_plan.h +++ b/src/core/instrument/bake/bake_plan.h @@ -32,7 +32,8 @@ bool bakeWindowNeedsHold(const SampleData& dialed); // the bake renders at, which is what the engine's frame counts are consumed against): // // Trigger — the note IS the play span (note-off is ignored anyway), stretched by the -// deepest downward Varispeed offset. +// slowest read the dialed voice can reach: Rate under BOTH engines, plus the +// deepest downward pitch offset under Varispeed. // Gate, loop — `hold` is the note length; the end offset is the release. // Gate, no loop— the read head runs off the source and frees the voice whatever the gate is // doing, so the note is the whole post-start span, stretched the same way. @@ -44,7 +45,7 @@ bool bakeWindowNeedsHold(const SampleData& dialed); // Every case is padded by the voice's terminal declick ramp (kDeclickFrames): trailing // silence is free, and closing the window on the frame the ramp starts is a hard cut. // `hold` is read only in the Gate-with-loop case; `velocity` is the velocity the note fires -// at, and it feeds the Varispeed stretch as well as the render. +// at, and it feeds the Varispeed half of that stretch as well as the render. // // Takes no tempo: nothing derived here is beat-denominated. The one field that is — `hold` — // meets the tempo in resolveNote, with the rest of the program's beat-denominated fields. diff --git a/src/core/instrument/engine/envelopes.h b/src/core/instrument/engine/envelopes.h index 7818a96..53baef1 100644 --- a/src/core/instrument/engine/envelopes.h +++ b/src/core/instrument/engine/envelopes.h @@ -421,7 +421,12 @@ public: // Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice // that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete // toggle travelling by reload, so the caller's copy of it is deliberately ignored. - void snapLive(const PitchEnvParams& params) { + // + // Both live entry points re-take `spanFrames` rather than keeping configure()'s: the span is + // an OUTPUT-frame duration the caller converts from the read rate, and that rate carries a + // live control (voice.h's pitchEnvSpanFrames). Passing the span back unchanged is exact. + void snapLive(std::int64_t spanFrames, const PitchEnvParams& params) { + span_ = spanFrames > 0 ? spanFrames : 0; params_.peakSemitones = params.peakSemitones; params_.shape = params.shape; fit_ = fitAhd(span_, params_.shape); @@ -430,9 +435,11 @@ public: // Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized // position within whichever leg the envelope is in, and absorb the depth step (peak is a - // level, not a duration). - void applyLive(const PitchEnvParams& params) { + // level, not a duration). A moved span re-fits under the same rule, so a live Pitch move + // reshapes this envelope continuously instead of leaving it on the note-on read rate. + void applyLive(std::int64_t spanFrames, const PitchEnvParams& params) { const double before = offsetAt(); + span_ = spanFrames > 0 ? spanFrames : 0; const AhdSpan next = fitAhd(span_, params.shape); pos_ = holdPhase(fit_, next); params_.peakSemitones = params.peakSemitones; diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index eb0e5cc..73d12f8 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -70,9 +70,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick // configured, and a Preserve voice whose shifters were never sized falls back to the // varispeed read. Rate has to reach the increment there too, or that fallback would ignore // the control outright — the predicate is spelled the same way advanceFrame spells it. - const bool preserveRead = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured(); - rateRatio_ = preserveRead ? 1.0 : stretchRate_; + preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured(); + rateRatio_ = preserveRead_ ? 1.0 : stretchRate_; recomputeBaseRatio(); + // pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at + // Pitch 0 it is a division by exactly 1.0. + pitchSpanBaseRate_ = baseRatio_ / pitchOffsetRatio_; // Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top) // rather than starting a voice already off the end. @@ -133,16 +136,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick } // The pitch AHD's Hold fraction is taken against the whole playable span, so its three - // stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count - // and this envelope counts OUTPUT frames (envelopes.h), so the span has to be divided by the - // rate the read head consumes source at — baseRatio_ under Varispeed, the stretch rate under - // Preserve — or a transposed (or re-rated) note's envelope outruns the note it shapes. - // Divides by baseRatio_ alone under Varispeed, though the actual read rate is baseRatio_ x - // envFactor — a deep pitch envelope makes that a first-order approximation, not exact. - const double readRate = preserveRead ? stretchRate_ : baseRatio_; - const double pitchSpan = (readRate > 0.0) ? static_cast(postStart) / readRate - : static_cast(postStart); - pitchEnv_.configure(static_cast(pitchSpan + 0.5), p.pitchEnv); + // stages lay 1:1 over the waveform from the start point. The source->output conversion, and + // why it is only first-order, are pitchEnvSpanFrames' own (voice.h). + pitchEnv_.configure(pitchEnvSpanFrames(), p.pitchEnv); pitchEnv_.noteOn(); // A restart lands every live glide back on the new note's own values, at a step derived @@ -273,22 +269,24 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { // // live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class, // delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The - // latched stretchRate_ is what rateFittedAhd converts a live AHD against, so a stage-time - // move mid-note lands in this note's own rate domain rather than resetting it. + // latched stretchRate_ is what stageFitRate carries into every conversion below, so a + // stage-time move mid-note lands in this note's own rate domain rather than resetting it. const bool gate = (playMode_ == PlayMode::Gate); + // The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_ + // up as one more factor of next frame's read increment, Preserve as the shifter's transpose. + // Applied BEFORE the envelopes below, because under Varispeed it is a factor of the read rate + // both of them are fitted against — a stale offset here would fit them to the previous move. + pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones); + recomputeBaseRatio(); if (snap) { if (gate) env_.snapLive(live.adsr); else ampAhd_.snapLive(rateFittedAhd(live.ampAhd)); - pitchEnv_.snapLive(live.pitchEnv); + pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv); } else { if (gate) env_.applyLive(live.adsr); else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd)); - pitchEnv_.applyLive(live.pitchEnv); + pitchEnv_.applyLive(pitchEnvSpanFrames(), live.pitchEnv); } - // The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_ - // up as one more factor of next frame's read increment, Preserve as the shifter's transpose. - pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones); - recomputeBaseRatio(); // The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but // pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_, // which already glides through rModAmount_'s live ramp regardless of spline state (below), @@ -340,9 +338,10 @@ void Voice::retune(int note) { // legato phrase is one gesture, one strike (classic mono-synth behavior). if (!active_ || sample_ == nullptr) return; note_ = note; - // Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the - // baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the - // first note's domain — consistent with "touch nothing else," but the drift lives here. + // Changes baseRatio_ without re-converting pitchEnv_'s already-configured span + // (pitchEnvSpanFrames, whose base rate this deliberately does not move), so a slide leaves + // that envelope on the first note's domain — consistent with "touch nothing else," but the + // drift lives here. // The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ — // one gesture, one strike. Rate and the Pitch offset ride through too: only the note moved. recomputeBaseRatio(); diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 7c119ee..e104eb8 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -205,22 +205,53 @@ private: velPitchRatio_ * pitchOffsetRatio_ * rateRatio_; } + // The rate the read head consumes SOURCE at, counting only the factors whose stage-time + // coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset + // transposes inside the shifter and never touches the read. Under Varispeed both Rate and + // Pitch are factors of the read increment and both are compensated: they are two views of one + // multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the + // velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped + // sounds, and compensating them would move every note off the root. + double stageFitRate() const { + return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_; + } + // A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the - // sustain-less envelopes are evaluated in (sourceOffset()). Rate stretches the source span - // those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate — multiplying by - // the read rate is exactly that conversion. The Varispeed PITCH coupling is deliberately NOT - // compensated here: it predates Rate and is the shipped behaviour. Rate 1.0 returns the - // argument untouched, which is what keeps the unity render bit-identical. + // sustain-less envelopes are evaluated in (sourceOffset()). The read stretches the source + // span those envelopes are fitted over, but a 30 ms attack is 30 ms at any rate — + // multiplying by the read rate is exactly that conversion. A fit of exactly 1.0 (Rate 100 %, + // Pitch 0 st) returns the argument untouched, which is what keeps the unity render + // bit-identical. AhdParams rateFittedAhd(const AhdParams& a) const { - if (stretchRate_ == 1.0) return a; + const double fit = stageFitRate(); + if (fit == 1.0) return a; AhdParams out = a; out.attackFrames = - static_cast(static_cast(a.attackFrames) * stretchRate_ + 0.5); + static_cast(static_cast(a.attackFrames) * fit + 0.5); out.decayFrames = - static_cast(static_cast(a.decayFrames) * stretchRate_ + 0.5); + static_cast(static_cast(a.decayFrames) * fit + 0.5); return out; } + // The pitch AHD's span. That envelope counts OUTPUT frames while its Hold fraction is taken + // against the playable SOURCE span, so the span converts by the rate the read head consumes + // source at. Divides by that alone though the Varispeed read rate is really baseRatio_ x + // envFactor: a deep pitch envelope makes it a first-order approximation, not exact. + // + // Shared by note-on and every live re-application, so a live Pitch move re-fits the envelope + // rather than leaving it on the offset the note started at. Only that live factor is + // re-read — pitchSpanBaseRate_ has it divided out — which is what leaves a legato retune's + // documented drift (retune) exactly where it was. + std::int64_t pitchEnvSpanFrames() const { + if (sample_ == nullptr) return 0; + const double postStart = static_cast( + static_cast(sample_->frames.size()) - startFrame_); + const double readRate = + preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_; + const double span = (readRate > 0.0) ? postStart / readRate : postStart; + return static_cast(span + 0.5); + } + // The read head as a fraction of the whole sample — the domain every spline EG is a pure // function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on // its opening value. @@ -679,6 +710,14 @@ private: double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1 + // Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters + // were never sized falls back to the varispeed one, and the two domains differ. Latched at + // note-on beside rateRatio_, which start() resolves from the same predicate. + bool preserveRead_ = false; + // baseRatio_ with the live Pitch factor divided back out, latched at note-on: what + // pitchEnvSpanFrames multiplies the CURRENT offset onto. Exact at Pitch 0 (the factor is + // exactly 1.0), which is what keeps the unity span bit-identical. + double pitchSpanBaseRate_ = 1.0; double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame) double readPos_ = 0.0; // fractional frame index into the sample const SampleData* sample_ = nullptr; diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index 60081e9..a92a85e 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -8,6 +8,7 @@ #include // std::isfinite (wire-value validation) #include // std::move +#include "core/instrument/engine/time_stretch.h" // clampStretchRate (THE rate bound) #include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation) #include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec) @@ -267,9 +268,13 @@ void readLimiterEnable(ByteReader& r, InstrumentParams& p) { // neutral the field already holds — unity rate, no offset — which is exactly what a pre-v16 // blob means and what every instance before them played. // -// The two guards are deliberately DIFFERENT. Rate gets finiteness only, because its range is the -// stretcher's and clampStretchRate is the one authority on it — a second range test here is -// exactly the second clamp that could disagree. The offset gets a real range test, because +// The two guards are deliberately DIFFERENT. Rate is RESOLVED through clampStretchRate rather +// than merely admitted: the stretcher owns its range, so a second copy of the bounds here could +// disagree with it — but a value that only playback clamped would re-serialize out of range and +// leave the stored value disagreeing with the needle, and with the host normalization once the +// instrument reports parameters. Finiteness stays a separate test in front of it, because +// corruption is not an out-of-range value: an infinite rate degrades to the neutral, where a +// merely-too-fast one clamps to the bound. The offset gets a real range test instead, because // nothing downstream bounds it: it reaches 2^(x/12) and then a read increment, and a wild // exponent there is UB on the per-sample path. void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) { @@ -277,7 +282,7 @@ void readRateAndPitchOffset(ByteReader& r, InstrumentParams& p) { const double rate = bitsToDouble(r.u64()); const double offset = bitsToDouble(r.u64()); if (reviveTruncatedTail(r, enteredOk)) return; - if (std::isfinite(rate) && rate > 0.0) p.play.playRate = rate; + if (std::isfinite(rate)) p.play.playRate = engine::clampStretchRate(rate); // The throw is kVelocityPitchRangeSemitones — the SAME +/-24 the pitch envelope's depth and // the velocity->pitch curve speak (play_params.h), reached directly rather than through the // deck's alias of it. diff --git a/src/core/instrument/ui/param_taper.cpp b/src/core/instrument/ui/param_taper.cpp index cec5549..8e61026 100644 --- a/src/core/instrument/ui/param_taper.cpp +++ b/src/core/instrument/ui/param_taper.cpp @@ -36,6 +36,15 @@ double rateSpanOctaves(double minRatio, double maxRatio) { return std::log2(maxRatio / minRatio); } +// The norm the general formula puts unity at, DERIVED from the bounds rather than assumed to be +// centre — it is 0.5 only when minRatio * maxRatio == 1. Both maps below pin their exact-unity +// case to this one expression, so the detent is where the curve already goes and the round trip +// closes bitwise on it. Spelling it 0.5 was correct for the shipped symmetric bounds and would +// have gone non-monotone the moment they were re-measured asymmetric. +double rateUnityNorm(double minRatio, double maxRatio) { + return -std::log2(minRatio) / rateSpanOctaves(minRatio, maxRatio); +} + } // namespace double timeNormFromSeconds(double seconds) { @@ -74,7 +83,8 @@ double rateNormFromRatio(double ratio, double minRatio, double maxRatio) { if (!(maxRatio > minRatio && minRatio > 0.0)) return 0.5; // degenerate bounds: park at unity if (!(ratio > minRatio)) return 0.0; // also catches NaN if (ratio >= maxRatio) return 1.0; - if (ratio == 1.0) return 0.5; // the centre detent is EXACT, so unity persists as unity + // The unity detent is EXACT, so unity persists as unity. + if (ratio == 1.0) return rateUnityNorm(minRatio, maxRatio); return std::log2(ratio / minRatio) / rateSpanOctaves(minRatio, maxRatio); } @@ -82,10 +92,10 @@ double rateRatioFromNorm(double norm, double minRatio, double maxRatio) { if (!(maxRatio > minRatio && minRatio > 0.0)) return 1.0; if (!(norm > 0.0)) return minRatio; // also catches NaN if (norm >= 1.0) return maxRatio; - if (norm == 0.5) return 1.0; + if (norm == rateUnityNorm(minRatio, maxRatio)) return 1.0; // NOT resolved onto a decimal quantum, unlike the two maps above, and the difference is // principled rather than an omission: this control's only default is unity, which the exact - // centre case above already delivers bitwise, so a grid would buy no preimage it does not + // detent case above already delivers bitwise, so a grid would buy no preimage it does not // already have — while costing accuracy at every whole semitone, none of which is a decimal // ratio. Left as the plain exponential, accurate to an ulp. return minRatio * std::exp2(norm * rateSpanOctaves(minRatio, maxRatio)); diff --git a/src/core/instrument/ui/param_taper.h b/src/core/instrument/ui/param_taper.h index a0f7f22..37b5484 100644 --- a/src/core/instrument/ui/param_taper.h +++ b/src/core/instrument/ui/param_taper.h @@ -82,8 +82,11 @@ double depthSemitonesFromNorm(double norm, double maxSemitones); // stretcher, which owns the measurement they came from, and a second copy here could drift from // it. The map is monotone and hits them exactly at norm 0 and 1, so a norm in [0,1] cannot reach // a ratio the engine's own clamp would then move — ONE clamp, at the stretcher, not two. -// Exactly 1.0 at norm 0.5 whenever the bounds bracket it, which is this control's whole -// preimage obligation — see rateRatioFromNorm for why it carries no output quantum. +// Exactly 1.0 at the norm the bounds themselves put unity at — `-log2(minRatio) / span`, which +// is 0.5 only when minRatio * maxRatio == 1 — whenever they bracket it. That detent is this +// control's whole preimage obligation; see rateRatioFromNorm for why it carries no output +// quantum. Pinning it to 0.5 regardless of the bounds is the specific mistake to avoid: it makes +// the map non-monotone the moment the stretcher's measured range stops being symmetric. double rateNormFromRatio(double ratio, double minRatio, double maxRatio); double rateRatioFromNorm(double norm, double minRatio, double maxRatio); diff --git a/tests/test_bake_reset.cpp b/tests/test_bake_reset.cpp index 3612ac9..2d14513 100644 --- a/tests/test_bake_reset.cpp +++ b/tests/test_bake_reset.cpp @@ -50,6 +50,8 @@ InstrumentParams dialed() { p.play.pitchEnv.peakSemitones = -7.0; p.play.pitchEnv.shape.attackSeconds = 0.05; p.play.pitchVelocityCurve = VelocityCurve::linear(); + p.play.playRate = 0.5; + p.play.pitchOffsetSemitones = -7.5; p.play.filter.enabled = true; p.play.filter.modAmount = -0.8; p.play.filter.velAmount = 0.6; @@ -139,6 +141,15 @@ int main() { CHECK(after.play.pitchEnv.peakSemitones == 0.0); CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds); + // --- RESET: Rate and the baseline Pitch offset ----------------------------------- + // Both are processing the bake already printed, so the whitelist leaves them at their + // defaults — the safe direction. A second bake of the result at a still-dialled rate would + // otherwise re-stretch what the first one baked in. + CHECK(after.play.playRate == 1.0); + CHECK(after.play.pitchOffsetSemitones == 0.0); + CHECK(after.play.playRate == freshPlay.playRate); + CHECK(after.play.pitchOffsetSemitones == freshPlay.pitchOffsetSemitones); + // --- RESET: the filter, including its velocity/key-tracking mod ----------------- CHECK(!after.play.filter.enabled); CHECK(after.play.filter.modAmount == 0.0); diff --git a/tests/test_bake_window.cpp b/tests/test_bake_window.cpp index 02e43b9..8e8945c 100644 --- a/tests/test_bake_window.cpp +++ b/tests/test_bake_window.cpp @@ -64,6 +64,19 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { return peak; } +// The last frame of the file that carries any signal at all — where the voice ACTUALLY stopped. +// A measurement of the engine, never a second evaluation of the derivation under test. -1 when +// the render is silent throughout. +std::int64_t lastSoundingFrame(const BakeAudio& audio) { + for (std::int64_t f = audio.frameCount() - 1; f >= 0; --f) { + if (std::fabs(static_cast( + audio.interleaved[static_cast(f * audio.channelCount)])) > kSilence) { + return f; + } + } + return -1; +} + // The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same // sound, a longer window). It leaves the derivation itself untouched, which is what makes the // comparison a measurement of the derived end rather than of a second derivation. @@ -92,6 +105,20 @@ std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) { return plan ? plan->totalFrames : -1; } +// Where the dialed sound stops when NOTHING cuts it: the same sound programmed with a +// deliberately long note and a window to match. This is the reference a derived window is +// judged against, and it has to be measured rather than recomputed — an under-derived Gate +// window truncates by releasing the note EARLY, which leaves no signal outside the file at all +// and so is invisible to "nothing past the end". +std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) { + NoteProgram p = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(100)); + p.length = lengthOfSeconds(heldSeconds); + p.end = EndOffset(offsetFromMs(200.0)); + const std::optional plan = planOf(p); + if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; } + return lastSoundingFrame(renderBake(s, *plan, kUnity)); +} + // The last frame of the file, which is where a hard cut shows up. double lastFrameLevel(const BakeAudio& audio) { return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount()) @@ -337,6 +364,107 @@ int main() { CHECK(derivedFrames(staged) == 12000 + kPad); } + // ============================ RATE AND PITCH ==================================== + + // The one judgement every case below makes: the derived window holds the WHOLE free-running + // sound (the derived render stops exactly where the uncut one does), and it is exactly + // enough rather than merely long. `heldSeconds` only has to exceed the free-running length. + const auto windowHoldsTheWholeNote = [&](const SampleData& s, double heldSeconds, + const char* what) { + const std::int64_t trueEnd = freeRunningEnd(s, heldSeconds); + const std::int64_t derived = derivedFrames(s); + const std::int64_t got = lastSoundingFrame(bakeWith(s, 0.0)); + const bool held = trueEnd >= 0 && derived > trueEnd && got == trueEnd; + CHECK(held); + CHECK(held && derived - trueEnd <= kPad + 8); + if (!(held && derived - trueEnd <= kPad + 8)) { + std::printf(" %s: free-running end %lld, derived render end %lld, window %lld\n", + what, static_cast(trueEnd), static_cast(got), + static_cast(derived)); + } + }; + + // --- Rate scales the window under BOTH engines, in both derived branches -------------- + // Rate IS the read rate: Varispeed folds it into the read increment, Preserve feeds the + // stretcher at it. Either way a 50 % rate doubles how long the source takes to play out and + // a 200 % one halves it, so a window blind to Rate truncates by half at the slow end and + // prints a file of trailing silence at the fast one. + { + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + for (PlayMode mode : {PlayMode::Trigger, PlayMode::Gate}) { + for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) { + SampleData s = dcSample(48000); // 1 s; 2 s at the slowest rate + s.play.playMode = mode; + s.play.pitchEngine = eng; + s.play.adsr.releaseFrames = 0; + s.play.playRate = rate; + char what[64]; + std::snprintf(what, sizeof(what), "eng %d mode %d rate %.2f", + static_cast(eng), static_cast(mode), rate); + windowHoldsTheWholeNote(s, 3.0, what); + } + } + } + } + + // --- A downward Pitch offset stretches the window under VARISPEED only --------------- + // It is a factor of the read increment there and a shifter transpose under Preserve, so the + // window follows it in one engine and not the other. Both must still hold the whole note. + { + SampleData s = dcSample(48000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.pitchOffsetSemitones = -12.0; // half rate for the note's whole lifetime + + CHECK(derivedFrames(s) == 96000 + kPad); + windowHoldsTheWholeNote(s, 3.0, "varispeed pitch -12"); + + SampleData p = s; + p.play.pitchEngine = PitchEngine::Preserve; + CHECK(derivedFrames(p) == 48000 + kPad); // the read rate never moved + windowHoldsTheWholeNote(p, 3.0, "preserve pitch -12"); + + // An UPWARD offset bounds nothing — the read only gets faster — so the window keeps the + // un-stretched span and the balance is trailing silence, on the same asymmetry the + // velocity->pitch term already takes. + SampleData up = s; + up.play.pitchOffsetSemitones = 12.0; + CHECK(derivedFrames(up) == 48000 + kPad); + const BakeAudio wideUp = bakeWith(up, /*extraMs=*/500.0); + CHECK(peakAt(wideUp, 48000 + kPad, wideUp.frameCount()) == 0.0); + } + + // --- Rate and Pitch COMPOUND, because the voice folds them into one multiply ---------- + { + SampleData s = dcSample(48000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.playRate = 0.5; + s.play.pitchOffsetSemitones = -12.0; // together: a quarter-speed read + + CHECK(derivedFrames(s) == 192000 + kPad); + windowHoldsTheWholeNote(s, 5.0, "varispeed rate 0.5 x pitch -12"); + } + + // --- Gate over a sustain loop is Hold's, and Rate does not touch it ------------------- + // The note length there is the user's Hold in wall clock and the release is ticked per + // output frame, so neither term of the stretch applies — the one derived branch that must + // NOT move when Rate does. + { + SampleData s = dcSample(48000); + s.loop = SampleLoop{true, 0, 24000}; + s.play.playMode = PlayMode::Gate; + s.play.adsr.releaseFrames = 4800; + CHECK(bakeWindowNeedsHold(s)); + + const std::int64_t unity = derivedFrames(s); + for (double rate : {0.5, 2.0}) { + SampleData r = s; + r.play.playRate = rate; + CHECK(derivedFrames(r) == unity); + } + } + // ============================== VELOCITY ======================================== // --- The bake renders at the velocity it is handed ---------------------------------- diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 5bc8ae9..adf8fa4 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -10,6 +10,7 @@ #include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec // links no engine, and this adds none) #include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap) +#include "../src/core/instrument/engine/time_stretch.h" // the rate bounds the codec clamps to #include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral) #include @@ -1544,10 +1545,12 @@ static void testRateAndPitchOffsetRoundTripAndV15LiftsToUnity() { CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0); } -// Neither field has a clamp of its own downstream that could rescue a corrupt blob: the rate -// multiplies a read increment (the engine's own clampStretchRate is the one authority on its -// RANGE, so the codec only refuses the unusable) and the offset feeds a 2^(x/12) whose result -// reaches a per-sample cast. Both degrade to their neutral rather than through. +// Corruption degrades to the neutral, and an out-of-RANGE rate resolves through the stretcher's +// own clamp rather than surviving unclamped: playback would clamp it anyway, so a stored value +// that did not would leave the needle — and the host normalization, once the instrument reports +// parameters — disagreeing with what is actually played. The offset has no such downstream clamp +// at all (it feeds a 2^(x/12) that reaches a per-sample cast), so it gets a real range test and +// degrades whole. static void testCorruptRateOrOffsetDegradesToTheNeutral() { const double nan = std::numeric_limits::quiet_NaN(); const struct { double rate; double offset; double wantRate; double wantOffset; } cases[] = { @@ -1556,6 +1559,12 @@ static void testCorruptRateOrOffsetDegradesToTheNeutral() { {0.0, 3.0, 1.0, 3.0}, // a zero rate would stall the read head {-1.0, 3.0, 1.0, 3.0}, // and a negative one would run it backwards {std::numeric_limits::infinity(), 3.0, 1.0, 3.0}, + // Finite but out of the stretcher's range — reachable from a downgrade, not corruption. + // Clamped to the bound the engine would have played, not left to re-serialize. + {10.0, 3.0, instrument::engine::kStretchRateMax, 3.0}, + {0.01, 3.0, instrument::engine::kStretchRateMin, 3.0}, + {instrument::engine::kStretchRateMin, 3.0, instrument::engine::kStretchRateMin, 3.0}, // the bounds themselves + {instrument::engine::kStretchRateMax, 3.0, instrument::engine::kStretchRateMax, 3.0}, // survive untouched {0.75, 1e9, 0.75, 0.0}, // past the +/-24 st throw {0.75, -1e9, 0.75, 0.0}, {0.75, 24.0, 0.75, 24.0}, // the throw itself is IN range diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index ab2a3d4..65eaef6 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -317,6 +317,12 @@ static void testEveryDefaultHasAnExactNormalizedPreimage() { CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction); CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction); CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount); + // The PITCH/RATE pair. Rate's preimage is the taper's unity detent, which sits at true + // centre only because these bounds are reciprocal; Pitch's is the depth taper's exact zero. + CHECK(rateRatioFromNorm(deckParamNorm(DeckParam::kRate, d), kRateMinRatio, kRateMaxRatio) == + d.playRate); + CHECK(depthSemitonesFromNorm(deckParamNorm(DeckParam::kPitch, d), kPitchDepthMaxSemis) == + d.pitchOffsetSemitones); CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) == d.adsr.attackCurve); // Master gain's unity: the case where a hair off is an audible gain error rather than a diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp index 7705d36..a580aa0 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -211,7 +211,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() { PitchEnvParams longer = p; longer.shape.decayFrames = 2000; - b.applyLive(longer); // decay doubled mid-decay + b.applyLive(100000, longer); // decay doubled mid-decay, same span CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame // A depth move is a level step, so it glides rather than jumping: the first frame after @@ -224,7 +224,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() { for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); } PitchEnvParams noDepth = p; noDepth.peakSemitones = 0.0; - c.applyLive(noDepth); // depth to zero mid-decay + c.applyLive(100000, noDepth); // depth to zero mid-decay CHECK(c.tick() == d.tick()); // ...and it does eventually reach the new depth rather than staying put. for (int i = 0; i < 400; ++i) c.tick(); @@ -259,7 +259,7 @@ static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() { for (int i = 0; i < 300; ++i) f.tick(); PitchEnvParams wider = p; wider.shape.holdFraction = 1.0; - f.applyLive(wider); + f.applyLive(1000, wider); CHECK(f.tick() == 12.0); for (int i = 0; i < 1200; ++i) f.tick(); CHECK(f.tick() == 0.0); @@ -307,7 +307,7 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() { PitchEnvParams dialled = stale; dialled.peakSemitones = 12.0; dialled.shape.decayFrames = 1000; - env.snapLive(dialled); + env.snapLive(100000, dialled); CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope for (int i = 0; i < 499; ++i) env.tick(); CHECK(std::fabs(env.tick() - 6.0) < 1e-12); @@ -850,6 +850,94 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() { } } +// --- The live Pitch offset reaches the note's TIME domains, not only its pitch ------------- + +// A block published BEFORE the note starts is the snapLive path, and the snapshot's own copy of +// the offset is deliberately stale there — so this is where a Pitch offset has to be in hand +// already when the note's envelopes are fitted against the read rate. Answers how many output +// frames the voice sounded for, to a 256-frame block. +static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis, + std::size_t capFrames) { + LiveParams block; + LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy + v.pitchOffsetSemitones = offsetSemis; + block.publish(v); + s.live = █ + VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(60, 127); + std::vector out; + std::size_t life = 0; + while (out.size() < capFrames && engine.activeVoiceCount() > 0) { + engine.render(out, 256); + life = out.size(); + } + return life; +} + +// Under Varispeed the Pitch offset is a factor of the read increment, and the staged AHD is +// evaluated at the SOURCE offset that increment advances — so its stage frames are fitted to the +// offset the note will ACTUALLY play at, exactly as they are to Rate. The attack therefore +// completes on the same output frame at every offset. Fitting against the snapshot's stale zero +// instead is what this catches. +static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() { + constexpr std::int64_t kAttack = 2000; + for (double semis : {-12.0, 0.0, 12.0}) { + SampleData s; + s.frames.assign(96000, 1.0f); // DC: the output IS the amp envelope + s.sampleRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; + + LiveParams block; + LiveValues v = foldLive(s.play); + v.pitchOffsetSemitones = semis; + block.publish(v); + s.live = █ + VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); + engine.noteOn(60, 127); + std::vector out; + engine.render(out, 8000); + std::size_t reachedFull = 0; + for (std::size_t i = 0; i < out.size(); ++i) { + if (out[i] > 0.99f) { reachedFull = i; break; } + } + const bool ok = reachedFull > 0 && + std::fabs(static_cast(reachedFull) - + static_cast(kAttack)) < 40.0; + CHECK(ok); + if (!ok) std::printf(" pitch %+.1f st: attack completed at %zu\n", semis, reachedFull); + } +} + +// The pitch envelope's SPAN is a wall-clock duration converted from the same read rate, so it +// follows the published offset too. Read out as the note's LIFETIME: the envelope's depth +// cancels the offset while it holds, so the read runs at unity for the hold and at the offset +// ratio after it — which makes the lifetime a direct readout of where the hold ended. +// 12000 source frames, offset -12 st (read at 0.5): the span is 24000 output frames, its +// half-span hold is 12000 of them at unity, and the source is exhausted exactly there. +// A span fitted to the stale zero offset is 12000, holds for 6000, and the remaining 6000 +// source frames then take 12000 more output frames — 18000 in total. +static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() { + SampleData s; + s.frames.assign(12000, 1.0f); + s.sampleRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.trigAhd = AhdParams{0, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.peakSemitones = 12.0; // cancels the -12 offset while it holds + s.play.pitchEnv.shape.attackFrames = 0; + s.play.pitchEnv.shape.decayFrames = 0; + s.play.pitchEnv.shape.holdFraction = 0.5; + + const std::size_t life = soundingBlocksWithPublishedPitch(s, -12.0, 60000); + CHECK(life > 11000 && life < 13000); + if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life); +} + // --- What stays latched at note-on ------------------------------------------------------- static void testPitchRatioAndVelocityGainStayLatched() { @@ -991,6 +1079,8 @@ int main() { testOneBlockServesTwoIndependentObservers(); testARateChangeSpareTheSoundingNoteAndReachesTheNextOne(); testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines(); + testAPublishedPitchOffsetLeavesTheStagedAttackWallClock(); + testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan(); testPitchRatioAndVelocityGainStayLatched(); testVelocityGainSurvivesAHostilePublishThatReallyLands(); if (g_fail == 0) std::printf("live_delivery tests passed\n"); diff --git a/tests/test_param_taper.cpp b/tests/test_param_taper.cpp index 61b35d3..a4ef4e2 100644 --- a/tests/test_param_taper.cpp +++ b/tests/test_param_taper.cpp @@ -295,6 +295,39 @@ static void testRateDefaultAndEndpointsRoundTripBitwise() { } } +// The exact-unity detent is DERIVED from the bounds, not assumed to sit at centre. The shipped +// bounds are reciprocal so the two agree today, but they are a MEASURED range: re-measure them +// asymmetric and a detent pinned to 0.5 makes the map fold back on itself around centre. Run at +// a deliberately non-reciprocal pair, which is exactly the case the ratio-of-ratios and +// round-trip tests above would still have passed. +static void testRateDetentFollowsAsymmetricBoundsInsteadOfCentre() { + constexpr double kLo = 0.4; + constexpr double kHi = 3.0; // kLo * kHi == 1.2, so unity is NOT at 0.5 + const double unity = rateNormFromRatio(1.0, kLo, kHi); + CHECK(unity > 0.0 && unity < 1.0); + CHECK(std::fabs(unity - 0.5) > 0.01); // the case a 0.5 detent gets wrong + CHECK(rateRatioFromNorm(unity, kLo, kHi) == 1.0); // ...and unity is still EXACT there + + double prev = -1.0; + for (int i = 0; i <= 200000; ++i) { + const double v = rateRatioFromNorm(static_cast(i) / 200000.0, kLo, kHi); + CHECK(v >= prev); + if (v < prev) { std::printf(" asymmetric fold at i=%d\n", i); return; } + prev = v; + } + // That sweep steps OVER the detent rather than onto it, so walk its immediate neighbourhood + // too — a misplaced exact case shows up there and nowhere else. + for (int k = -8; k < 8; ++k) { + const double a = rateRatioFromNorm(unity + static_cast(k) * 1e-9, kLo, kHi); + const double b = rateRatioFromNorm(unity + static_cast(k + 1) * 1e-9, kLo, kHi); + CHECK(b >= a); + if (!(b >= a)) { std::printf(" detent fold at k=%d\n", k); return; } + } + // And the shipped reciprocal bounds still put unity at true knob centre: the general rule + // reproduces the special case rather than replacing it. + CHECK(rateNormFromRatio(1.0, kRateMin, kRateMax) == 0.5); +} + // Degenerate bounds are a caller bug, not a crash: the map collapses to unity. static void testDegenerateRateBoundsCollapseToUnity() { CHECK(rateRatioFromNorm(0.3, 2.0, 0.5) == 1.0); @@ -394,6 +427,7 @@ int main() { testRateIsLinearInSemitonesAcrossTheWholeTravel(); testRateIsMonotone(); testRateDefaultAndEndpointsRoundTripBitwise(); + testRateDetentFollowsAsymmetricBoundsInsteadOfCentre(); testDegenerateRateBoundsCollapseToUnity(); testMillisecondSnap(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 3294b89..adc3efc 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -3251,6 +3251,179 @@ static void testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames() { } } +// Preserve's half of the loop claim, and it is the OPPOSITE of the Varispeed one — written down +// here because the obvious extension of the test above is WRONG. Preserve consumes the loop at +// `rate` source frames per output frame, so the TRAVERSAL scales (the feed-side witness in +// testPreserveStretchLoopsTheSourceSpan measures that directly); what the listener hears does +// not, because holding the source's period while its duration changes is the definition of the +// engine. Measured with a ring long enough to hold the whole loop, so the reading is the design +// property rather than splice cadence — at shorter rings the same fixture measured 3064 and 4130 +// frames at rate 0.5 (windows 1024 and 2048), neither of which is the 8000 a scaling period +// would give either. +static void testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal() { + constexpr std::int64_t kLoopStart = 4000; + constexpr std::int64_t kLoopEnd = 8000; + SampleData base; + base.frames.assign(20000, 0.0f); + for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) { + base.frames[static_cast(i)] = + static_cast(i - kLoopStart) / static_cast(kLoopEnd - kLoopStart); + } + base.rootNote = 60; + base.startFrame = kLoopStart; + base.loop = SampleLoop{true, kLoopStart, kLoopEnd}; + base.play.adsr = flatAdsr(); + base.play.pitchEngine = PitchEngine::Preserve; + + auto sawPeriod = [](const std::vector& v) { + double sum = 0.0; + std::size_t prev = 0, count = 0; + for (std::size_t i = 1; i < v.size(); ++i) { + if (v[i - 1] <= 0.5f && v[i] > 0.5f) { + if (count > 0) sum += static_cast(i - prev); + prev = i; + ++count; + } + } + return count > 1 ? sum / static_cast(count - 1) : 0.0; + }; + + for (double rate : {1.0, 0.5, 2.0}) { + SampleData s = base; + s.play.playRate = rate; + Voice v; + v.presizePreserveShifters(8192); // > the 4000-frame loop + v.start(60, 127, s, /*declickTakeover=*/false, rate); + std::vector out(40000, 0.0f); + for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame(); + const double period = sawPeriod(out); + CHECK(approx(period, 4000.0, 40.0)); + if (!approx(period, 4000.0, 40.0)) std::printf(" rate %.2f period %.1f\n", rate, period); + // And the marks the waveform draws are source-frame FACTS the engine only ever reads. + CHECK(s.loop.start == kLoopStart); + CHECK(s.loop.end == kLoopEnd); + CHECK(s.startFrame == kLoopStart); + } +} + +// The other half of the same rule, which nothing asserted: a drawn contour is a pure function of +// NORMALIZED sample position, so it follows the read head and its wall-clock shape scales by +// 1/rate — under BOTH engines, since both advance that head at the rate. Measured as the output +// frame the contour's own half-way point arrives on, which is what a listener hears move. +static void testADrawnContourScalesWithRateInBothEngines() { + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + double atUnity = 0.0; + for (double rate : {1.0, 0.5, 2.0}) { + SampleData s = dcSample(24000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = eng; + s.play.playRate = rate; + s.play.ampSpline.mode = EnvMode::Spline; + s.play.ampSpline.contour = VelocityCurve::linear(); // 0 -> 1 across the sample + Voice v; + v.presizePreserveShifters(1024); + v.start(60, 127, s, /*declickTakeover=*/false, rate); + double halfway = 0.0; + for (std::size_t i = 0; i < 80000 && v.active(); ++i) { + const double y = static_cast(v.renderFrame()); + if (halfway == 0.0 && y > 0.5) halfway = static_cast(i); + } + CHECK(halfway > 0.0); + if (rate == 1.0) atUnity = halfway; + // 12000 source frames in at unity; twice as many output frames at half rate. + else CHECK(approx(halfway, atUnity / rate, atUnity * 0.02)); + if (rate != 1.0 && !approx(halfway, atUnity / rate, atUnity * 0.02)) { + std::printf(" eng %d rate %.2f: halfway %.0f, wanted %.0f\n", + static_cast(eng), rate, halfway, atUnity / rate); + } + } + } +} + +// Pitch is the same multiply as Rate under Varispeed, so the same rule binds it: a staged stage +// time is OF THE PERFORMANCE and does not scale. The AHD is the case that can go wrong, since it +// is evaluated at the SOURCE offset — which a Pitch offset advances faster or slower. Under +// Preserve the offset never touches the read, so the same attack lands on the same frame there +// for a different reason; asserted in both so the compensation cannot be applied to the wrong +// engine. Key-tracking is deliberately NOT compensated, and the last block pins that too. +static void testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed() { + constexpr std::int64_t kAttack = 2000; + SampleData base = dcSample(48000); + base.play.playMode = PlayMode::Trigger; + base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; + + const auto attackFrame = [](const SampleData& s, int note) { + Voice v; + v.presizePreserveShifters(1024); + v.start(note, 127, s, /*declickTakeover=*/false, s.play.playRate); + for (std::size_t i = 0; i < 200000 && v.active(); ++i) { + if (static_cast(v.renderFrame()) > 0.99) return static_cast(i); + } + return -1.0; + }; + + for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + for (double semis : {-12.0, -5.0, 0.0, 7.0, 12.0}) { + SampleData s = base; + s.play.pitchEngine = eng; + s.play.pitchOffsetSemitones = semis; + const double got = attackFrame(s, 60); + CHECK(approx(got, static_cast(kAttack), 40.0)); + if (!approx(got, static_cast(kAttack), 40.0)) { + std::printf(" eng %d pitch %+.1f st: attack completed at %.0f\n", + static_cast(eng), semis, got); + } + } + } + + // Key-tracking stays UNCOMPENSATED on purpose — it is a shipped sound, and compensating it + // would move every note off the root. An octave up therefore completes the attack in half + // the output frames, which is exactly the behaviour Pitch above does not have. + SampleData vari = base; + vari.play.pitchEngine = PitchEngine::Varispeed; + CHECK(approx(attackFrame(vari, 72), static_cast(kAttack) / 2.0, 40.0)); +} + +// --- The Varispeed null case, baselined so the NEXT track's claim is measured. --- +// Unlike the Preserve hashes above, these were captured from THIS commit rather than witnessed +// against the pre-track one, and that difference is the whole reason the comment says so: the +// pre-track equality is proved structurally instead, and cheaply — at Rate 100 % and Pitch 0 st +// both new factors of recomputeBaseRatio's product are EXACTLY 1.0 (semitoneRatio short-circuits +// at zero; the clamp returns 1.0 for 1.0), and multiplying a double by 1.0 is bit-exact, so the +// read increment is the pre-track engine's own. What these constants add is a witness for the +// track AFTER this one. A change here is a change to what every already-saved project sounds +// like — re-derive the cause before re-baselining. +static void testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline() { + const std::size_t n = 6000; + struct Case { int note; bool stereo; bool loop; std::uint64_t hashL; std::uint64_t hashR; }; + const Case cases[] = { + {60, false, false, 5964955069002935931ull, 0ull}, // on root: unity read + {67, false, false, 134881748704183217ull, 0ull}, // +7 st + {55, false, false, 11914283967735558216ull, 0ull}, // -5 st + {67, true, true, 11674273643338193955ull, 15241091931688620298ull}, // stereo + loop + }; + for (const Case& c : cases) { + SampleData s = stretchProbeSample(4000, c.stereo); + s.play.pitchEngine = PitchEngine::Varispeed; + if (c.loop) { + s.loop.hasLoop = true; + s.loop.start = 1200; + s.loop.end = 3600; + s.loopCrossfadeFrames = 256; + } + std::vector l(n), r(c.stereo ? n : 0); + renderVoice(s, c.note, /*rate=*/1.0, /*window=*/2205, c.stereo, l, r); + const std::uint64_t hl = hashStream(l); + CHECK(hl == c.hashL); + if (hl != c.hashL) std::printf(" varispeed note %d L hash %lluull\n", c.note, hl); + if (c.stereo) { + const std::uint64_t hr = hashStream(r); + CHECK(hr == c.hashR); + if (hr != c.hashR) std::printf(" varispeed note %d R hash %lluull\n", c.note, hr); + } + } +} + // The asymmetry the spec is explicit about: a contour is OF THE SAMPLE and scales with Rate, a // staged envelope is OF THE PERFORMANCE and does not. Trigger's AHD is the case that could go // wrong — it is evaluated at the SOURCE offset, which advances at the rate — so its stage frames @@ -3639,6 +3812,10 @@ int main() { testKeyTrackRateAndPitchOffsetResolveToOneMultiply(); testPreserveRoutesRateToDurationAndTheOffsetToPitch(); testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames(); + testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal(); + testADrawnContourScalesWithRateInBothEngines(); + testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed(); + testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline(); testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes(); testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); testPreserveStretchLoopsTheSourceSpan(); From 35ab59d7f96f889dda555cc38cfca4e32f64d732 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 05:44:04 -0400 Subject: [PATCH 30/56] docs: record Phase Gamma Wave 1's seven landed tracks in COMPLETED, including T6 which never had a plan entry --- docs/COMPLETED.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index e50d580..827e5d5 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -887,3 +887,165 @@ wrong that refusal costs a working capture. Each track's DAW-verification obliga is recorded in `docs/PLAN.md`'s Phase Ψ section; `docs/verify-track-scope-multitrack.md` is a new standalone verification script on this branch, for Ψ-W3-T1's multi-track refusal specifically. No human has observed any of these seven behaviors in a DAW. + +### Γ-W1-T1 — knob-interaction-law + +One consistent interaction and taper law across every variable control in the instrument, +landed before any new control (Rate, Pitch) or VST3 parameter existed so both are authored +into it rather than retrofitted. The taper is extracted into its own pure module, +`core/instrument/ui/param_taper` — the norm↔value maps (ms knobs log-scaled, semitone knobs +log2/centre-expanded) and the modifier vocabulary (`DragModifiers`, `kFineDragScale`, the +four whole-unit Shift snaps), now the single home three consumers read: the knob's needle +(`deck_values`), the AHDSR overlay's schematic axis and its drag inverse +(`envelope_overlay`/`envelope_edit`), and — from a later wave — the VST3 host's +`toPlain`/`toNormalized`. Shift snaps to whole units in the control's displayed category +(ms, semitones, percent, curve-exponent, dB); Ctrl scales the drag by 0.05; Shift+Ctrl +resolves to Shift; a mid-drag modifier press or release re-anchors value and cursor +position so the rate changes without a value jump. `resetDeckParam`'s taper bypass — +writing the default directly rather than round-tripping through `norm → value` — is now +mandatory rather than merely convenient, since the 10 s ceiling is not a power of two the +way the retired 2 s one was. + +**The stage-time ceiling moves 2.0 s → 10.0 s** (`kGateStageMaxSeconds` / +`kEnvTimeMaxSeconds`, moved together so they cannot drift), reversing Γ-F3 on Daniel's +later ruling. The AHDSR overlay's schematic axis was re-derived against it: a stage's slot +width is now `slotPx × taperNorm(seconds)` rather than a linear fraction of the ceiling, so +a node's position within its slot is its knob's needle position and a short attack stays +legible at the raised ceiling instead of collapsing under a pixel. Every default gains an +exact normalized preimage under its own taper — the requirement Γ-W4-T1's +`defaultNormalizedValue` depends on, since a host's reset-to-default has no +`resetDeckParam` bypass to fall back on. The filter's four `*Norm` controls (cutoff, Q, +morph, drive) are untouched — their laws are wire-frozen in payload v9 — and the change is +persistence-neutral throughout: the payload stores raw engine doubles, so a project saved +at the old ceiling reloads with identical stored seconds and identical audio. + +### Γ-W1-T2 — master-bus-audio + +The master bus: a bypassable true-peak limiter, the meter's audio and publication half, and +the plugin's first latency report. New pure modules `core/instrument/engine/limiter` and +`core/instrument/engine/meter_ballistics`. Chain: voice mixer → master gain → limiter → +output bus, with the meter tapped post-limiter. The limiter is a single toggle with no +configurable controls — a baked −0.3 dBTP ceiling, default off, no makeup gain of any kind, +stereo-linked detection so the image never moves. True-peak detection is a 4x-oversampled +sidechain-only detector; the signal path itself is never oversampled. Its gain law is a +sliding minimum of the per-sample target over the lookahead window followed by a moving +average of the same width — every term of that average is a minimum whose own window +contains the sample being gained, so the ceiling holds structurally rather than by a tuned +attack, and the release only ever slows the rise. Switching is a mute, never a blend: the +unlimited signal is emitted at weight 1 or weight 0 and never in between, so the fade +always rides the limited path and the hard edge always lands on the bypassed side. + +**Dynamic reported latency** — `getLatencySamples()` returns 0 with the limiter off and the +lookahead in samples with it on, driving `restartComponent(kLatencyChanged)` on toggle — is +the plugin's first latency reporting of any kind; nothing in `src/` called +`restartComponent` before this track. Per block the processor publishes relaxed-atomic +peak, clip flag, and max gain reduction with no dB conversion or ballistics on the audio +thread; `meter_ballistics` (pure, unit-tested) does the conversion — instantaneous rise, +20 dB/s fall, a 1.5 s peak hold releasing at the same rate, linear-in-dB scale over +−60…+6 dBFS, and a clip latch cleared on request. **Spent the phase's first payload rung**: +`kParamsPayloadVersion` reaches **15**, appending the limiter enable flag as a strict +suffix; a pre-v15 blob lifts to bypassed. + +### Γ-W1-T3 — contour-trace-curves + +Staged envelope segments now draw as the curve their exponent defines, closing the defect +where the mid-segment knot floated off its own trace — the paint path dropped knots and +joined the remaining vertices with straight strokes even though the exponent was already +in scope, while knot *positioning* had honoured it since Θ-W3-T2. A new pure module, +`curve_tessellate`, joins the non-knot vertices along the same curve `envelopes.h`'s +evaluators use — one point per pixel column at `start + (end − start) × curveMap(phi)` — +so the drawn stage and the sound it makes cannot diverge; a neutral exponent or a +zero-level span still emits just the two endpoints, matching the straight stroke drawn +before curves existed. All three envelopes (amp, pitch, filter), both play modes, every +sloped stage, share the one fix. + +### Γ-W1-T4 — editor-floor-and-row-law + +Commits the editor's canvas — the window floor, the width budget it derives from, and +which row each deck group belongs to — so every later UI track in the phase is drawn and +judged at the final window size rather than a size a subsequent wave changes under it. +`kEditorMinWidth` moves 980 → 1190, staying in `sample_bands.h`; `kEditorMinHeight` stays +680 (Γ-F1). `kEditorCeilingWidth` (1280, the hard cap the floor may not exceed) relocates +from `knob_deck.h` into `sample_bands.h` alongside the min-width/min-height pair, since it +is a window fact rather than a deck one; the deck's own width-budget constants — the row +block (1020) and MASTER's reserved width (142) — stay in `knob_deck.h`. The floor is +derived rather than asserted as a literal: `1020 + 12 (gap) + 142 + 2×8 (pad) = 1190`, +leaving 90 px of headroom against the 1280 px ceiling. Row membership becomes a property of +the group id — `DeckRow { Sound, Contour, Spanning }` plus `deckRowFor(DeckGroupId)`, an +exhaustive switch (Sound = PITCH/RATE, FILTER, VELOCITY, VOICE; Contour = PITCH ENV, FILTER +ENV, AMP ENVELOPE; Spanning = MASTER) so a future group left unclassified is a compile +error. Nothing consumes the predicate yet — the two-row arrangement inside this canvas is +Γ-W3-T1's — so at the new floor the deck still packs by the unchanged greedy whole-group +wrap, landing on two rows rather than three; the composition is knowingly interim (PITCH +ENV sits with the sound decks, both rows left-packed with dead space) until Γ-W3-T1 lands +the reflow. No drawing code, descriptor, parameter, or audio changed. + +### Γ-W1-T5 — preserve-time-stretch + +A real pitch-preserving time-stretcher for Preserve mode, landed a wave ahead of the Rate +control that will drive it so Rate ships onto a finished engine instead of a disposable +stand-in — moved up from a later wave on Daniel's ruling that it was the phase's longest +pole and had no UI dependency. The write rate (duration) and the tap rate (pitch) are +independent, which is the whole mechanism: a new header-only pure module, `time_stretch`, +holds `StretchCursor` — the per-output-frame source-feed schedule, a fractional cursor +carrying its rate debt, loop-wrapped — plus the measured rate bounds and their clamp, +alongside `pitch_shift`'s existing shift-ratio control. Rate 1.0 is exactly one source +frame per output frame with no residue, which is what makes the unity-ratio Preserve read +bit-identical to the pre-stretch engine — the regression floor the track is gated on, since +nothing publishes a non-unity ratio until Γ-W2-T1's Rate knob exists. No new third-party +dependency, no allocation or lock in `process()`, no dispatch on the per-sample path, +buffers sized at voice allocation or reload on `pitch_shift`'s existing pre-warm precedent. + +### Γ-W1-T6 — exhaustive-switch gate on pure libraries + +Merged as `ee839cf`. **This track has no entry in `docs/PLAN.md`** — the plan's Γ-W1 wave +header states so directly ("the phase's track numbering runs to T7; T6 landed within this +wave but has no entry in this document") — so this record is reconstructed from +`cmake/reasampler_targets.cmake` and the enforcement comment at its confirmed call site, +`src/core/instrument/ui/deck_groups.cpp`, rather than from a spec section. + +`reasampler_pure_library()` (`cmake/reasampler_targets.cmake`) now promotes a default-less +`switch` missing an enumerator to a compile error on every pure library: `/we4062` on MSVC, +`-Werror=switch` on GCC/Clang. Neither fired before this track — MSVC's C4062 is off at the +repo's `/W1` default, and GCC/Clang's `-Wswitch` warns without `-Werror`, which this repo +sets nowhere else. The gate is deliberately **not** C4061, which fires even on a switch +that already has a `default:` clause — that would light up every defensive switch in the +tree instead of catching only the deliberately default-less ones, such as +`isLiveDeckParam` and `deck_groups.cpp`'s live-param routing, where a newly added +enumerator must be a compile error rather than a silent fall-through. Later Γ-W1 work +(T4's `deckRowFor`) relies on this gate being in place. + +### Γ-W1-T7 — psola-preserve + +Preserve's splices become pitch-synchronous. A new pure module, +`core/instrument/engine/period_detect` (two-pass YIN — a decimated cumulative-mean- +normalized difference picks the period, then the full-rate difference function refines it +to a fraction of a frame), estimates the source's fundamental period once at load; +`pitch_shift`'s splice jump becomes the multiple of that period nearest the fixed window +that still fits the ring's jump bound, so an aligned landing point sits at the centre of +the existing correlation search instead of possibly not existing inside it at all. +Detection runs off the audio thread by link graph — `sampler_core` does not link +`period_detect`, so no translation unit on the render path can name `detectPeriod` — and an +unknown period (noise, polyphony, percussion, a drifting source) restores the fixed-window +geometry byte for byte. A period is derived from the audio at load, so it is cache rather +than state: no `ComponentState` field, no payload rung. Merged as `7a162a5`. + +**Status, corrected against what `docs/PLAN.md` currently states — the closure is now +complete, not partial.** The **geometry** failure mode (no phase-aligned landing existing +inside the search window at all, for low material such as a 30 Hz tone) was closed and +asserted at the original merge and stands unchanged. The **cadence** failure mode — +splices recurring faster than the output period at rate/shift combinations where +`window/|rate − shift|` is short — was left "not closed, re-characterized rather than +fixed, no-regression asserted rather than improvement claimed" at that point, pending +remediation. Three remediation commits have since landed, including `f66b9bd` (correcting +the agreement denominator to count only probes that carried signal) and `83e7cca` — the +commit that closes the track, current tip of this merge — and a re-review confirmed the +earlier findings closed. The re-review found the original cadence analysis itself stale: +PSOLA made the splice interval follow `spliceJump()` rather than the fixed `window` the +module header still described, and the closeout supplied the missing measurement in the +collapse band. **Measured at P = 1470 frames (30 Hz at 44.1 kHz), rate 2.0 / −24 st, +against a known-answer metric floor of 55.86 % and a period-off control arm: fixed-window +excess 18.52 %, pitch-synchronous excess 0.00 %** — PSOLA eliminates that corner rather +than regressing it, with every splice at n = 1 landing exactly one source period away. +**These figures are a one-machine, Debug-build measurement against the named control arm, +not a general performance claim.** From a7c3c7a828973caaf20a0c5f779c9660973a1213 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 05:18:53 -0400 Subject: [PATCH 31/56] Loop: an explicit enable, four named marks with grabbable caps, and the crossfade painted where it is actually heard hasLoop becomes user-owned with the gestures as shortcuts onto it; no format change. START uses overlay/trace, not accent/primary, which is the waveform's own fill. --- docs/TODO.md | 8 +- src/core/instrument/CLAUDE.md | 4 +- src/core/instrument/ui/CMakeLists.txt | 5 + src/core/instrument/ui/loop_marks.cpp | 55 ++++ src/core/instrument/ui/loop_marks.h | 60 +++++ src/core/instrument/ui/sample_chrome.cpp | 15 +- src/core/instrument/ui/sample_chrome.h | 8 +- src/core/instrument/ui/spline_edit.h | 18 +- src/core/instrument/ui/waveform_view.cpp | 60 +++++ src/core/instrument/ui/waveform_view.h | 68 ++++- src/shell/instrument/CLAUDE.md | 2 +- src/shell/instrument/CMakeLists.txt | 2 +- src/shell/instrument/editor_controls.cpp | 42 +++ src/shell/instrument/editor_input.cpp | 1 + src/shell/instrument/editor_input_chrome.cpp | 23 +- .../instrument/editor_input_waveform.cpp | 73 ++++-- src/shell/instrument/editor_paint.cpp | 11 +- src/shell/instrument/editor_paint_chrome.cpp | 32 ++- .../instrument/editor_paint_waveform.cpp | 243 ++++++++++++++---- src/shell/instrument/editor_session.cpp | 69 ++--- src/shell/instrument/reasampler_editor.h | 58 +++-- tests/test_component_state_io.cpp | 22 ++ tests/test_loop_marks.cpp | 208 +++++++++++++++ tests/test_sample_chrome.cpp | 35 ++- tests/test_spline_edit.cpp | 43 +++- tests/test_waveform_view.cpp | 216 +++++++++++++++- 26 files changed, 1190 insertions(+), 191 deletions(-) create mode 100644 src/core/instrument/ui/loop_marks.cpp create mode 100644 src/core/instrument/ui/loop_marks.h create mode 100644 tests/test_loop_marks.cpp diff --git a/docs/TODO.md b/docs/TODO.md index 27a93a6..a36407c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -172,13 +172,7 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r **The wart.** A zero-attack `AttackEnd` vertex is drawn at the same pixel as `Origin` (the envelope's non-draggable start anchor), which for an AHD envelope sits at the start marker's frame. Because a node's nominal pick-box area is smaller than the marker's full-height grab-column area, and `resolveWaveformClaim`'s rule is "smallest area among hit candidates wins," the draggable `AttackEnd` node still claims the click over the start marker when the two coincide — and, at a loop starting there, over the crossfade tab. Folding the staged pass into the shared arbitration slot did not change this specific outcome, since the rule that decides node-vs-marker priority is unchanged from what the contour-node fix established. `Origin` itself is excluded from `nodeAtPoint`'s candidate set entirely (never draggable, never a hit), so the common case — attack > 0, no coincidence — is unaffected. -**Intended fix.** Not yet proposed. Bringing the staged pass into the shared arbitration slot was the natural first step and has landed; closing the remaining collision needs either a per-affordance priority rule for genuinely coincident precision targets, or accepting the current smallest-area outcome as intended and documenting it as such rather than as an open wart. - -**The constraint the fix MUST handle.** Whatever rule changes must not regress the contour-node/marker and tab/marker arbitration W5 already fixed, and must not make `Origin` draggable or otherwise touch `isDraggable`'s AHD/AHDSR shape rules. - -**Priority / risk.** Low. Pre-existing, not introduced by W5; the common case (nonzero attack) is unaffected, and the collision requires both a zero-attack stage and a coincident marker/tab to be reachable at all. - -**Done looks like.** A zero-attack `AttackEnd` node coincident with the start marker (or, on a loop starting there, the crossfade tab) no longer silently claims the click ahead of the marker/tab — either by an explicit priority rule or by a recorded decision that the current behavior is intended. +**RESOLVED — Γ-W2-T2 (`loop-crossfade-ux`), incidentally.** Giving every mark the cap-grip the crossfade already had is what closed it: the start marker now carries an 11x10 cap in the overlay's top strip, whose nominal area (110) is smaller than the node's fixed pick box (169), so the cap wins the coincident pixel and the marker is reachable again. No priority rule was added and `resolveWaveformClaim` is byte-for-byte unchanged — every cap is one `markerHandleRect`, so the cap slot's nominal area did not move and the `cap < node < column` ordering still holds. Below the cap strip the node keeps the click, which is correct: that is where the node is actually drawn for any non-degenerate envelope. Pinned by `testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip` (`tests/test_spline_edit.cpp`). `Origin` was not touched and `isDraggable`'s shape rules are unchanged. ## Active-bank indicator placement (B4 polish) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index a852599..81b683e 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -326,11 +326,13 @@ anything for a trigger shape. - `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own. - `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack. -- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset. +- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset. - `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means. - `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins. - `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other). - **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one. + - **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes. +- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition. - `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing. - `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search. - `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 9c428ed..f19dbef 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -29,6 +29,11 @@ reasampler_pure_library(waveform_view # waveform_view does not re-export. reasampler_test(waveform_view LINK waveform_view sample_bands) +# The loop enable's state machine. Links loop_span for the park bounds — the span the user is +# offered and the span the engine accepts stay one definition. +reasampler_pure_library(loop_marks SOURCES loop_marks.cpp LINK PUBLIC loop_span) +reasampler_test(loop_marks LINK loop_marks) + reasampler_pure_library(browser_scroll SOURCES browser_scroll.cpp LINK PUBLIC capture_browser sample_chrome) diff --git a/src/core/instrument/ui/loop_marks.cpp b/src/core/instrument/ui/loop_marks.cpp new file mode 100644 index 0000000..82cc51a --- /dev/null +++ b/src/core/instrument/ui/loop_marks.cpp @@ -0,0 +1,55 @@ +// loop_marks.cpp — see loop_marks.h. Pure value folds; no host types. + +#include "core/instrument/ui/loop_marks.h" + +#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds + +namespace reasampler::instrument::ui { + +namespace { + +// The engine's own acceptance test, restated over the editor's two integers: resolveLoop +// refuses an inverted, empty or out-of-range span rather than repairing it, so a span it would +// refuse is one the handles must be re-parked out of. +bool spanUsable(std::int64_t loopStart, std::int64_t loopEnd, std::int64_t frameCount) { + return loopStart >= 0 && loopEnd > loopStart && loopEnd <= frameCount; +} + +} // namespace + +LoopMarks resolveLoopMarks(const StoredLoop& stored, std::int64_t frameCount) { + LoopMarks m; + if (stored.override_) { + m.hasLoop = stored.override_->hasLoop; + m.loopStart = stored.override_->start; + m.loopEnd = stored.override_->end; + } else if (stored.intrinsic && stored.intrinsic->hasLoop) { + m.hasLoop = true; + m.loopStart = stored.intrinsic->start; + m.loopEnd = stored.intrinsic->end; + } + if (stored.startPoint) m.start = *stored.startPoint; + m.crossfade = stored.crossfade > 0 ? stored.crossfade : 0; + + if (!spanUsable(m.loopStart, m.loopEnd, frameCount)) { + m.hasLoop = false; + m.parked = true; + const engine::loop::LoopBounds d = engine::loop::defaultLoopBounds(frameCount); + m.loopStart = d.start; + m.loopEnd = d.end; + } + return m; +} + +LoopWrite applyLoopMarks(const LoopMarks& m) { + LoopWrite w; + const bool spanAlive = m.loopEnd > m.loopStart; + w.loop.hasLoop = m.hasLoop && spanAlive; + w.loop.start = m.loopStart; + w.loop.end = m.loopEnd; + w.crossfade = (spanAlive && m.crossfade > 0) ? m.crossfade : 0; + w.start = m.start; + return w; +} + +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/loop_marks.h b/src/core/instrument/ui/loop_marks.h new file mode 100644 index 0000000..773cbde --- /dev/null +++ b/src/core/instrument/ui/loop_marks.h @@ -0,0 +1,60 @@ +#pragma once +// loop_marks.h — the loop enable's state machine: what the waveform band SHOWS for a stored +// loop, and what a marker edit WRITES back. `SampleLoop::hasLoop` is the single authority; +// collapse-to-off and drag-to-create are shortcuts onto it, not a second state. Pure values +// only — the shell supplies the stored side and applies the result. + +#include +#include + +#include "core/instrument/engine/play_params.h" // SampleLoop + +namespace reasampler::instrument::ui { + +using ::reasampler::SampleLoop; + +// What the four marks are showing. `parked` separates the two OFF states: a pair sitting on +// defaultLoopBounds because nothing was ever set (there is a loop to "set") from a real span +// the user switched off (there is not). +struct LoopMarks { + std::int64_t start = 0; + std::int64_t loopStart = 0; + std::int64_t loopEnd = 0; + std::int64_t crossfade = 0; // pre-seam fade, SOURCE frames + bool hasLoop = false; + bool parked = false; +}; + +// The stored side: the parameter set's loop override, the bank's loop intrinsic (consulted only +// when there is no override — the override always supersedes it), the crossfade length, and the +// start point. +struct StoredLoop { + std::optional override_; + std::optional intrinsic; + std::int64_t crossfade = 0; + std::optional startPoint; +}; + +// Reads the stored loop into what the band shows. A span the engine could not honour — +// collapsed, inverted, or outside [0, frameCount] — re-parks on defaultLoopBounds so two +// coincident handles can never become ungrabbable; a VALID span keeps its own positions +// whatever the enable says, which is what makes the enable a toggle rather than a delete +// button. +LoopMarks resolveLoopMarks(const StoredLoop& stored, std::int64_t frameCount); + +// The write side, the inverse of resolveLoopMarks. +struct LoopWrite { + SampleLoop loop; + std::int64_t crossfade = 0; + std::int64_t start = 0; +}; + +// Folds collapse-to-off in: a span dragged onto itself is the OFF gesture, recorded as such so +// the next resolve re-offers the default handles. The crossfade goes with the SPAN, not with +// the enable — zeroed only when the span is destroyed. That preserves the original zeroing +// rule's reason rather than overruling it: a stale length could silently re-apply against a +// span that no longer exists, but a retained span retains its clamp bound too, so nothing is +// stale. +LoopWrite applyLoopMarks(const LoopMarks& m); + +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/sample_chrome.cpp b/src/core/instrument/ui/sample_chrome.cpp index 21ac836..20c2004 100644 --- a/src/core/instrument/ui/sample_chrome.cpp +++ b/src/core/instrument/ui/sample_chrome.cpp @@ -19,6 +19,10 @@ constexpr int kStripBandHeight = 30; constexpr int kRunGap = 6; // between adjacent items of the toolbar run constexpr int kChanSegW = 52; constexpr int kChanSegH = 18; +// The loop enable's segments carry a two-word label, so they are wider than Mono|Stereo's. +// If the title slot ever fails to hold its text at the editor's floor, THIS narrows — the +// floor does not move. +constexpr int kLoopSegW = 58; constexpr int kVelCellW = 56; constexpr int kHoldCellW = 56; // the bake Hold cell, same grammar as the velocity cell constexpr int kVelLabelH = 16; @@ -43,7 +47,8 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) { const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; }; const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); }; - // The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview, bake, hold. + // The fixed run, right to left: Browse, Mono|Stereo, Loop Off|On, velocity cell, preview, + // bake, hold. // The velocity-curve button that used to sit here now lives in the deck's VELOCITY group. const int navH = std::min(kRunButtonH, row.height); const int navTop = topFor(navH); @@ -58,9 +63,15 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) { r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x, chanTop + kChanSegH); + const int loopRight = leftOf(r.chanMono.x, kRunGap); + r.loopOn = Rect::ltrb(leftOf(loopRight, kLoopSegW), chanTop, loopRight, + chanTop + kChanSegH); + r.loopOff = Rect::ltrb(leftOf(r.loopOn.x, kLoopSegW), chanTop, r.loopOn.x, + chanTop + kChanSegH); + const int cellH = std::min(row.height, knobSize + kVelLabelH); const int cellTop = topFor(cellH); - const int cellRight = leftOf(r.chanMono.x, kRunGap); + const int cellRight = leftOf(r.loopOff.x, kRunGap); r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH); const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2; r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize, diff --git a/src/core/instrument/ui/sample_chrome.h b/src/core/instrument/ui/sample_chrome.h index bf66034..28e684c 100644 --- a/src/core/instrument/ui/sample_chrome.h +++ b/src/core/instrument/ui/sample_chrome.h @@ -2,7 +2,8 @@ // sample_chrome.h — interior geometry of the Sample face's CHROME band: the toolbar row // (title + the whole right-anchored control run + Browse) over the strip row, which the // piano strip has to itself. Reads the band rect the allocator hands it (sample_bands) and -// never allocates vertical space of its own. +// never allocates vertical space of its own. The run is right-anchored and the title takes +// the remainder, so a member added to the run costs the title, never the window's floor. #include "core/instrument/ui/editor_geometry.h" // Rect @@ -32,6 +33,11 @@ struct ChromeRects { Rect velCell; // preview-velocity knob cell (knob + label band) Rect velKnob; Rect velLabel; + // The sustain loop's enable. Immediately left of the channel toggle because it is the same + // class of control — a playback mode of the loaded capture — and because the run is + // right-anchored, so the title slot absorbs its width and the editor's floor does not move. + Rect loopOff; + Rect loopOn; Rect chanMono; Rect chanStereo; Rect navBrowse; diff --git a/src/core/instrument/ui/spline_edit.h b/src/core/instrument/ui/spline_edit.h index 246fa9b..35b522c 100644 --- a/src/core/instrument/ui/spline_edit.h +++ b/src/core/instrument/ui/spline_edit.h @@ -65,14 +65,16 @@ struct WaveformClaim { enum class WaveformClaimant { kNone, kNode, kTab, kMarker }; // The overlay's cross-affordance arbitration: a contour node (or, mutually exclusively, a -// staged envelope's drag node — both feed the same `node` slot), the loop crossfade tab, and a -// marker's full-height column can all claim the same pixel. Hit gates a candidate out -// entirely; among the ones that hit, the SMALLEST nominal area wins — the marker column is the -// odd one out (its target is the whole overlay height), so it only wins where nothing narrower -// also claims the click. Ties go to whichever is checked first: node, then tab, then marker — -// no live geometry produces a tie except tab-vs-marker, which the tab correctly wins (see -// editor_input_waveform.cpp's mouseDownWaveform for the live constants). A control-click has no -// tab/marker meaning (they answer plain grabs only), so it resolves to the node whenever the +// staged envelope's drag node — both feed the same `node` slot), a mark's CAP, and a mark's +// full-height column can all claim the same pixel. Hit gates a candidate out entirely; among +// the ones that hit, the SMALLEST nominal area wins — the column is the odd one out (its target +// is the whole overlay height), so it only wins where nothing narrower also claims the click. +// Ties go to whichever is checked first: node, then tab, then marker — no live geometry +// produces a tie except tab-vs-marker, which the tab correctly wins (see +// editor_input_waveform.cpp's mouseDownWaveform for the live constants). Every mark's cap is +// one markerHandleRect, so the `tab` slot carries ONE nominal area however many marks feed it; +// which mark it resolves to is waveform_view's capAtPoint, not this. A control-click has no +// cap/column meaning (they answer plain grabs only), so it resolves to the node whenever the // node is in the running, regardless of area. WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab, const WaveformClaim& marker, SplineGesture gesture); diff --git a/src/core/instrument/ui/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp index b3347fa..209eda2 100644 --- a/src/core/instrument/ui/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -79,6 +79,66 @@ Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int return Rect{left, r.y, right - left, std::min(kMarkerHandleHeight, r.height)}; } +int capAtPoint(const OverlayArea& area, std::int64_t frameCount, const WaveMarks& marks, int x, + int y) { + for (int i = kWaveMarkCount - 1; i >= 0; --i) { + if (!marks.present[i]) continue; + if (contains(markerHandleRect(area, frameCount, marks.frame[i]), x, y)) return i; + } + return -1; +} + +bool markLabelLeftOfLine(WaveMark m) { + return m == WaveMark::kLoopEnd || m == WaveMark::kCrossfade; +} + +Rect markLabelRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame, + bool leftOfLine, int textWidth) { + const Rect& r = area.rect; + if (r.empty() || textWidth <= 0 || textWidth > r.width) return Rect{}; + const int h = std::min(kMarkLabelHeight, r.height - kMarkerHandleHeight); + if (h <= 0) return Rect{}; + const int mx = frameToX(area, frameCount, frame); + int left = leftOfLine ? mx - kMarkLabelGap - textWidth : mx + kMarkLabelGap; + left = std::max(r.x, std::min(left, r.right() - textWidth)); + return Rect{left, r.y + kMarkerHandleHeight, textWidth, h}; +} + +WaveMarkLabels layoutMarkLabels(const OverlayArea& area, std::int64_t frameCount, + const WaveMarks& marks, const int* textWidth, int promoted) { + WaveMarkLabels out; + if (textWidth == nullptr) return out; + // The promoted mark first, then draw order. Every label shares one row, so "would overlap + // one already placed" reduces to a horizontal span test. + int order[kWaveMarkCount + 1] = {promoted, 0, 1, 2, 3}; + for (int slot = 0; slot < kWaveMarkCount + 1; ++slot) { + const int i = order[slot]; + if (i < 0 || i >= kWaveMarkCount) continue; + if (!marks.present[i] || !out.box[i].empty()) continue; + const Rect box = markLabelRect(area, frameCount, marks.frame[i], + markLabelLeftOfLine(static_cast(i)), + textWidth[i]); + if (box.empty()) continue; + bool clash = false; + for (int j = 0; j < kWaveMarkCount && !clash; ++j) { + clash = !out.box[j].empty() && box.x < out.box[j].right() && + out.box[j].x < box.right(); + } + if (!clash) out.box[i] = box; + } + return out; +} + +int crossfadeWedgeHeight(int x0, int x1, int x) { + const int w = x1 - x0; + if (w <= 0 || x < x0 || x >= x1) return 0; + if (w == 1) return kCrossfadeWedgePx; + // Normalized over w - 1 so the LAST drawn column lands exactly on the peak, the same + // reason crossfadeWeight normalizes over crossfade - 1 (loop_span.h). + const int d = x - x0; + return (kCrossfadeWedgePx * d + (w - 1) / 2) / (w - 1); +} + int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames, int count, int x, int y) { if (count <= 0 || frames == nullptr) return -1; diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index e59002d..2d2f310 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -65,17 +65,75 @@ int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t fram // area.x yields 0; right of area.right() yields frameCount. std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x); -// A marker's grab HANDLE: a tab riding the top of the overlay, centred on the marker's x and -// clipped into the area. Distinct from the full-height grab COLUMN markerAtPoint answers, so -// two markers that share a frame stay independently grabbable — the handle owns the top -// strip, the column owns everything below it. Without that split, first-in-draw-order wins -// every coincident tie and the loser can never be dragged apart again. +// A marker's grab HANDLE — THE CAP, in the mark grammar's vocabulary: a tab riding the top of +// the overlay, centred on the marker's x and clipped into the area. Distinct from the +// full-height grab COLUMN markerAtPoint answers, so two markers that share a frame stay +// independently grabbable — the handle owns the top strip, the column owns everything below +// it. Without that split, first-in-draw-order wins every coincident tie and the loser can never +// be dragged apart again. Every mark's cap is this ONE rect shape; only the glyph drawn inside +// it differs, which is what lets the claim arbitration see a single nominal cap area. inline constexpr int kMarkerHandleHeight = 10; // Same half-width as the column's own grab band on purpose: the handle is that same grab // tolerance, just confined to the top strip, not an independent tuning. inline constexpr int kMarkerHandleHalfWidth = kMarkerGrabWidth; Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame); +// --- The overlay's four marks ------------------------------------------------------------- + +// The marks the overlay carries, in DRAW and COLUMN-hit order. The shell's WaveMarker aliases +// this, so the drag router and the geometry below cannot disagree about an ordinal. +enum class WaveMark { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCrossfade = 3, kCount = 4 }; +inline constexpr int kWaveMarkCount = static_cast(WaveMark::kCount); + +// Which marks are on screen and where. A mark that is not `present` is excluded from every +// answer below — with no loop set there is no crossfade mark to reach. +struct WaveMarks { + std::int64_t frame[kWaveMarkCount] = {0, 0, 0, 0}; + bool present[kWaveMarkCount] = {false, false, false, false}; +}; + +// Which mark's cap a point lands on, or -1. Caps resolve in the REVERSE of the column order +// markerAtPoint uses — crossfade, end, loop start, start — and that reversal is the whole +// separability argument: whichever mark of a coincident PAIR loses the cap still answers its +// own full-height column, and the crossfade, the one mark with no column at all, is first so +// nothing can shadow it. A coincident TRIPLE still strands its middle mark, exactly as the +// pre-cap tab/column split did. +int capAtPoint(const OverlayArea& area, std::int64_t frameCount, const WaveMarks& marks, + int x, int y); + +// Labels sit in the row directly below the caps, beside the mark's line: START and LOOP to the +// right of it, END and XFADE to the left, so a label never crosses into the span it bounds. +inline constexpr int kMarkLabelHeight = 10; +inline constexpr int kMarkLabelGap = 3; // between the mark's line and its text + +bool markLabelLeftOfLine(WaveMark m); + +// The box `textWidth` px of label occupies for a mark at `frame`. Nudged inside the area rather +// than clipped — half a label reads as a different mark's — and empty when it cannot fit. +Rect markLabelRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame, + bool leftOfLine, int textWidth); + +// The placed labels; an empty box is a label that is not drawn. `textWidth` is per mark, in the +// caller's own font (this module measures no text). `promoted` — a WaveMark ordinal, or -1 — +// is placed FIRST and so can never be the one suppressed: it is the mark the user is grabbing +// or hovering, i.e. the one they are asking about. +struct WaveMarkLabels { + Rect box[kWaveMarkCount]; +}; +WaveMarkLabels layoutMarkLabels(const OverlayArea& area, std::int64_t frameCount, + const WaveMarks& marks, const int* textWidth, int promoted); + +// The crossfade region's peak edge-wedge height. The region draws as a wedge at the overlay's +// top and bottom edges and NEVER as a second fill: it now sits INSIDE the loop span, where a +// translucent fill would stack on the loop fill over an already-accepted under-floor contrast +// pair (see editor_paint_waveform.cpp). +inline constexpr int kCrossfadeWedgePx = 10; + +// Wedge height at pixel column `x` over [x0, x1): zero at x0, kCrossfadeWedgePx at x1 - 1. The +// audible region and the ingredient ghost are the SAME ramp over the two spans the fade mixes, +// so one function draws both. +int crossfadeWedgeHeight(int x0, int x1, int x); + // Which marker (index into the caller's parallel `frames` array, in draw order) a grab at // (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of // its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie. diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 1802e8c..677c636 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -9,7 +9,7 @@ two small identity/helper headers this directory owns outright The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`, `sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`, -`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, +`sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`, `param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, `deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index 55180b1..d03f76d 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -86,7 +86,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sampler_core sample_map component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip sample_bands sample_chrome - waveform_view bank_sync browser_scroll param_slider tooltip + waveform_view loop_marks bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage limiter meter_ballistics bake_hold diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 6d623db..72ee77b 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -142,6 +142,48 @@ instrument::ui::DeckEnableState ReaSamplerEditor::deckEnableState() const { play.filterSpline.mode == EnvMode::Spline}; } +bool ReaSamplerEditor::loopControlsLive() const { + return effectivePlayMode(params_.play) == PlayMode::Gate; +} + +instrument::ui::WaveMarks ReaSamplerEditor::waveMarksFor(const SetupMarkers& m) const { + using instrument::ui::WaveMark; + instrument::ui::WaveMarks w; + w.frame[static_cast(WaveMark::kStart)] = m.start; + w.frame[static_cast(WaveMark::kLoopStart)] = m.loopStart; + w.frame[static_cast(WaveMark::kLoopEnd)] = m.loopEnd; + // The crossfade grows LEFT from the seam it closes, which is where it is audible. + w.frame[static_cast(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade; + // The crossfade mark belongs to an ACTIVE loop: with the enable off there is no seam for it + // to sit on, and no length to drag. + w.present[static_cast(WaveMark::kStart)] = true; + w.present[static_cast(WaveMark::kLoopStart)] = true; + w.present[static_cast(WaveMark::kLoopEnd)] = true; + w.present[static_cast(WaveMark::kCrossfade)] = m.hasLoop; + return w; +} + +instrument::ui::WaveMarks ReaSamplerEditor::grabbableMarks(const SetupMarkers& m) const { + using instrument::ui::WaveMark; + instrument::ui::WaveMarks w = waveMarksFor(m); + if (!loopControlsLive()) { + w.present[static_cast(WaveMark::kLoopStart)] = false; + w.present[static_cast(WaveMark::kLoopEnd)] = false; + w.present[static_cast(WaveMark::kCrossfade)] = false; + } + return w; +} + +void ReaSamplerEditor::setLoopEnabled(bool on) { + const auto frames = static_cast(monoPcmFor(selectedId_).size()); + if (frames <= 0) return; + SetupMarkers m = pickedMarkers(frames); + if (m.hasLoop == on) return; // a no-op commit would buy a re-decode for nothing + m.hasLoop = on; + applyMarkers(m); + commitAndReload(); +} + void ReaSamplerEditor::applyDeckKnob(int id, double norm) { if (!processor_) return; norm = clamp01(norm); diff --git a/src/shell/instrument/editor_input.cpp b/src/shell/instrument/editor_input.cpp index 56d7b09..e498961 100644 --- a/src/shell/instrument/editor_input.cpp +++ b/src/shell/instrument/editor_input.cpp @@ -170,6 +170,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) { const FaceLayout fl = faceLayout(w, hgt); h = hoverChrome(fl, x, y); if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverDeck(fl, x, y); + if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverWaveform(fl, x, y); } if (h != hover_) { diff --git a/src/shell/instrument/editor_input_chrome.cpp b/src/shell/instrument/editor_input_chrome.cpp index fc9fa1b..8da7c46 100644 --- a/src/shell/instrument/editor_input_chrome.cpp +++ b/src/shell/instrument/editor_input_chrome.cpp @@ -1,6 +1,6 @@ // editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger, -// the preview-velocity knob grab, the channel toggle, and the piano strip's root grab plus -// its live drag. Windows-only. +// the preview-velocity knob grab, the loop enable, the channel toggle, and the piano strip's +// root grab plus its live drag. Windows-only. #include "shell/instrument/reasampler_editor.h" @@ -73,6 +73,21 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) { invalidate(); return true; } + // The loop enable. Inert (not hidden) outside Gate: that refusal comes from the engine and + // no click can talk it out of it — unlike the user's own off, which the marks themselves + // still offer to reverse. + if (loopControlsLive()) { + if (contains(cr.loopOff, x, y)) { + setLoopEnabled(false); + invalidate(); + return true; + } + if (contains(cr.loopOn, x, y)) { + setLoopEnabled(true); + invalidate(); + return true; + } + } if (contains(cr.chanMono, x, y)) { channelMode_ = ChannelMode::Mono; processor_->setChannelMode(ChannelMode::Mono); @@ -146,6 +161,10 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1}; if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1}; if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1}; + if (loopControlsLive()) { + if (contains(cr.loopOff, x, y)) return {HoverKind::kLoopOff, -1}; + if (contains(cr.loopOn, x, y)) return {HoverKind::kLoopOn, -1}; + } if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1}; if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1}; if (!cr.rootStrip.empty()) { diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 01d2673..c3a4c3a 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -55,16 +55,20 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } const SetupMarkers m = pickedMarkers(frames); + const WaveMarks grabbable = grabbableMarks(m); const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; // Three affordances can claim the same pixel: a node (the staged envelope's or the drawn - // contour's — a small fixed pick box either way), the crossfade tab (a small clipped - // top-strip tab), and a marker's full-height grab column (waveform_view.h's tab-vs-column - // split already keeps the tab apart from ITS OWN column; this is the cross-affordance case - // on top of that). resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures - // each claimant's own NOMINAL target area and lets the smallest hit win, since a fixed check - // order shadows whichever one loses the tie — this seam regressed twice from exactly that - // fix. Never add here (kAdd is only tried once nothing else has claimed the click, below). + // contour's — a small fixed pick box either way), a mark's CAP (a small clipped top-strip + // tab), and a mark's full-height grab column (waveform_view.h's cap-vs-column split already + // keeps a cap apart from ITS OWN column; this is the cross-affordance case on top of that). + // resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures each claimant's + // own NOMINAL target area and lets the smallest hit win, since a fixed check order shadows + // whichever one loses the tie — this seam regressed twice from exactly that fix. Giving + // every mark a cap changed WHICH mark the cap slot resolves to, not the slot's nominal area + // (every cap is one markerHandleRect) and not the ordering cap < node < column, so the + // arbitration itself is unchanged. Never add here (kAdd is only tried once nothing else has + // claimed the click, below). WaveformClaim node; if (envNodeHit.hit) { constexpr std::int64_t side = 2 * kNodeGrabRadius + 1; @@ -81,18 +85,22 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } } - const Rect tabRect = - m.hasLoop ? markerHandleRect(overlay, frames, m.loopStart - m.crossfade) : Rect{}; - const WaveformClaim tab = (m.hasLoop && contains(tabRect, x, y)) - ? WaveformClaim{true, static_cast(tabRect.width) * - tabRect.height} - : WaveformClaim{}; + const int capHit = capAtPoint(overlay, frames, grabbable, x, y); + const WaveformClaim tab = + (capHit >= 0) + ? WaveformClaim{true, static_cast(2 * kMarkerHandleHalfWidth + 1) * + kMarkerHandleHeight} + : WaveformClaim{}; // Nominal, not actual: markerAtPoint clips the column at the overlay edges (a marker at // frame 0 has 6 usable columns, not 11) and the node's fixed side clips too at a pick-box // corner. Both overestimate in the direction that already produces the intended winner, so // the arbitration runs on NOMINAL area, not the measured hit-testable pixel count. - const int markerHit = markerAtPoint(overlay, frames, markerFrames, 3, x, y); + const int markerHit = + (grabbable.present[static_cast(WaveMark::kLoopStart)] + ? markerAtPoint(overlay, frames, markerFrames, 3, x, y) + // In Trigger only START answers a column, and it is index 0 of the same array. + : markerAtPoint(overlay, frames, markerFrames, 1, x, y)); const WaveformClaim marker = (markerHit >= 0) ? WaveformClaim{true, static_cast(2 * kMarkerGrabWidth + 1) * @@ -114,7 +122,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { } return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false); case WaveformClaimant::kTab: - beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x); + beginMarkerDrag(static_cast(capHit), m, frames, x); return true; case WaveformClaimant::kMarker: beginMarkerDrag(static_cast(markerHit), m, frames, x); @@ -179,6 +187,28 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in return true; } +ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x, + int y) { + // Caps only: the cap is the grip, so it is the one thing on the overlay a resting pointer + // can be "on". A hovered mark promotes its own label past the suppression rule. + // + // Rejected on the band's own rect FIRST, before anything expensive: this runs on every + // WM_MOUSEMOVE, and with no loop override set pickedMarkers costs a bridge read plus a bank + // parse. Every cap lives in the top kMarkerHandleHeight of the band, so that strip is the + // only place the answer can be anything but a miss. + const Rect& band = fl.bands.waveform; + if (band.empty() || x < band.x || x >= band.right() || y < band.y || + y >= band.y + kMarkerHandleHeight) { + return {}; + } + const auto frames = static_cast(monoPcmFor(selectedId_).size()); + if (frames <= 0) return {}; + const OverlayArea overlay = waveformOverlayArea(band); + const int cap = capAtPoint(overlay, frames, grabbableMarks(pickedMarkers(frames)), x, y); + if (cap < 0) return {}; + return {HoverKind::kWaveMark, cap}; +} + void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m, std::int64_t frames, int x) { drag_ = DragKind::kWaveMarker; @@ -243,7 +273,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { const int idx = static_cast(waveMarker_); const std::int64_t startVals[4] = {dragStartMarkers_.start, dragStartMarkers_.loopStart, dragStartMarkers_.loopEnd, - dragStartMarkers_.loopStart - + dragStartMarkers_.loopEnd - dragStartMarkers_.crossfade}; std::int64_t newFrame = resolveDragFrame(overlay, frames, startVals[idx], dx); @@ -251,13 +281,16 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { // frames — no host types, no file I/O. The crossfade handle is exempt: it sets a fade // LENGTH, and the whole point of the fade is that its edges need no zero crossing. const std::vector& pcm = monoPcmFor(selectedId_); - if (!pcm.empty() && waveMarker_ != WaveMarker::kLoopXfade) { + if (!pcm.empty() && waveMarker_ != WaveMarker::kCrossfade) { newFrame = nearestZeroCrossing(pcm.data(), static_cast(pcm.size()), newFrame); } // Build the edited marker set from the snapshot, moving only the grabbed marker, then - // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop. + // clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a LOOP marker turns the + // enable on — a grab implies intent to loop, and it is what teaches the chrome toggle by + // demonstration. Dragging START does not: it is live in both modes and says nothing about + // the loop. SetupMarkers m = dragStartMarkers_; if (waveMarker_ == WaveMarker::kStart) { m.start = newFrame; @@ -267,8 +300,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { } else if (waveMarker_ == WaveMarker::kLoopEnd) { m.loopEnd = (std::max)(newFrame, m.loopStart); m.hasLoop = true; - } else { // kLoopXfade — the handle sits at loopStart - crossfade, so left lengthens it - m.crossfade = (std::max)(std::int64_t{0}, m.loopStart - newFrame); + } else { // kCrossfade — the handle sits at loopEnd - crossfade, so left still lengthens it + m.crossfade = (std::max)(std::int64_t{0}, m.loopEnd - newFrame); } if (m.start < 0) m.start = 0; if (m.start > frames - 1) m.start = frames - 1; diff --git a/src/shell/instrument/editor_paint.cpp b/src/shell/instrument/editor_paint.cpp index 2595326..f864215 100644 --- a/src/shell/instrument/editor_paint.cpp +++ b/src/shell/instrument/editor_paint.cpp @@ -55,7 +55,14 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { const FaceLayout fl = faceLayout(w, h); const bool empty = selectedId_.empty(); - paintChrome(bmp, fl, empty); + // Resolved ONCE per paint and handed to both bands that show it. The chrome enable and the + // waveform marks are two views of the same `hasLoop`, so they must not resolve it + // separately — and with no loop override set the resolve costs a bridge read plus a bank + // parse, which is not a cost to pay twice a frame. + SetupMarkers marks; + if (!empty) marks = pickedMarkers(static_cast(monoPcmFor(selectedId_).size())); + + paintChrome(bmp, fl, empty, marks); // Nothing loaded: the lower bands carry the "pick a capture" prompt pointing at Browse // (which the chrome lit above), and there is nothing to deck. @@ -66,7 +73,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { return; } - paintWaveform(bmp, fl.bands.waveform); + paintWaveform(bmp, fl.bands.waveform, marks); paintDeck(bmp, fl); // The curve popup: a centered sheet over the whole face, drawn last. diff --git a/src/shell/instrument/editor_paint_chrome.cpp b/src/shell/instrument/editor_paint_chrome.cpp index 0abb6ba..b5270e9 100644 --- a/src/shell/instrument/editor_paint_chrome.cpp +++ b/src/shell/instrument/editor_paint_chrome.cpp @@ -1,7 +1,7 @@ // editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title + -// live readout, then the control run — preview, preview-velocity knob, Mono|Stereo, Browse) -// over the strip row, which the piano strip has to itself. Windows-only; all rects come from -// the pure sample_chrome interior and the pure keyboard_strip geometry. +// live readout, then the control run — preview, preview-velocity knob, Loop Off|On, +// Mono|Stereo, Browse) over the strip row, which the piano strip has to itself. Windows-only; +// all rects come from the pure sample_chrome interior and the pure keyboard_strip geometry. #include "shell/instrument/reasampler_editor.h" @@ -94,7 +94,8 @@ void drawRootKey(LICE_IBitmap* bmp, const Rect& area, const StripLayout& sl, int } // namespace -void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty) { +void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty, + const SetupMarkers& marks) { const ChromeRects& cr = fl.chrome; fillSurface(bmp, toKitBox(cr.toolbar), Role::BgPanel, InteractionState::Rest); @@ -211,6 +212,29 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool } } + // Loop Off | On. A two-segment toggle in the same primitive as Mono|Stereo because it is + // the same class of control: a playback mode of the loaded capture. Outside Gate both + // segments draw Disabled and neither accepts a click — the state is preserved, not cleared, + // so the return to Gate restores it. + { + const bool live = loopControlsLive(); + const bool on = marks.hasLoop; + const auto segState = [&](bool active, HoverKind hk) { + if (!live) return InteractionState::Disabled; + if (active) return InteractionState::Active; + return isHovered(hk, -1) ? InteractionState::Hover : InteractionState::Rest; + }; + const InteractionState offState = segState(!on, HoverKind::kLoopOff); + const InteractionState onState = segState(on, HoverKind::kLoopOn); + fillSurface(bmp, toKitBox(cr.loopOff), Role::BgCell, offState); + fillSurface(bmp, toKitBox(cr.loopOn), Role::BgCell, onState); + const Role dim = live ? Role::TextPrimary : Role::TextDim; + kitTextCentered(bmp, cr.loopOff, "Loop Off", kToolbarFont, + (live && !on) ? Role::BgBase : dim); + kitTextCentered(bmp, cr.loopOn, "Loop On", kToolbarFont, + (live && on) ? Role::BgBase : dim); + } + // Mono | Stereo output-mode toggle. { const bool isStereo = (channelMode_ == ChannelMode::Stereo); diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 9e62054..c398fb7 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -1,5 +1,6 @@ -// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop -// span + start/loop markers, and the amp-envelope overlay. Windows-only. +// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop span, +// the four marks (line + shaped cap + label) with the crossfade's wedge and ghost, and the +// envelope overlay. Windows-only. All cap/label geometry is the pure waveform_view module's. // // Overlay contract: see waveform_view.h's WaveformSurface. @@ -26,16 +27,41 @@ using namespace reasampler::instrument::ui; // lanes + waveform geometry using audio::computeEnvelope; namespace { -// Marker roles — semantic, drawn through the kit's palette: start AND loop start/end both -// = teal (secondary). Markers are 2px bars and a translucent span fill, not the 1px trace, so -// they live with 1.92:1 against the waveform; the trace, which cannot, has its own role. -// Do not collapse the two back onto one role — they overlap in this rect. The trace crossing -// the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1 against a 3:1 floor), and no -// trace value fixes it — see the two-neighbour rule in core/ui/CLAUDE.md. If it is ever -// resolved, the FILL is what changes; do not nudge a color to chase it. -constexpr Role kRoleStartMarker = Role::AccentSecondary; +// Marker roles — semantic, drawn through the kit's palette. The loop family is teal +// (secondary); markers are 2px bars and a translucent span fill, not the 1px trace, so they live +// with 1.92:1 against the waveform. Do not collapse the two back onto one role — they overlap in +// this rect. The trace crossing the loop-span fill is a KNOWN, ACCEPTED under-floor pair (2.25:1 +// against a 3:1 floor), and no trace value fixes it — see the two-neighbour rule in +// core/ui/CLAUDE.md. If it is ever resolved, the FILL is what changes; do not nudge a color to +// chase it. +// +// START is deliberately NOT accent/primary, which is what the design called for: accent/primary +// IS the waveform's own fill, so a primary START mark would be 1:1 against the material it marks +// — worse than the teal it replaced, not better. overlay/trace is the one role that clears 3:1 +// against BOTH the lime and bg/base (core/ui/CLAUDE.md's two-neighbour ceiling sits exactly on +// it), so it is the only ink that can carry a distinct always-in-effect mark here. It reads +// apart from the envelope trace by shape: a straight full-height column under a solid triangle +// cap, never a curve. +constexpr Role kRoleStartMarker = Role::OverlayTrace; constexpr Role kRoleLoopMarker = Role::AccentSecondary; +// Mark weights. A Disabled mark (loop off, or Trigger) keeps its position and its cap so the +// information survives the state; the crossfade is a SOFT boundary and rides below the loop +// pair's weight at rest. +constexpr float kMarkAlpha = 1.0f; +constexpr float kMarkAlphaXfade = 0.7f; +constexpr float kMarkAlphaDisabled = 0.4f; + +// The dashed crossfade line: a 3 px stroke every 6 px down the band. +constexpr int kDashOn = 3; +constexpr int kDashPeriod = 6; + +// The ingredient ghost's weight relative to the audible wedge, and the hairline it draws at +// rest. It fills in only while the crossfade handle is hovered or dragged — the relationship is +// revealed when the user asks about it, not permanently. +constexpr float kGhostAlpha = 0.5f; +constexpr int kGhostHairlinePx = 1; + // Envelope-handle half-extents. Grabbed grows and hollows out; kNodeGrabRadius (envelope_edit) // is the PICK radius and is unrelated — a handle may draw larger than it without widening any // hit region. @@ -46,9 +72,91 @@ constexpr int kEnvHandleRingPx = 2; // Both envelope traces — staged and drawn — are one grammar and one weight. Two pixels is what // reads as a trace rather than a hairline over the waveform behind it. constexpr float kEnvTracePx = 2.0f; + +const char* markLabel(WaveMark m) { + switch (m) { + case WaveMark::kStart: return "START"; + case WaveMark::kLoopStart: return "LOOP"; + case WaveMark::kLoopEnd: return "END"; + case WaveMark::kCrossfade: return "XFADE"; + case WaveMark::kCount: break; + } + return ""; +} + +// Font::Micro is proportional, so this is a generous per-character estimate: the label box may +// end up wider than the glyphs, never narrower — an under-estimate would let the suppression +// rule place two boxes that visibly collide. +constexpr int kMicroCharPx = 6; +int markLabelWidth(WaveMark m) { + int n = 0; + for (const char* s = markLabel(m); *s; ++s) ++n; + return n * kMicroCharPx; +} + +// One mark's cap glyph, drawn inside the cap rect the hit-test uses. The four shapes ARE the +// marks' identities — a label may be suppressed, a cap never is. +void drawMarkCap(LICE_IBitmap* bmp, WaveMark which, const Rect& cap, int mx, LICE_pixel ink, + float alpha) { + if (cap.empty()) return; + const int top = cap.y; + const int bot = cap.bottom(); + const int arm = kMarkerHandleHalfWidth; // the cap's own half-width, so glyph == grip + switch (which) { + case WaveMark::kStart: + // A play flag: it points into the material that will play. + LICE_FillTriangle(bmp, mx - 1, top, mx - 1, bot, mx - 1 + arm + 2, (top + bot) / 2, + ink, alpha, 0); + break; + case WaveMark::kLoopStart: + // '[' — opens right, into the span. + LICE_FillRect(bmp, mx - 1, top, 2, cap.height, ink, alpha, 0); + LICE_FillRect(bmp, mx - 1, top, arm + 1, 2, ink, alpha, 0); + LICE_FillRect(bmp, mx - 1, bot - 2, arm + 1, 2, ink, alpha, 0); + break; + case WaveMark::kLoopEnd: + // ']' — opens left, into the span. The opposed pair reads as an enclosure. + LICE_FillRect(bmp, mx - 1, top, 2, cap.height, ink, alpha, 0); + LICE_FillRect(bmp, mx - arm, top, arm + 1, 2, ink, alpha, 0); + LICE_FillRect(bmp, mx - arm, bot - 2, arm + 1, 2, ink, alpha, 0); + break; + case WaveMark::kCrossfade: + // A ramp whose hypotenuse rises toward the seam — the fade's own shape. + LICE_FillTriangle(bmp, mx - 1, bot - 1, mx - 1 + arm, bot - 1, mx - 1 + arm, top, + ink, alpha, 0); + break; + case WaveMark::kCount: + break; + } +} + +// The crossfade region over [f0, f1) as a top-and-bottom edge wedge. NEVER a fill: the audible +// region sits INSIDE the loop span, and a translucent fill there would stack on the loop fill, +// making the already-accepted 2.25:1 trace pair worse. `filled` false draws the resting ghost — +// a hairline dashed outline of the same wedge. +void drawCrossfadeWedge(LICE_IBitmap* bmp, const OverlayArea& overlay, std::int64_t frames, + std::int64_t f0, std::int64_t f1, LICE_pixel ink, float alpha, + bool filled) { + const Rect& r = overlay.rect; + const int x0 = frameToX(overlay, frames, f0); + const int x1 = frameToX(overlay, frames, f1); + if (x1 <= x0 || r.empty()) return; + for (int x = x0; x < x1; ++x) { + const int h = crossfadeWedgeHeight(x0, x1, x); + if (h <= 0) continue; + if (filled) { + LICE_FillRect(bmp, x, r.y, 1, h, ink, alpha, 0); + LICE_FillRect(bmp, x, r.bottom() - h, 1, h, ink, alpha, 0); + } else if ((x - x0) % kDashPeriod < kDashOn) { + LICE_FillRect(bmp, x, r.y + h - kGhostHairlinePx, 1, kGhostHairlinePx, ink, alpha, 0); + LICE_FillRect(bmp, x, r.bottom() - h, 1, kGhostHairlinePx, ink, alpha, 0); + } + } +} } // namespace -void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { +void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band, + const SetupMarkers& m) { fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest); if (band.empty()) return; @@ -94,47 +202,92 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { // stereo view reads one loop region rather than two. const OverlayArea& overlay = surface.overlay; const Rect& overlayRect = overlay.rect; - const SetupMarkers m = pickedMarkers(frames); - if (m.hasLoop && m.loopEnd > m.loopStart) { - const int lx = frameToX(overlay, frames, m.loopStart); - const int rx = frameToX(overlay, frames, m.loopEnd); - if (rx > lx) { - LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height, - toLice(roleColor(kRoleLoopMarker)), - static_cast(kLoopSpanFillAlpha), 0); - } + const bool loopLive = loopControlsLive(); + const bool loopOn = m.hasLoop && loopLive; + const WaveMarks marks = waveMarksFor(m); + const LICE_pixel loopInk = toLice(roleColor(kRoleLoopMarker)); + + const int lx = frameToX(overlay, frames, m.loopStart); + const int rx = frameToX(overlay, frames, m.loopEnd); + if (loopOn && rx > lx) { + LICE_FillRect(bmp, lx, overlayRect.y, rx - lx, overlayRect.height, loopInk, + static_cast(kLoopSpanFillAlpha), 0); } - // The crossfade region, at half the loop span's weight so the two read as nested rather - // than as a second loop. Drawn before the marker bars so the bars stay on top. - if (m.hasLoop && m.crossfade > 0) { - const int fx = frameToX(overlay, frames, m.loopStart - m.crossfade); - const int lx = frameToX(overlay, frames, m.loopStart); - if (lx > fx) { - LICE_FillRect(bmp, fx, overlayRect.y, lx - fx, overlayRect.height, - toLice(roleColor(kRoleLoopMarker)), - static_cast(kLoopSpanFillAlpha) * 0.5f, 0); - } + + // The crossfade, in the two places it exists: the AUDIBLE region, over the frames the fade + // actually runs on, and its INGREDIENT — the material one loop length earlier that is being + // mixed in — as a ghost. Drawing only the ingredient (which is what shipped before) put the + // one grab affordance on the wrong side of the loop from the sound it controls. + const int xfadeIdx = static_cast(WaveMark::kCrossfade); + const bool xfadeHot = + (drag_ == DragKind::kWaveMarker && waveMarker_ == WaveMark::kCrossfade) || + isHovered(HoverKind::kWaveMark, xfadeIdx); + if (loopOn && m.crossfade > 0) { + drawCrossfadeWedge(bmp, overlay, frames, m.loopEnd - m.crossfade, m.loopEnd, loopInk, + kMarkAlpha, /*filled=*/true); + // The clamp is crossfade <= min(loopStart, loopLength), and each half is now visible: + // the ghost's left edge reaches frame 0 exactly at the loopStart bound, and the audible + // wedge's left edge reaches the LOOP mark exactly at the loopLength bound. The user + // sees why the fade stopped growing instead of hitting an invisible wall. + drawCrossfadeWedge(bmp, overlay, frames, m.loopStart - m.crossfade, m.loopStart, loopInk, + kGhostAlpha, /*filled=*/xfadeHot); } - const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; - for (int i = 0; i < 3; ++i) { - const int mx = frameToX(overlay, frames, markerFrames[i]); - const bool loopMarker = (i != 0); - const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; - LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height, - toLice(roleColor(markerRoles[i])), alpha, 0); + + // The state caption, centred in the span: the two OFF states say different things because + // they mean different things, and Trigger's refusal names its own reason. + const char* caption = nullptr; + if (!loopLive) caption = "LOOP - GATE ONLY"; + else if (!m.hasLoop) caption = m.parked ? "DRAG TO SET LOOP" : "LOOP OFF"; + if (caption != nullptr && rx > lx) { + kitTextCentered(bmp, Rect::ltrb(lx, overlayRect.y, rx, overlayRect.bottom()), caption, + Font::Micro, Role::TextDim); } - // The crossfade's grab tab. Only offered with a loop set, matching the hit-test, and it - // is the whole affordance for a zero-length fade — nothing else marks where it sits. - if (m.hasLoop) { - const Rect tab = markerHandleRect(overlay, frames, m.loopStart - m.crossfade); - if (!tab.empty()) { - LICE_FillRect(bmp, tab.x, tab.y, tab.width, tab.height, - toLice(roleColor(kRoleLoopMarker)), 1.0f, 0); + + // Labels beneath the trace and the handles in z-order; the promoted one is re-drawn ON TOP + // after the overlay, so you always see what you grabbed. + const int promoted = + (drag_ == DragKind::kWaveMarker) + ? static_cast(waveMarker_) + : (hover_.kind == HoverKind::kWaveMark ? hover_.index : -1); + int labelW[kWaveMarkCount]; + for (int i = 0; i < kWaveMarkCount; ++i) labelW[i] = markLabelWidth(static_cast(i)); + const WaveMarkLabels labels = layoutMarkLabels(overlay, frames, marks, labelW, promoted); + for (int i = 0; i < kWaveMarkCount; ++i) { + if (i == promoted || labels.box[i].empty()) continue; + kitTextCentered(bmp, labels.box[i], markLabel(static_cast(i)), Font::Micro, + Role::TextDim); + } + + // Line + shaped cap per mark, one grammar. A mark whose gesture is refused draws Disabled + // rather than hidden — the position is information the user put there. + for (int i = 0; i < kWaveMarkCount; ++i) { + if (!marks.present[i]) continue; + const WaveMark which = static_cast(i); + const bool isStart = (which == WaveMark::kStart); + const bool dim = !isStart && !loopOn; + const LICE_pixel ink = isStart ? toLice(roleColor(kRoleStartMarker)) : loopInk; + const float alpha = dim ? kMarkAlphaDisabled + : (which == WaveMark::kCrossfade ? kMarkAlphaXfade : kMarkAlpha); + const int mx = frameToX(overlay, frames, marks.frame[i]); + if (which == WaveMark::kCrossfade) { + // Dashed: a soft boundary, not a hard one. + for (int y = overlayRect.y; y < overlayRect.bottom(); y += kDashPeriod) { + const int h = (std::min)(kDashOn, overlayRect.bottom() - y); + LICE_FillRect(bmp, mx - 1, y, 2, h, ink, alpha, 0); + } + } else { + LICE_FillRect(bmp, mx - 1, overlayRect.y, 2, overlayRect.height, ink, alpha, 0); } + drawMarkCap(bmp, which, markerHandleRect(overlay, frames, marks.frame[i]), mx, ink, + alpha); } paintEnvelopeOverlay(bmp, overlay, frames); + + if (promoted >= 0 && promoted < kWaveMarkCount && !labels.box[promoted].empty()) { + kitTextCentered(bmp, labels.box[promoted], markLabel(static_cast(promoted)), + Font::Micro, Role::TextPrimary); + } } void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) { diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 7c51cad..29179b2 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -18,7 +18,6 @@ #include "core/util/file_bytes.h" // shared whole-file loader #include "ext_keys.h" #include "core/instrument/bake/bake_plan.h" // bakeWindowNeedsHold (the Hold predicate) -#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span) #include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter) #include "shell/instrument/instrument_bake.h" // the deferred bake the sync tick runs #include "shell/instrument/reaper_bridge.h" @@ -34,8 +33,6 @@ using capture::WavLayout; using capture::extractFloatFrames; using capture::parseWavLayout; using capture::resolveBankFile; -using instrument::engine::loop::LoopBounds; -using instrument::engine::loop::defaultLoopBounds; using instrument::ui::nameMatchesQuery; using ui::ThumbnailKey; using ui::thumbnailKeyString; @@ -218,15 +215,16 @@ void ReaSamplerEditor::loadSelection(const std::string& id) { } ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { - SetupMarkers m; - // Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's - // override win (the instrument's performance choice). Read the loop intrinsic from the - // live bank blob (the same path selectSample uses); when that is not readable (extension - // absent / not yet parsed) the instance-owned ref carries the same intrinsics. Skipped - // entirely once an override is already set — it would just be overwritten below, and the - // bridge read + JSON parse it costs is real (mouseDownWaveform's arbitration calls this on - // every waveform click, not just marker grabs, to know whether a tab or marker candidate - // hits at all). + instrument::ui::StoredLoop stored; + stored.override_ = params_.loopOverride; + stored.crossfade = params_.loopCrossfadeFrames; + stored.startPoint = params_.startPoint; + // Read the loop intrinsic from the live bank blob (the same path selectSample uses); when + // that is not readable (extension absent / not yet parsed) the instance-owned ref carries + // the same intrinsics. Skipped entirely once an override is already set — the resolve would + // discard it, and the bridge read + JSON parse it costs is real (mouseDownWaveform's + // arbitration calls this on every waveform click, not just marker grabs, to know whether a + // cap or column candidate hits at all). if (processor_ && !params_.loopOverride) { std::optional sel; auto banksJson = @@ -236,31 +234,9 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram const SampleRefs refs = processor_->sampleRefs(); if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r; } - if (sel && sel->loop.hasLoop) { - m.hasLoop = true; - m.loopStart = sel->loop.start; - m.loopEnd = sel->loop.end; - } + if (sel) stored.intrinsic = sel->loop; } - // The parameter set's override (loop + start) supersedes the intrinsic. - if (params_.loopOverride) { - m.hasLoop = params_.loopOverride->hasLoop; - m.loopStart = params_.loopOverride->start; - m.loopEnd = params_.loopOverride->end; - } - if (params_.startPoint) m.start = *params_.startPoint; - m.crossfade = params_.loopCrossfadeFrames; - // A collapsed or inverted span is the OFF state (the engine refuses it either way), so - // park the handles on the shared default rather than leaving them stacked on each other - // where neither could be grabbed apart again. The markers are still drawn at 'no loop' - // weight — drag one to CREATE a loop. - if (!m.hasLoop || m.loopEnd <= m.loopStart) { - m.hasLoop = false; - const LoopBounds d = defaultLoopBounds(frames); - m.loopStart = d.start; - m.loopEnd = d.end; - } - return m; + return instrument::ui::resolveLoopMarks(stored, frames); } bool ReaSamplerEditor::HoldNeedKey::operator==(const HoldNeedKey& o) const { @@ -303,20 +279,13 @@ bool ReaSamplerEditor::resolveBakeHoldNeeded() { } void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) { - // Write the edited markers into the parameter set as the loop/start override. The bank - // intrinsic is never written (read-only bank consumer). - SampleLoop loop; - // Collapsing the span onto itself is the OFF gesture — record it as such so the next - // pickedMarkers re-offers the default handles instead of two coincident ones. - loop.hasLoop = m.hasLoop && m.loopEnd > m.loopStart; - loop.start = m.loopStart; - loop.end = m.loopEnd; - params_.loopOverride = loop; - // OFF parks the crossfade at 0 too — loadSelection's own clear (a fresh capture has no - // loop to fade) is the same rule; leaving a stale length here would silently re-apply it - // (clamped) the next time a loop is dragged back in. - params_.loopCrossfadeFrames = loop.hasLoop ? m.crossfade : 0; - params_.startPoint = m.start; + // Write the edited markers into the parameter set as the loop/start override; the fold + // itself is the pure loop_marks module's. The bank intrinsic is never written (read-only + // bank consumer). + const instrument::ui::LoopWrite w = instrument::ui::applyLoopMarks(m); + params_.loopOverride = w.loop; + params_.loopCrossfadeFrames = w.crossfade; + params_.startPoint = w.start; } int ReaSamplerEditor::effectiveRoot() const { diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index df050eb..a9cee85 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -19,7 +19,9 @@ #include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit) #include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam) #include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band) +#include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine) #include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator) +#include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks) #include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar #include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior) #include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) @@ -104,12 +106,13 @@ private: // [0, DeckParam::kCount) is what keeps liveCommitFor answering "not a live control". static constexpr int kBakeHoldKnobId = -3; - // The waveform markers on the waveform band: start-point + the sustain loop's two ends, - // in draw + hit order, then the crossfade handle. The crossfade is NOT part of the - // full-height column hit-test — it answers only in its top-strip handle (waveform_view's + // The waveform band's four marks, in draw + hit order. The crossfade is NOT part of the + // full-height column hit-test — it answers only in its cap (waveform_view's // markerHandleRect), because at a zero fade it sits exactly on the loop start. - enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kLoopXfade = 3, - kCount = 4 }; + using WaveMarker = instrument::ui::WaveMark; + + // What those four marks are showing — see pickedMarkers. + using SetupMarkers = instrument::ui::LoopMarks; // The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index` // disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when @@ -125,6 +128,9 @@ private: kBrowseCancel, // the Browse modal "Cancel" button kChanMono, // the mono channel-mode segment kChanStereo, // the stereo channel-mode segment + kLoopOff, // the loop enable's Off segment + kLoopOn, // the loop enable's On segment + kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label kPreview, // the preview-trigger button kBake, // the resample-bake trigger kControl, // a knob-deck element (index = control id) @@ -162,12 +168,14 @@ private: // --- Band painters (one TU each, mirroring the input side) --- // Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob, // channel toggle). - void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty); + void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty, + const SetupMarkers& marks); // The hovered piano key's note-name chip. Drawn after every band — it overhangs the // chrome into whatever is below it. void paintChromeTooltip(LICE_IBitmap* bmp, const FaceLayout& fl, int w, int h); - // Waveform: the channel lane(s), the loop/start markers, and the envelope overlay. - void paintWaveform(LICE_IBitmap* bmp, const Rect& band); + // Waveform: the channel lane(s), the four marks, and the envelope overlay. `marks` is + // resolved once per paint by paintSample — see there. + void paintWaveform(LICE_IBitmap* bmp, const Rect& band, const SetupMarkers& marks); // Decks: the group fence + caption + compact caption toggles + radial knobs with // label<->value swap on hover/drag. void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl); @@ -243,6 +251,9 @@ private: // mouse-down branches but are read-only. Windows-only. void resolveHover(int x, int y); HoverTarget hoverChrome(const FaceLayout& fl, int x, int y) const; + // Marks only — the cap strip. Everything else in the band already reports its own state + // through the drag, so nothing else on the overlay needs a hover. + HoverTarget hoverWaveform(const FaceLayout& fl, int x, int y); HoverTarget hoverDeck(const FaceLayout& fl, int x, int y) const; HoverTarget hoverBrowse(int w, int h, int x, int y) const; HoverTarget hoverCurvePopup(int w, int h, int x, int y) const; @@ -326,20 +337,27 @@ private: // instance's own SampleRefs as the self-contained fallback. "" when unresolvable. std::string samplePathFor(const std::string& sampleId) const; - // The effective loop + start markers for the loaded capture: the parameter set's - // override when one is set, else the bank's loop intrinsic / frame 0. With no loop set, - // the loop handles park on loop_span's defaultLoopBounds so both stay grabbable — the - // frame-0 default they replace put loopStart under the start marker, where nothing could - // reach it. - struct SetupMarkers { - std::int64_t start = 0; - std::int64_t loopStart = 0; - std::int64_t loopEnd = 0; - std::int64_t crossfade = 0; // pre-seam fade, SOURCE frames; handle at loopStart - this - bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance) - }; + // The effective loop + start markers for the loaded capture. The state machine behind them + // — which stored source wins, when the pair re-parks, what the two OFF states mean — is the + // pure loop_marks module's; this only reads the bank intrinsic it cannot see. SetupMarkers pickedMarkers(std::int64_t frames) const; + // Whether the loop controls answer at all: the sustain loop is Gate-only, so in Trigger the + // marks and the chrome enable draw Disabled and inert. Reads the mode AFTER the drawn-EG + // fold, so a drawn envelope disables them through the same predicate. + bool loopControlsLive() const; + + // Which marks the band DRAWS, and which of those accept a grab. They differ in exactly one + // place — Trigger, where the loop marks stay drawn (hiding a set loop on a mode flip would + // destroy information the user put there) but refuse every gesture, because that refusal + // comes from the engine and no drag can talk it out of it. + instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const; + instrument::ui::WaveMarks grabbableMarks(const SetupMarkers& m) const; + + // Flips the enable. `on` false retains the span and the crossfade — that retention is the + // whole difference between a toggle and a delete button. + void setLoopEnabled(bool on); + // Whether the loaded sound's bake window needs the user's Hold — the pure predicate // (bake_plan.h) answered against the markers this face is showing. Decodes and reads the // bank, so it is called on the sync tick, not per paint, and memoized against the inputs diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 276734c..198aab0 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -1089,6 +1089,27 @@ static void testLoopSpanAndCrossfadeRoundTrip() { CHECK(out.params.rootOverride && *out.params.rootOverride == 55); } +// The loop ENABLE is `hasLoop`, and the block writes start/end unconditionally — so the wire +// already carries "off, with a span remembered." Nothing about the format changes to make the +// enable user-owned; this pins that the off state and its retained span both survive a reload, +// because an off that came back as a re-parked default would be a delete button, not a toggle. +static void testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained() { + ComponentState in; + in.selectionId = "pad"; + SampleLoop lp; + lp.hasLoop = false; + lp.start = 4096; + lp.end = 65536; + in.params.loopOverride = lp; + in.params.loopCrossfadeFrames = 1024; + + const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); + CHECK(out.params.loopOverride && !out.params.loopOverride->hasLoop); + CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096); + CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536); + CHECK(out.params.loopCrossfadeFrames == 1024); +} + // A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it // at the wire keeps the parameter set the editor reads back sane. static void testNegativeCrossfadeOnTheWireLiftsToZero() { @@ -2039,6 +2060,7 @@ int main() { testDefaultStateRoundTripsToDefaults(); testEnvelopePrefixBytesFrozen(); testLoopSpanAndCrossfadeRoundTrip(); + testAnOffLoopRoundTripsWithItsSpanAndCrossfadeRetained(); testNegativeCrossfadeOnTheWireLiftsToZero(); testPriorPayloadVersionsLiftToAHardSeam(); testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact(); diff --git a/tests/test_loop_marks.cpp b/tests/test_loop_marks.cpp new file mode 100644 index 0000000..9d37b20 --- /dev/null +++ b/tests/test_loop_marks.cpp @@ -0,0 +1,208 @@ +// Standalone tests for reasampler::instrument::ui::loop_marks — no VST3, no REAPER, no +// framework. Asserts the loop enable's whole state machine: the resolve's park rule and its two +// OFF states, the write's collapse fold and crossfade retention, and the four gestures that +// reach `hasLoop` composed end to end (resolve -> edit -> apply -> resolve), which is exactly +// how the editor drives it. + +#include "../src/core/instrument/ui/loop_marks.h" +#include "../src/core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the park target) + +#include + +using namespace reasampler::instrument::ui; +using reasampler::SampleLoop; +using reasampler::instrument::engine::loop::LoopBounds; +using reasampler::instrument::engine::loop::defaultLoopBounds; + +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 constexpr std::int64_t kFrames = 10000; + +// The editor's own round trip: what the band shows after a marker set is written back. +static LoopMarks writeThenRead(const LoopMarks& edited, std::int64_t frames = kFrames) { + const LoopWrite w = applyLoopMarks(edited); + StoredLoop s; + s.override_ = w.loop; + s.crossfade = w.crossfade; + s.startPoint = w.start; + return resolveLoopMarks(s, frames); +} + +static StoredLoop storedSpan(bool on, std::int64_t start, std::int64_t end, + std::int64_t crossfade) { + StoredLoop s; + s.override_ = SampleLoop{on, start, end}; + s.crossfade = crossfade; + return s; +} + +// --- resolve: the two OFF states ----------------------------------------------- + +static void testNothingSetParksOnTheDefaultBoundsAndReadsAsNeverSet() { + const LoopMarks m = resolveLoopMarks(StoredLoop{}, kFrames); + const LoopBounds d = defaultLoopBounds(kFrames); + CHECK(!m.hasLoop); + CHECK(m.parked); // the "DRAG TO SET LOOP" state + CHECK(m.loopStart == d.start && m.loopEnd == d.end); +} + +static void testAValidSpanSwitchedOffKeepsItsOwnPositions() { + const LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames); + CHECK(!m.hasLoop); + CHECK(!m.parked); // the "LOOP OFF" state — there is nothing to "set" + CHECK(m.loopStart == 4000 && m.loopEnd == 6000); + CHECK(m.crossfade == 300); + // And it is NOT the park position, which is what the off->on restore depends on. + const LoopBounds d = defaultLoopBounds(kFrames); + CHECK(!(m.loopStart == d.start && m.loopEnd == d.end)); +} + +static void testAnUnusableSpanParksWhateverTheEnableSays() { + const LoopBounds d = defaultLoopBounds(kFrames); + // Collapsed, inverted, past the PCM, and negative — the four the engine refuses. + const StoredLoop bad[4] = {storedSpan(true, 500, 500, 0), storedSpan(true, 900, 400, 0), + storedSpan(true, 500, kFrames + 1, 0), + storedSpan(true, -5, 400, 0)}; + for (const StoredLoop& s : bad) { + const LoopMarks m = resolveLoopMarks(s, kFrames); + CHECK(!m.hasLoop); + CHECK(m.parked); + CHECK(m.loopStart == d.start && m.loopEnd == d.end); + } +} + +static void testTheOverrideSupersedesTheBankIntrinsic() { + StoredLoop s; + s.intrinsic = SampleLoop{true, 100, 200}; + s.override_ = SampleLoop{true, 4000, 6000}; + const LoopMarks m = resolveLoopMarks(s, kFrames); + CHECK(m.hasLoop && m.loopStart == 4000 && m.loopEnd == 6000); + // With no override the intrinsic is what the band shows. + s.override_.reset(); + const LoopMarks i = resolveLoopMarks(s, kFrames); + CHECK(i.hasLoop && i.loopStart == 100 && i.loopEnd == 200); + // An intrinsic that itself says "no loop" is not a span to adopt. + s.intrinsic = SampleLoop{false, 100, 200}; + CHECK(resolveLoopMarks(s, kFrames).parked); +} + +// --- the four gestures onto hasLoop -------------------------------------------- + +// Gesture 1: the enable clicked ON. Span and crossfade retained as-is. +static void testEnableOnKeepsTheSpanAndCrossfade() { + LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames); + m.hasLoop = true; + const LoopMarks after = writeThenRead(m); + CHECK(after.hasLoop); + CHECK(after.loopStart == 4000 && after.loopEnd == 6000); + CHECK(after.crossfade == 300); +} + +// Gesture 2: the enable clicked OFF. Span and crossfade retained — the whole point of it being +// a toggle rather than a delete button. +static void testEnableOffRetainsTheSpanAndCrossfade() { + LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames); + m.hasLoop = false; + const LoopMarks after = writeThenRead(m); + CHECK(!after.hasLoop); + CHECK(!after.parked); + CHECK(after.loopStart == 4000 && after.loopEnd == 6000); + CHECK(after.crossfade == 300); +} + +// The acceptance criterion in one assertion: off then on restores the loop EXACTLY. +static void testOffThenOnRestoresTheLoopExactly() { + const LoopMarks before = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames); + LoopMarks off = before; + off.hasLoop = false; + LoopMarks mid = writeThenRead(off); + mid.hasLoop = true; + const LoopMarks back = writeThenRead(mid); + CHECK(back.hasLoop == before.hasLoop); + CHECK(back.loopStart == before.loopStart && back.loopEnd == before.loopEnd); + CHECK(back.crossfade == before.crossfade); + CHECK(back.parked == before.parked); +} + +// Gesture 3: the span collapsed onto itself. OFF, span destroyed and re-parked, crossfade zeroed. +static void testCollapsingTheSpanTurnsItOffReparksAndZeroesTheCrossfade() { + LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 300), kFrames); + m.loopEnd = m.loopStart; // the drag that lands one mark on the other + const LoopWrite w = applyLoopMarks(m); + CHECK(!w.loop.hasLoop); + CHECK(w.crossfade == 0); + const LoopMarks after = writeThenRead(m); + const LoopBounds d = defaultLoopBounds(kFrames); + CHECK(!after.hasLoop && after.parked); + CHECK(after.loopStart == d.start && after.loopEnd == d.end); + CHECK(after.crossfade == 0); +} + +// Gesture 4: dragging a loop mark while OFF turns it on, in BOTH off-states. +static void testDraggingALoopMarkWhileOffTurnsItOn() { + // Never set: the pair is parked, and the drag takes it off the park. + LoopMarks parked = resolveLoopMarks(StoredLoop{}, kFrames); + CHECK(parked.parked && !parked.hasLoop); + parked.loopStart = 3000; // the drag + parked.hasLoop = true; + const LoopMarks fromParked = writeThenRead(parked); + CHECK(fromParked.hasLoop && !fromParked.parked); + CHECK(fromParked.loopStart == 3000); + + // Span retained: the drag turns it on at the dragged positions, crossfade retained. + LoopMarks retained = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames); + CHECK(!retained.parked && !retained.hasLoop); + retained.loopEnd = 7000; + retained.hasLoop = true; + const LoopMarks fromRetained = writeThenRead(retained); + CHECK(fromRetained.hasLoop); + CHECK(fromRetained.loopStart == 4000 && fromRetained.loopEnd == 7000); + CHECK(fromRetained.crossfade == 300); +} + +// --- the write's own rules ------------------------------------------------------ + +// Dragging the START marker with the enable off must not turn the loop on, and must not +// destroy the crossfade travelling with the retained span. +static void testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone() { + LoopMarks m = resolveLoopMarks(storedSpan(false, 4000, 6000, 300), kFrames); + m.start = 512; + const LoopWrite w = applyLoopMarks(m); + CHECK(!w.loop.hasLoop); + CHECK(w.loop.start == 4000 && w.loop.end == 6000); + CHECK(w.crossfade == 300); + CHECK(w.start == 512); +} + +static void testANegativeCrossfadeNeverReachesTheStore() { + LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 0), kFrames); + m.crossfade = -1; + CHECK(applyLoopMarks(m).crossfade == 0); + StoredLoop s = storedSpan(true, 4000, 6000, -1); + CHECK(resolveLoopMarks(s, kFrames).crossfade == 0); +} + +int main() { + testNothingSetParksOnTheDefaultBoundsAndReadsAsNeverSet(); + testAValidSpanSwitchedOffKeepsItsOwnPositions(); + testAnUnusableSpanParksWhateverTheEnableSays(); + testTheOverrideSupersedesTheBankIntrinsic(); + + testEnableOnKeepsTheSpanAndCrossfade(); + testEnableOffRetainsTheSpanAndCrossfade(); + testOffThenOnRestoresTheLoopExactly(); + testCollapsingTheSpanTurnsItOffReparksAndZeroesTheCrossfade(); + testDraggingALoopMarkWhileOffTurnsItOn(); + + testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone(); + testANegativeCrossfadeNeverReachesTheStore(); + + if (g_fail == 0) { + std::printf("loop_marks: all tests passed\n"); + return 0; + } + std::printf("loop_marks: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_sample_chrome.cpp b/tests/test_sample_chrome.cpp index 264f40e..331c347 100644 --- a/tests/test_sample_chrome.cpp +++ b/tests/test_sample_chrome.cpp @@ -3,7 +3,8 @@ // // Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the // toolbar's fixed right-anchored run in order (Hold, bake, preview, velocity cell, -// Mono|Stereo, Browse) with the title taking the remainder; the velocity and Hold knobs +// Loop Off|On, Mono|Stereo, Browse) with the title taking the remainder and still holding its +// text at the editor's floor; the velocity and Hold knobs // centred in their cells above their labels; the piano strip owning its whole row at every // width; no rect on the // toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview @@ -58,7 +59,10 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() { CHECK(r.navBrowse.width == kNavButtonWidth); CHECK(r.chanStereo.right() <= r.navBrowse.x); CHECK(r.chanMono.right() == r.chanStereo.x); - CHECK(r.velCell.right() <= r.chanMono.x); + CHECK(r.loopOn.right() <= r.chanMono.x); // the enable is immediately left of Mono|Stereo + CHECK(r.loopOff.right() == r.loopOn.x); // its two segments abut, like the channel pair + CHECK(r.loopOff.y == r.chanMono.y && r.loopOff.height == r.chanMono.height); + CHECK(r.velCell.right() <= r.loopOff.x); CHECK(r.preview.right() <= r.velCell.x); CHECK(r.bake.right() <= r.preview.x); CHECK(r.bake.width == kBakeButtonWidth); @@ -70,8 +74,8 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() { CHECK(r.title.width > 0); // Every toolbar rect sits inside the toolbar row. - const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.chanMono, - r.chanStereo, r.navBrowse}; + const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, + r.loopOn, r.chanMono, r.chanStereo, r.navBrowse}; for (const Rect& it : items) { CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom()); } @@ -84,8 +88,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() { // stay inside its own row, clear of every control. CHECK(!overlaps(r.toolbar, r.rootStrip)); CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom()); - const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.chanMono, - r.chanStereo, r.navBrowse}; + const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, r.loopOn, + r.chanMono, r.chanStereo, r.navBrowse}; for (const Rect& it : items) { CHECK(!overlaps(it, r.rootStrip)); CHECK(!overlaps(it, r.title)); @@ -147,6 +151,20 @@ static void testHoldCellIsReservedAndFollowsTheVelocityCellGrammar() { CHECK(wide.holdCell.width == r.holdCell.width); } +// The enable joins a RIGHT-ANCHORED run, so it is charged to the title slot and not to the +// window. kEditorMinWidth itself is guarded by test_deck_groups' derived-floor assertion — this +// is the other half of that contract: the title must still hold its text AT that floor, because +// the agreed remedy if it cannot is to narrow the enable's segments, never to move the floor. +static void testTheControlRunLeavesTheTitleReadableAtTheEditorFloor() { + const ChromeRects r = chromeRects(chromeBand(), kKnob); + // "ReaSampler 9000" (15 chars) plus a bracketed 20-char capture name, at the toolbar font's + // generous ~7 px/char estimate: 38 * 7. + constexpr int kTitleTextFloorPx = 266; + CHECK(r.title.width >= kTitleTextFloorPx); + // Nothing in the run reaches into the title's slot. + CHECK(r.title.right() <= r.holdCell.x); +} + static void testDegenerateBandYieldsNoInvertedRects() { const ChromeRects empty = chromeRects(Rect{}, kKnob); CHECK(empty.toolbar.empty() && empty.controls.empty()); @@ -157,8 +175,8 @@ static void testDegenerateBandYieldsNoInvertedRects() { kKnob); const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel, tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel, - tiny.chanMono, tiny.chanStereo, tiny.navBrowse, - tiny.rootStrip}; + tiny.loopOff, tiny.loopOn, tiny.chanMono, tiny.chanStereo, + tiny.navBrowse, tiny.rootStrip}; for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y); } @@ -206,6 +224,7 @@ int main() { testStripOwnsItsWholeRowAndGrowsWithTheWindow(); testVelocityKnobIsCentredInItsCellAboveTheLabel(); testHoldCellIsReservedAndFollowsTheVelocityCellGrammar(); + testTheControlRunLeavesTheTitleReadableAtTheEditorFloor(); testDegenerateBandYieldsNoInvertedRects(); testPreviewGlyphSitsInsideTheButtonAndPointsRight(); testPreviewGlyphDegradesRatherThanOverflowing(); diff --git a/tests/test_spline_edit.cpp b/tests/test_spline_edit.cpp index 5346e1f..c9b6447 100644 --- a/tests/test_spline_edit.cpp +++ b/tests/test_spline_edit.cpp @@ -107,7 +107,7 @@ static void testOverlayBoxIsTheWholeArea() { // --- Smallest-target-first: resolveWaveformClaim, the shell's own comparison chain ----- // // editor_input_waveform.cpp's mouseDownWaveform resolves a click among a contour node (a fixed -// pick box), the crossfade tab, and a marker's full-height column by calling +// pick box), a mark's CAP, and a mark's full-height column by calling // resolveWaveformClaim with each candidate's own target area; the smallest hit wins. These // tests build the real geometry over the pure primitives the shell composes, then feed it into // resolveWaveformClaim itself, so a reverted node-first/marker-first/tab-first ordering fails @@ -215,7 +215,45 @@ static void testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere() { WaveformClaimant::kMarker); } -// The only live tie: the crossfade tab (<=110) can equal the node (169) only off-geometry, but +// Case (d): every mark now carries a cap, which RESOLVES the long-open "staged-envelope-node +// shadow at zero-attack" wart. A zero-attack AttackEnd node sits at the canvas's top-left — the +// same pixel a START marker at frame 0 draws at — and used to win the click outright, because +// the marker's only target there was its full-height column. START's cap is the same 11x10 tab +// the crossfade always had, so the node no longer shadows it. The cap slot's nominal area is +// unchanged by the change (every mark's cap is one markerHandleRect), which is why the +// arbitration itself needed no re-tuning: cap < node < column still holds. +static void testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip() { + const Rect a = Rect{20, 10, 1000, kWaveformMinHeight}; + const OverlayArea overlay = overlayOf(a); + const std::int64_t frames = 100000; + // START at frame 0: its cap clips against the band's left edge, and a zero-attack node is + // drawn on that same corner. + const Rect cap = markerHandleRect(overlay, frames, 0); + CHECK(!cap.empty()); + CHECK(contains(cap, a.x, a.y)); + + // NOMINAL, matching what the shell feeds the arbitration — the clipped tab at frame 0 is + // the worst case for the cap, and it still wins on the nominal number the shell uses. + const std::int64_t capArea = + static_cast(2 * kMarkerHandleHalfWidth + 1) * kMarkerHandleHeight; + const std::int64_t columnArea = + static_cast(2 * kMarkerGrabWidth + 1) * a.height; + CHECK(capArea < kNodeArea); + CHECK(kNodeArea < columnArea); + + const WaveformClaim node{true, kNodeArea}; + const WaveformClaim capClaim{true, capArea}; + const WaveformClaim column{true, columnArea}; + CHECK(resolveWaveformClaim(node, capClaim, column, SplineGesture::kLeft) == + WaveformClaimant::kTab); + // And the node keeps everything below the cap strip, which is where it is actually drawn + // for any non-degenerate envelope. + CHECK(!contains(cap, a.x, a.y + kMarkerHandleHeight)); + CHECK(resolveWaveformClaim(node, WaveformClaim{}, column, SplineGesture::kLeft) == + WaveformClaimant::kNode); +} + +// The only live tie: a mark's cap (<=110) can equal the node (169) only off-geometry, but // tab-vs-marker ties at overlay height 10 (kMarkerHandleHeight), where the tab's 11x10 strip // (110) equals a marker column's 11 * 10 (110) — the tab wins, matching check order. static void testTabWinsAGenuineTabVersusMarkerTie() { @@ -269,6 +307,7 @@ int main() { testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero(); testCrossfadeTabBeatsAContourNodeNearItsTopStrip(); testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere(); + testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip(); testTabWinsAGenuineTabVersusMarkerTie(); testNoHitAnywhereFallsThroughToNone(); testAMissedCandidateNeverWinsOnADegenerateZeroArea(); diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index d74627f..d5e139f 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -8,7 +8,10 @@ // markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable); // resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width // no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower, -// no-crossing keeps target, target clamp, degenerate buffers); waveformSurface (two stacked +// no-crossing keeps target, target clamp, degenerate buffers); the four marks (per-mark cap +// resolve, the reverse cap order that keeps a coincident pair separable, label sides/nudging, +// the suppression rule and its promoted-first placement, the crossfade wedge ramp); +// waveformSurface (two stacked // lanes L-over-R in stereo, one lane in mono AND for a mono source, overlay always the full // stacked height, grabs reaching the lower lane); laneEnvelope (per-lane channel split). @@ -361,18 +364,18 @@ static void testMarkerHandleOnDegenerateAreas() { CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4); } -// The shell (editor_input_waveform.cpp) checks the loop crossfade's own grab handle — at -// loopStart - crossfade — before it iterates the ordinary marker array, because a zero-length -// fade puts that handle exactly on the loop-start marker's frame. The same coincidence recurs -// whenever ANY marker shares that frame, most plausibly the START marker dragged up against the -// fade edge: this module can't exercise the shell's check-order itself, but it can prove the -// geometric ambiguity that makes the ordering load-bearing — the array's own first-match rule -// would otherwise resolve the top strip to the START marker, not the fade handle. +// The shell (editor_input_waveform.cpp) resolves a mark's CAP before it iterates the ordinary +// marker array, because a zero-length fade puts the crossfade cap exactly on the loop-end +// marker's frame. The same coincidence recurs whenever ANY marker shares that frame, most +// plausibly the START marker dragged up against the fade edge: this module can't exercise the +// shell's check-order itself, but it can prove the geometric ambiguity that makes the ordering +// load-bearing — the array's own first-match rule would otherwise resolve the top strip to the +// START marker, not the fade handle. static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() { const Rect a = wideArea(); - const std::int64_t loopStart = 400, crossfade = 30; - const std::int64_t fadeEdge = loopStart - crossfade; // where the crossfade handle sits - const std::int64_t markers[3] = {fadeEdge, loopStart, loopStart + 100}; // start dialled here + const std::int64_t loopEnd = 400, crossfade = 30; + const std::int64_t fadeEdge = loopEnd - crossfade; // where the crossfade cap sits + const std::int64_t markers[3] = {fadeEdge, 200, loopEnd}; // start dialled onto the fade edge const int mx = frameToX(overlayOf(a), 1000, fadeEdge); const int topY = a.y; // inside the handle's top strip // Without the shell's priority check, the array's own first-match rule already resolves the @@ -380,10 +383,188 @@ static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() { CHECK(markerAtPoint(overlayOf(a), 1000, markers, 3, mx, topY) == 0); // ...and the fade handle's rect claims the exact same pixel — the ambiguity the shell // resolves by smallest-target-first (the handle's clipped tab is always the narrower - // target), same as it does for the zero-fade/loop-start case. + // target), same as it does for the zero-fade/loop-end case. CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY)); } +// --- The four marks: cap resolve, labels, suppression, crossfade wedge ---------- + +static WaveMarks marksAt(std::int64_t start, std::int64_t loopStart, std::int64_t loopEnd, + std::int64_t xfade, bool loopPresent) { + WaveMarks m; + m.frame[0] = start; + m.frame[1] = loopStart; + m.frame[2] = loopEnd; + m.frame[3] = xfade; + m.present[0] = true; + m.present[1] = m.present[2] = m.present[3] = loopPresent; + return m; +} + +static void testEveryMarkAnswersItsOwnCap() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(50, 300, 700, 620, true); + for (int i = 0; i < kWaveMarkCount; ++i) { + const int mx = frameToX(ov, 1000, m.frame[i]); + CHECK(capAtPoint(ov, 1000, m, mx, a.y) == i); + CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight - 1) == i); + // Below the cap strip is the column's, never the cap's. + CHECK(capAtPoint(ov, 1000, m, mx, a.y + kMarkerHandleHeight) == -1); + } +} + +static void testAMarkThatIsNotPresentAnswersNoCap() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false); + CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 300), a.y) == -1); + CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 620), a.y) == -1); + CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 50), a.y) == 0); // START stays live +} + +// The separability argument the reverse cap order exists for: for a coincident PAIR, one mark +// answers the cap and the OTHER answers the full-height column, so neither is ever stranded. +static void testACoincidentPairStaysSeparableAcrossCapAndColumn() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const int midY = a.y + a.height / 2; + // Zero-length fade: the crossfade mark sits at loopEnd - 0, i.e. exactly on the END marker. + // This is the live case — the crossfade is anchored to the seam it closes. + { + const WaveMarks m = marksAt(50, 300, 700, 700, true); + const int mx = frameToX(ov, 1000, 700); + CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast(WaveMark::kCrossfade)); + const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]}; + CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast(WaveMark::kLoopEnd)); + } + // START dragged onto the loop start: the cap goes to LOOP, the column to START. + { + const WaveMarks m = marksAt(300, 300, 700, 100, true); + const int mx = frameToX(ov, 1000, 300); + CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast(WaveMark::kLoopStart)); + const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]}; + CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast(WaveMark::kStart)); + } + // START dragged onto the loop end: the cap goes to END, the column to START. + { + const WaveMarks m = marksAt(700, 300, 700, 100, true); + const int mx = frameToX(ov, 1000, 700); + CHECK(capAtPoint(ov, 1000, m, mx, a.y) == static_cast(WaveMark::kLoopEnd)); + const std::int64_t cols[3] = {m.frame[0], m.frame[1], m.frame[2]}; + CHECK(markerAtPoint(ov, 1000, cols, 3, mx, midY) == static_cast(WaveMark::kStart)); + } +} + +// The crossfade is the one mark with NO full-height column, so it must never lose a cap tie. +static void testTheCrossfadeCapOutranksEveryOtherMark() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(400, 400, 400, 400, true); // every mark on one frame + CHECK(capAtPoint(ov, 1000, m, frameToX(ov, 1000, 400), a.y) == + static_cast(WaveMark::kCrossfade)); +} + +static void testLabelSidesKeepEachLabelOutOfTheSpanItBounds() { + CHECK(!markLabelLeftOfLine(WaveMark::kStart)); + CHECK(!markLabelLeftOfLine(WaveMark::kLoopStart)); + CHECK(markLabelLeftOfLine(WaveMark::kLoopEnd)); + CHECK(markLabelLeftOfLine(WaveMark::kCrossfade)); + + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const int mx = frameToX(ov, 1000, 500); + const Rect right = markLabelRect(ov, 1000, 500, /*leftOfLine=*/false, 30); + const Rect left = markLabelRect(ov, 1000, 500, /*leftOfLine=*/true, 30); + CHECK(right.x == mx + kMarkLabelGap && right.width == 30); + CHECK(left.right() == mx - kMarkLabelGap && left.width == 30); + // Directly under the cap strip, so caps and labels never fight for the same pixels. + CHECK(right.y == a.y + kMarkerHandleHeight && right.height == kMarkLabelHeight); + CHECK(left.y == right.y); +} + +static void testALabelIsNudgedInsideTheAreaRatherThanClipped() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + // At frame 0 a right-side label would still fit; at the last frame it would overhang. + const Rect atEnd = markLabelRect(ov, 1000, 1000, /*leftOfLine=*/false, 40); + CHECK(atEnd.width == 40); + CHECK(atEnd.right() == a.right()); + const Rect atStart = markLabelRect(ov, 1000, 0, /*leftOfLine=*/true, 40); + CHECK(atStart.width == 40); + CHECK(atStart.x == a.x); + // Wider than the whole band, or no band to draw in: nothing placed. + CHECK(markLabelRect(ov, 1000, 500, false, a.width + 1).empty()); + CHECK(markLabelRect(overlayOf(Rect{0, 0, 200, kMarkerHandleHeight}), 1000, 500, false, 20) + .empty()); +} + +static void testOverlappingLabelsAreSuppressedInPlacementOrder() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + // LOOP labels right of its line at 500, XFADE left of its line at 520: the two boxes point + // at each other and cannot both fit. (LOOP and END never collide however close they get — + // their labels point away from the span they bound.) + const WaveMarks m = marksAt(50, 500, 900, 520, true); + const int w[kWaveMarkCount] = {36, 32, 26, 40}; + const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, /*promoted=*/-1); + CHECK(!lab.box[0].empty()); // START, far away, always placed + CHECK(!lab.box[1].empty()); // LOOP placed before XFADE, so LOOP wins + CHECK(!lab.box[2].empty()); // END, far away, always placed + CHECK(lab.box[3].empty()); // XFADE suppressed + // Every placed box is disjoint from every other. + for (int i = 0; i < kWaveMarkCount; ++i) { + for (int j = i + 1; j < kWaveMarkCount; ++j) { + if (lab.box[i].empty() || lab.box[j].empty()) continue; + CHECK(lab.box[i].x >= lab.box[j].right() || lab.box[j].x >= lab.box[i].right()); + } + } +} + +// The promoted mark is placed FIRST, so grabbing or hovering a mark always shows its label — +// even the one the resting layout suppresses. +static void testThePromotedMarkIsNeverTheSuppressedOne() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(50, 500, 900, 520, true); + const int w[kWaveMarkCount] = {36, 32, 26, 40}; + CHECK(layoutMarkLabels(ov, 1000, m, w, -1).box[3].empty()); // XFADE suppressed at rest + const WaveMarkLabels grabbed = + layoutMarkLabels(ov, 1000, m, w, static_cast(WaveMark::kCrossfade)); + CHECK(!grabbed.box[3].empty()); // and placed when it is the one being grabbed + CHECK(grabbed.box[1].empty()); // LOOP yields to it instead +} + +static void testAbsentMarksTakeNoLabel() { + const Rect a = wideArea(); + const OverlayArea ov = overlayOf(a); + const WaveMarks m = marksAt(50, 300, 700, 620, /*loopPresent=*/false); + const int w[kWaveMarkCount] = {36, 32, 26, 40}; + const WaveMarkLabels lab = layoutMarkLabels(ov, 1000, m, w, -1); + CHECK(!lab.box[0].empty()); + CHECK(lab.box[1].empty() && lab.box[2].empty() && lab.box[3].empty()); +} + +static void testTheCrossfadeWedgeRampsToItsPeakAtTheSeam() { + // Zero at the fade's start, the peak at its last column, monotone in between. + CHECK(crossfadeWedgeHeight(100, 200, 100) == 0); + CHECK(crossfadeWedgeHeight(100, 200, 199) == kCrossfadeWedgePx); + int prev = -1; + for (int x = 100; x < 200; ++x) { + const int h = crossfadeWedgeHeight(100, 200, x); + CHECK(h >= prev); + CHECK(h >= 0 && h <= kCrossfadeWedgePx); + prev = h; + } + // Outside the span it contributes nothing, so a caller can sweep a wider range safely. + CHECK(crossfadeWedgeHeight(100, 200, 99) == 0); + CHECK(crossfadeWedgeHeight(100, 200, 200) == 0); + // Degenerate spans: an empty one draws nothing, a one-column one is all peak. + CHECK(crossfadeWedgeHeight(100, 100, 100) == 0); + CHECK(crossfadeWedgeHeight(100, 99, 100) == 0); + CHECK(crossfadeWedgeHeight(100, 101, 100) == kCrossfadeWedgePx); +} + // --- Per-lane envelope content ------------------------------------------------- static void testAsymmetricStereoLanesCarryDifferentContent() { @@ -460,6 +641,17 @@ int main() { testMarkerHandleOnDegenerateAreas(); testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge(); + testEveryMarkAnswersItsOwnCap(); + testAMarkThatIsNotPresentAnswersNoCap(); + testACoincidentPairStaysSeparableAcrossCapAndColumn(); + testTheCrossfadeCapOutranksEveryOtherMark(); + testLabelSidesKeepEachLabelOutOfTheSpanItBounds(); + testALabelIsNudgedInsideTheAreaRatherThanClipped(); + testOverlappingLabelsAreSuppressedInPlacementOrder(); + testThePromotedMarkIsNeverTheSuppressedOne(); + testAbsentMarksTakeNoLabel(); + testTheCrossfadeWedgeRampsToItsPeakAtTheSeam(); + testAsymmetricStereoLanesCarryDifferentContent(); testLaneEnvelopeRejectsOutOfRangeLane(); From 56bf26d8b6e159ee505d8525e295e5be0e78295c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 06:31:27 -0400 Subject: [PATCH 32/56] docs: collapse Phase Gamma Wave 1 to its landed record, and correct T7's superseded status --- docs/PLAN.md | 652 ++++++--------------------------------------------- 1 file changed, 75 insertions(+), 577 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index b0a1cba..636aa29 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -843,604 +843,98 @@ exact interim layout; do not "fix" it in a track that does not own it. ### Γ-W1 — Foundations -**Depends on:** nothing in this phase. **Six tracks, disjoint by surface** — re-verified -against this membership rather than carried over from the four-wave shape. The phase's track -numbering runs to T7; **T6 landed within this wave but has no entry in this document**: +**Depends on:** nothing in this phase. -| Track | Owns | -|---|---| -| **T1** `knob-interaction-law` | a **new pure taper module** under `core/instrument/ui/`, `ui/deck_values`, `ui/envelope_overlay` + `ui/envelope_edit` (the AHDSR schematic axis and its drag inverse), `ui/param_slider`, the three `shell/instrument/editor_input_*` drag paths, `editor_controls.cpp`'s `envClampBounds` only, the shared modifier helper in `editor_internal.h` | -| **T2** `master-bus-audio` | new pure `engine/limiter` + `engine/meter_ballistics`, `shell/instrument/reasampler_processor` + `processor_state`, `map/component_state_io` + `params_payload` (**the wave's payload rung**) | -| **T3** `contour-trace-curves` | `shell/instrument/editor_paint_waveform.cpp`'s staged trace + a **new pure** tessellation module | -| **T4** `editor-floor-and-row-law` | `ui/sample_bands.h` (the floor), `ui/knob_deck.h` (budget constants + two invalidated header notes), `ui/deck_groups` (the row predicate **only**), five test fixtures | -| **T5** `preserve-time-stretch` | `engine/pitch_shift` + a new pure stretcher module, `engine/voice.{h,cpp}`'s Preserve read path | -| **T7** `psola-preserve` | a **new pure module** `engine/period_detect` (two-pass YIN, with its own `period_detect_tests` target), `pitch_shift`'s jump geometry, the load-time hook in `map/sample_map`'s `buildSampleData`, `voice`'s note-on | - -**Two shared files in the wave, named rather than discovered at merge.** -`src/core/instrument/engine/CMakeLists.txt` — T2 declares two new pure libraries and their -test targets there, T5 declares one. **And, newly, `src/core/instrument/ui/CMakeLists.txt`** — -T1 declares the taper module and its test target, T3 declares the tessellation module and -its. All four are append-only additions in separate blocks — **textual merge adjacency, not -semantic contention.** Whichever lands second rebases. - -**Three near-misses that are avoided by construction, and must stay avoided.** - -(a) **T3's tessellation helper lands in a NEW pure module — explicitly NOT -`ui/envelope_overlay`, which T1 now owns**, and not in `editor_internal.h`, which T1 is also -editing. The prior wording offered `envelope_overlay` as an option; Ruling 2 removed it, -because T1's schematic-axis work rewrites that module's whole time→x map. This still -satisfies the phase's geometry-stays-pure criterion, so it costs nothing. - -(b) **T1 and T3 are disjoint by file but coupled by data, and the coupling has a stated -resolution.** T1 owns where an AHDSR's vertices *land*; T3 owns the stroke *between* -vertices. T3's tessellation is over φ across a segment's pixel span, so the tapered axis -changes nothing about the curve it draws — **but T3's tests must assert against the returned -vertices, not against absolute pixel literals**, or they break when T1 lands. Whichever -track lands second rebases; expressing T3's assertions relatively makes that rebase free. - -(c) **T4 touches `deck_groups` but adds only the new row predicate**; it does not touch -`sampleDeckGroups`, which W2-T1 and W3-T1 own in later waves, and it does not touch -`deck_values`, which is T1's. - -**Two consumption boundaries worth stating, because they look like collisions and are not.** -T1 **consumes** `engine/master_gain`'s dB taper for its whole-dB snap and does not edit it; -T2 does not touch it either. And T2's payload rung is the wave's only format change — -T1's taper and ceiling changes are persistence-neutral by construction (the payload stores -raw engine doubles). - -**Both of the phase's DSP unknowns are in this wave** — T2's limiter and T5's stretcher. That -is deliberate: they are the two tracks whose gate can fail, and failing in wave 1 is -recoverable in a way that failing in the last wave is not. +**All seven tracks have landed** — Γ-W1-T1 (`knob-interaction-law`), Γ-W1-T2 +(`master-bus-audio`), Γ-W1-T3 (`contour-trace-curves`), Γ-W1-T4 (`editor-floor-and-row-law`), +Γ-W1-T5 (`preserve-time-stretch`), Γ-W1-T6 (`exhaustive-switch gate on pure libraries`), and +Γ-W1-T7 (`psola-preserve`) — see `docs/COMPLETED.md` for the full narrative of each. **T6 has +no subsection below**, matching this plan's original choice not to give it one; its record in +`docs/COMPLETED.md` is reconstructed from the CMake change and its enforcement call site +rather than from a spec section here. #### Γ-W1-T1 — `knob-interaction-law` -**Goal.** One consistent, unit-category-driven interaction and taper rule across every -variable control, **over a stage-time range raised 2 s → 10 s**, landed **before** any new -control is added so the new ones are authored into it rather than retro-fitted — and before -any parameter is declared, so the law is what the host is handed rather than something the -host has to be reconciled with later. - -**Spec:** `docs/product/instrument-control-surface.md` §4, **§4.3.1 (the 10 s ceiling and the -overlay-legibility design — new, read it before scoping this track)**, and -`docs/product/parameter-automation.md` §8 (the one-way-door sweep this track discharges). - -**Surface boundary — owns:** a **new pure taper module** under `core/instrument/ui/` (the -ms/semitone/exponent maps, extracted so they have one home), -`core/instrument/ui/deck_values` (the bindings, the snap-unit table, `resetDeckParam`), -`core/instrument/ui/envelope_overlay` (the ceiling constant **and** the AHDSR schematic -axis) and `core/instrument/ui/envelope_edit` (its drag inverse), -`core/instrument/ui/param_slider` (the drag law), `shell/instrument/editor_input_*` -(modifier read + re-anchor), `shell/instrument/editor_controls.cpp`'s `envClampBounds` -**only** (it reads `kEnvTimeMaxSeconds`), and the modifier-reading helper the three input -paths share. **Does not own** any deck descriptor, any parameter, the waveform painter, or -`engine/master_gain` (consumed, not edited). - -**Why this track does NOT split, asked and answered.** Ruling 2 makes it materially bigger — -tapers, modifiers, re-anchor, reset bypass, the ceiling, and the overlay's schematic scale. -Two splits were considered and both are **serial, not parallel**, so neither buys any -concurrency: an *interaction* half (modifiers, snap, re-anchor) needs the *domain* half's -taper and snap-unit table to exist first; and a standalone *overlay-axis* track needs the -taper module and the final ceiling before it can define a stage's slot width. Splitting -would therefore cost a wave and gain nothing, while putting the single most -identity-critical function in the phase across a wave boundary — the same function the host -will normalize against three waves later. **The seam that matters is internal and is a -deliverable: the taper is extracted into its own pure module**, which is what makes "the -taper IS the host-facing normalization" structurally true rather than a comment. The -~600-line ceiling is a per-file bar, and the extraction is what keeps every file under it. - -**Behavior.** -- **Shift snaps to whole numbers in the control's displayed unit**; **Ctrl scales the drag by - 0.05**; **Shift+Ctrl = Shift wins** (Ctrl is ignored — with an integer-quantized output a - finer drag yields the same sequence, so this is identity, not a compromise). -- **Snap unit by category:** ms knobs → whole ms; semitone knobs (incl. Rate, when it - arrives) → whole semitones; percent/fraction knobs → whole percent; the 12 curve-exponent - inner dials → whole numbers (which puts 1.0, the linear neutral, one snap away); master - gain → whole dB; already-integer controls unchanged. Full table in the spec §4.2. -- **Mid-drag modifier transitions re-anchor** — on every press *and* release during an active - drag, the current value becomes the anchor value and the current cursor position the anchor - position. The value is continuous across the transition; only the rate changes. Without - this the grab-anchored absolute drag (`kKnobDragRangePixels = 128`) jumps by - `(1 − 0.05) ×` the accumulated delta. -- **Millisecond knobs become log-scaled.** Exactly 0 s at norm 0 and exactly - `kEnvTimeMaxSeconds` at norm 1, monotone throughout; **10 ms lands within 0.12–0.20 of - travel and 100 ms within 0.42–0.52**. -- **The stage-time ceiling moves 2.0 s → 10.0 s** (Daniel, reversing Γ-F3): - `kGateStageMaxSeconds` (`envelope_overlay.h:85`) and, through it, `kEnvTimeMaxSeconds` - (`deck_values.h:22`). **The two move together or not at all** — `deck_values.h` reads the - overlay's constant rather than restating it precisely so they cannot drift - (`deck_values.h:19-22`). The taper's landmarks above are fit against the **new** ceiling, - which is why the ceiling cannot be a follow-up: fitting the taper twice is the only other - way to get there. -- **`resetDeckParam`'s bypass becomes MANDATORY rather than merely required-anyway.** - `deck_values.h:42-46` records that exact default recovery depends on the ceiling being a - power of two; **2.0 is, 10.0 is not**, and the log taper compounds it. Nothing here may be - "simplified" back into a norm round-trip under any circumstance. -- **NEW, and the sharpest requirement in the track: every default must have an EXACT - normalized preimage under its taper.** `ParameterInfo::defaultNormalizedValue` (Γ-W4-T1) - is normalized, so a host's reset-to-default arrives as `toPlain(defaultNorm)` — and **the - host has no `resetDeckParam` bypass to use**. The bypass fixes the editor's reset and - cannot fix the host's; only exactness in the map itself makes the two land on the same - value. This binds the taper's *shape*, so it belongs here and cannot be handed forward. - Master gain's unity (≈ 0.714 norm) is the case where a hair off is audible. -- **The AHDSR overlay's schematic axis becomes the taper — the ceiling's real cost, and it - is design work, not a constant change.** Each of the four timed stages gets an equal slot - and today maps seconds across it linearly (`gatePxPerSecond`, - `envelope_overlay.cpp:33-34`). At 2 s a 30 ms attack is 1.5 % of its stage's domain; **at - 10 s it is 0.3 %, under a pixel at the floor width.** The fix: a stage's slot width becomes - `slotPx × taperNorm(seconds)` instead of `slotPx × seconds / ceiling`, so a node's position - within its slot **is** its knob's needle position. Legibility becomes ceiling-independent by - construction; the one-model invariant gets stronger rather than strained; and **the drawn - curve is unaffected**, because the taper decides only where a stage's end node lands while φ - still runs linearly across the stage's pixel span — so Γ-W1-T3's φ^p trace composes with it - rather than fighting it. **The AHD policy is untouched**: an AHD maps 1:1 onto the - waveform's own PCM-aligned time axis and stays linear in seconds. Two alternatives - (content-fit auto-scale; a minimum drawn stage width) were considered and rejected — spec - §4.3.1 names why, and neither is to be reintroduced as a "simplification." -- **`envelope_edit`'s drag inverse must remain the EXACT inverse of the draw.** Both read the - same taper module; a node dragged to a pixel and the knob's value at that pixel are one - number, not two that agree. -- **The taper is EXTRACTED into its own pure module**, with its own `_tests` target, - because it now has three consumers in two different dependency layers: `deck_values` (which - sits above `envelope_overlay`), `envelope_overlay`/`envelope_edit` (which sit below it), - and — from Γ-W4-T1 — the host. Leaving it inside `deck_values` would force an inverted - include edge. **Do not solve that by copying the map.** -- **Semitone knobs become log2/centre-expanded.** Symmetric, exactly 0 at centre, exactly - ±`kPitchDepthMaxSemis` at the ends, monotone; **±7 st reached at 50–58 % of each - half-travel**. -- **`resetDeckParam` bypasses the taper** — it writes the default value directly instead of - round-tripping through `norm → value`. This *removes* the power-of-two dependency the - header currently documents rather than working around it; that comment - (`deck_values.h:42-46`) becomes wrong and must be rewritten. -- **Scope is the parameter, not the widget.** Deck knobs (outer ring and inner dial), - envelope stage nodes and curve knots all honour it — they are surfaces onto one model, and - a snap on one but not the others is a divergence. **Waveform markers are explicitly - excluded**: they carry a shipped zero-crossing snap on the same modifier space and their - domain is frames. - -**Acceptance criteria.** -- Every taper change is verified **persistence-neutral**: a project saved before the change - reopens with bit-identical stored values and identical audio; only needle angles move. -- Holding Shift mid-drag on each unit category lands the documented whole unit; releasing it - does not jump the value. -- Holding and releasing Ctrl mid-drag is continuous — no step at either transition. -- Double-clicking any knob (outer ring and inner dial independently) lands **exactly** on its - default at every taper, verified against a default-constructed `PlaySeconds` rather than a - round trip. -- The log/log2 landmark positions above are asserted in the **taper module's** own tests, and - hold at the **10 s** ceiling — the fit is against the new ceiling, not the old one. -- **Every default round-trips exactly through `norm → value`**, asserted per unit category - against a default-constructed `PlaySeconds` and against `master_gain`'s unity. This is the - criterion Γ-W4-T1 will declare `defaultNormalizedValue` from; it fails here, not there. -- **A stage time of several seconds is reachable by hand with no loss of resolution below - 100 ms**, and `kEnvTimeMaxSeconds == kGateStageMaxSeconds` is asserted, not assumed. -- **A project saved at the 2 s ceiling reloads with identical stored seconds and identical - audio** — the ceiling change is persistence-neutral for the same reason the taper is. -- **The AHDSR overlay reads legibly at both ends of the new range**: a default 3 ms attack is - a visible, grabbable node at the floor width, and a 10 s decay still lands its end node at - its slot's edge. Assert the node separation, then judge the result by eye in the DAW. -- **The overlay's drag inverse is the exact inverse of its draw** at the tapered axis — - `nodeAtPoint` / `resolveNodeDrag` and `buildEnvelopePolyline` round-trip. -- **The AHD 1:1 policy is unchanged**, asserted: a sustain-less envelope's x-axis stays - wall-clock over the waveform. -- **The taper module is pure, CTest-covered, and is the ONLY definition of each map** — a - grep finds no second copy in `deck_values`, `envelope_overlay`, or the shell. -- **The filter's four `*Norm` controls are untouched by the taper pass** — cutoff, Q, morph - and drive are already wire-frozen in payload v9; a regression baseline proves their audio - is unchanged. -- One shared modifier-read helper serves all drag surfaces; no second modifier grammar exists. - -**Open questions.** -- **No [Daniel] questions.** Fork **Γ-F3 is REVERSED**: the ceiling moves to **10.0 s, in this - track.** Daniel's *"a horrifically long decay with tight exp"* is the case it serves, and - the `docs/TODO.md` entry that carried it is discharged rather than deferred again. **The - reversal's cause is Ruling 1** — a range endpoint is host-facing normalization, free to - move now and permanently expensive after Γ-W4-T1. Both of the prerequisites the deferred - entry named are in this track anyway: the log taper (which is what makes a higher ceiling - usable at the low end rather than unusable) and the reset bypass (which retires the - power-of-two dependency — 2.0 is a power of two, 10.0 is not). The engineer should know the - reset bypass is now doing triple duty and must not be "simplified" back into a norm - round-trip under any circumstance. -- **[propose at review]** the exact shape of the taper, subject to the landmark bounds **and** - the exact-default-preimage requirement. Those two together are tighter than either alone, - and the second is easy to satisfy by accident and easy to lose in a refactor — **assert it, - do not observe it.** -- **[propose at review]** whether the tapered schematic axis wants a visible tick or - gradation cue, now that it is no longer linear in time. The plan's lean is **no** — the ms - labels carry the number and the editor's no-decoration policy stands — but a reader who - finds the axis illegible in the DAW should say so rather than silently adding one. +**Landed** — see `docs/COMPLETED.md` for the full narrative. One consistent interaction and +taper law across every variable control, landed before Rate/Pitch or any VST3 parameter +existed so both are authored into it rather than retrofitted. The taper is extracted into +its own pure module, `core/instrument/ui/param_taper` — the one home three consumers read +(the knob's needle, the AHDSR overlay's schematic axis and its drag inverse, and — from a +later wave — the VST3 host's `toPlain`/`toNormalized`). Shift snaps to whole units in the +control's displayed category; Ctrl scales the drag by 0.05; Shift+Ctrl resolves to Shift; a +mid-drag modifier transition re-anchors value and cursor position. **The stage-time ceiling +moves 2.0 s → 10.0 s** (`kGateStageMaxSeconds`/`kEnvTimeMaxSeconds`, moved together so they +cannot drift), reversing Γ-F3 on Daniel's later ruling, and every default now has an exact +normalized preimage under its own taper — the requirement Γ-W4-T1's `defaultNormalizedValue` +depends on, since a host's reset-to-default has no `resetDeckParam` bypass to fall back on. +The filter's four `*Norm` controls stay untouched (wire-frozen in payload v9); the change is +persistence-neutral throughout. #### Γ-W1-T2 — `master-bus-audio` -**Goal.** The master limiter and the meter's **audio and publication halves**, plus **the -plugin's first latency reporting** — no editor drawing. Landing the audio ahead of the deck is -what lets Γ-W3 draw against real published state instead of a stub. - -**Spec:** `docs/product/instrument-control-surface.md` §3.1, **§3.1.1 (latency — read this -first; it was rewritten when Γ-F6 closed, so an older reading of it is wrong)**, §3.2–3.3, -§3.5, §7.10, §7.11, §8.2. - -**Surface boundary — owns:** a new pure limiter module and a new pure meter-ballistics -module under `core/instrument/engine/` (each with its own `_tests` target), -`shell/instrument/reasampler_processor` (the chain, the published block state, **and the -`getLatencySamples` / `restartComponent(kLatencyChanged)` path**), and **the phase's FIRST -params-payload rung** (the limiter enable flag). **Does not own** MASTER's deck geometry or -any drawing — that is Γ-W3-T1. **This track spends that rung: `kParamsPayloadVersion == 15`** -(`map/component_state_io.h:170`), confirmed on this branch as of 2026-08-01 — the full ladder -(spent / next-free / reserved) is stated in the phase summary's resequencing note near the end -of this phase's ASCII block. - -**Behavior.** -- **Chain:** `voice mixer → master gain (existing ramped multiply) → limiter (bypassable) → - output bus`, with the meter tapped at the **bus output, post-limiter**. -- **Limiter: a single toggle, no configurable controls.** Baked ceiling **−0.3 dBTP**. - Default **off**. **No makeup gain, ever, of any kind** — transparent at rest. - Stereo-linked detection (max |L|,|R| drives one gain) so the image is not moved. -- **True-peak detection is sidechain-only** — an oversampled detector in the sidechain, never - oversampling the signal path. Factor is the engineer's call under the measure gate. -- **Lookahead, with DYNAMIC reported latency (Γ-F2, ruled).** `getLatencySamples()` returns - **0** when the limiter is off and **the lookahead in samples** when it is on; the toggle - calls `IComponentHandler::restartComponent(kLatencyChanged)`. **None of this exists today** — - there is no `getLatencySamples` override, no `kLatencyChanged`, and no `restartComponent` - call site anywhere in `src/`; the plugin ships the SDK default of 0. This track introduces - the plugin's first latency reporting. -- **The restart is routine; the fencing is against a standing scar, not against the flag.** - Dynamic latency reporting is ordinary VST3-instrument behaviour and REAPER handles it as a - matter of course. The SDK's deactivate/reactivate requirement - (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) is the normal contract. **What makes the - cycle expensive here is this plugin's own `setActive`** — reactivate calls - `reloadInstrument()`, a bridge read plus a full WAV re-decode - (`reasampler_processor.cpp:89-97`), where a typical plugin only allocates buffers; deactivate - frees `live_`/`draining_`/graveyard (`:98-107`) for a documented reason (ghost sustained - voices). **Γ-F6 is ruled: ship it — the toggle is a patch-design gesture, not a - during-playback one.** Do **not** build a constant-reported-latency fallback and do **not** - gate the deliverable on a measurement. The reduction of that self-inflicted cost is filed in - `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") with its trigger - condition; it is out of scope here. The four requirements below survive as engineering - hygiene against the `kIoChanged` scar, and all four are acceptance criteria: - 1. **Verify the whole call sequence against the vendored Steinberg SDK** before writing it, - including the ordering rule that the new latency is what `getLatencySamples` returns - *after* `setActive(true)` — so **the reported value must derive from persisted state, not - from a transient the deactivate clears.** - 2. **Prove the restart does not disturb the output bus arrangement.** The output stays one - permanently-stereo bus, never renegotiated. - 3. **Ship a regression test in the spirit of `testDualMonoStereoSampleRendersCentered`** — - a dual-mono capture rendered across a limiter toggle stays centered, L ≡ R. - 4. **`restartComponent` is never called from `process()`.** Main/UI thread only, and - coalesced so repeated clicks produce one restart per settled state. - **This is NOT the change `reasampler_processor.cpp:66-68` forbids.** That warning is against - reintroducing per-mode **bus** renegotiation (`kIoChanged` class), which panned a dual-mono - capture hard right in the host's pin re-routing; `kLatencyChanged` is a different flag and the - bus is untouched. But the precedent — mid-session `restartComponent` in this plugin has - already shipped one real regression — is exactly why (2) and (3) are non-negotiable. -- **Flipping the toggle during playback: apply immediately, do NOT defer to a transport - boundary** (product ruling, spec §3.1.1). A deferred restart leaves the plugin misaligned by - the lookahead with no visible cue, which is worse than a visible interruption; and the host, - not the plugin, schedules the deactivate/reactivate anyway. **Daniel has accepted the - interruption outright** (Γ-F6) — it is not a case to design for. Two things remain in scope, - and neither is a mitigation for it: - - **A short (≤ 10 ms) equal-gain crossfade over the engage/disengage.** Kept as a *quality* - measure, not a mitigation: a limiter engaging is a gain-path change, and this codebase - already ramps every gain-path change (`kGainRampSeconds`, `ValueRamp`). It also earns its - keep independently of the restart, because **we do not control when the host acts on the - request** — our own transition must be clean in the window before it does. - - **The limiter enable is classified NOT automatable** - (`docs/product/parameter-automation.md` §3.8) so nothing can flip it at rate. It is also - **not** the plugin's `kIsBypass` parameter. -- **Per block the processor publishes, as relaxed atomics:** per-channel peak `max|x|`, a - latched clip flag, and the block's maximum gain reduction. **No dB conversion, no - ballistics, no hold timers on the audio thread** — the UI converts and runs ballistics from - block peaks and elapsed time. This widens the existing advisory-peak pattern - (`reasampler_processor.h:109-113`), which is not reusable as-is. -- **Meter ballistics (pure, unit-tested):** instantaneous rise; **fall 20 dB/s**; peak-hold - latched at the running max, **held 1.5 s**, then falling at the same rate; scale **linear in - dB over −60…+6 dBFS**; clip latches at block peak ≥ 0 dBFS and is cleared on request. -- **The `ComponentState` payload rung** appends the limiter flag as a strict suffix on the - existing discipline; the preceding version's blob is a strict prefix and lifts to bypassed. - -**Acceptance criteria.** -- **With the limiter bypassed the rendered output is byte-identical to the pre-change build**, - asserted by a regression baseline, not by ear. -- With the limiter engaged, no output sample exceeds the ceiling on program material that - exceeds it by up to +12 dB; with it bypassed and gain driven, the output does exceed - 0 dBFS (proving the toggle is doing the work). -- **Nothing is louder at rest with the limiter on.** A signal that never reaches the threshold - is bit-identical engaged and bypassed. -- No allocation, no lock, no transcendental on the per-sample path; the measure-and-report - gate reports per-voice-block CPU with the limiter engaged at 32 voices. -- The meter-ballistics module is pure and CTest-covered: rise, 20 dB/s fall, 1.5 s hold, clip - latch/clear, and the dB↔pixel map are all asserted without a host. -- A project saved before this change reopens with the limiter bypassed and sounding identical. -- **`getLatencySamples()` returns exactly 0 with the limiter off**, and the lookahead in - samples with it on — asserted against the persisted flag, and correct across a - deactivate/reactivate cycle. -- **A dual-mono capture rendered across a limiter toggle stays centered** (L ≡ R), and the - output bus arrangement after a latency-change restart is identical to before it. -- **The plugin emits no hard step at the toggle** — the engage/disengage crossfade is asserted - on a rendered signal, not judged by ear. - -**Open questions.** -- **No [Daniel] questions. Fork Γ-F6 is ruled** — dynamic latency ships as specced, the - deactivate/reactivate is accepted, and there is no fallback design and no measurement gate. - Do not reintroduce either; the constant-reported-latency option is closed, not shelved. -- **[verify]** `temp_cortex/` has already been assessed and **rejected** (spec §3.5) — do not - re-litigate it, and do not transplant from it. -- **[record, not a gate]** While the limiter is in REAPER under your hand, note what the - restart actually costs — do notes cut, is the re-decode perceptible, does transport hiccup — - and record it in this track's review. It is **not** a gate on shipping and no outcome changes - the design; it is the trigger-condition evidence for the `docs/TODO.md` entry "Decouple the - instrument reload from VST3 activation," which is where that cost gets reduced if it ever - matters. Do **not** restructure `setActive` here: its destructive shape is deliberate and its - reasoning (ghost sustained voices on reactivate) is documented at the call site. +**Landed** — see `docs/COMPLETED.md` for the full narrative. The master bus: a bypassable +true-peak limiter (baked −0.3 dBTP ceiling, default off, no makeup gain, stereo-linked, +sidechain-only oversampled detection), the meter's audio and publication half, and the +plugin's first latency report — `getLatencySamples()` returns 0 with the limiter off and the +lookahead in samples with it on, driving `restartComponent(kLatencyChanged)` on toggle. New +pure modules `core/instrument/engine/limiter` and `engine/meter_ballistics`. **Spent the +phase's first payload rung: `kParamsPayloadVersion` reaches 15**, appending the limiter +enable flag as a strict suffix. #### Γ-W1-T3 — `contour-trace-curves` -**Goal.** Staged envelope segments draw as the curve their exponent defines, so the -mid-segment knot stops floating off its own trace. - -**Spec:** `docs/product/instrument-control-surface.md` §5. - -**Surface boundary — owns:** `shell/instrument/editor_paint_waveform.cpp`'s staged-envelope -trace and a **new pure tessellation module** for it. **Does not own** the loop/crossfade -marks (Γ-W2-T2), `envelope_overlay`'s vertex model, or the drawn-EG (spline) trace. - -**The helper's home is now constrained, not a choice.** `ui/envelope_overlay` was previously -offered as a candidate home for the tessellation helper; **Γ-W1-T1 now owns that module** -(the AHDSR schematic axis, per Ruling 2), so the helper lands in a **new** pure module under -`core/instrument/ui/`. T1 also owns where an AHDSR's vertices land — this track owns only the -stroke *between* vertices, and tessellates over φ across a segment's pixel span, so the -tapered axis changes nothing about the curve drawn. **Express this track's assertions against -the returned vertices, not against absolute pixel literals**, and the rebase onto T1 is free. - -**Behavior.** The defect is verified: `editor_paint_waveform.cpp:218` drops knots -(`if (v.knot) continue;`) and joins the remaining vertices with straight strokes, and -`curveMap` is never called in the paint path even though the exponent is in scope at `:211`. -Knot *positioning* already honours the exponent via `curveMidLevel` -(`envelope_overlay.cpp:94-105`) — that divergence is the visible symptom. The fix draws each -sloped stage through **the same `curveMap` the audio uses**, so trace and sound cannot -diverge; tessellation approach is the engineer's call. - -**Acceptance criteria.** -- **At every exponent the knot's centre lies on the trace, within 1 px** — the reported defect, - stated as the gate. -- **At exponent 1.0 the segment is visually identical to today's straight line.** -- No visible faceting at the widest segment the canvas can produce; a fixed low tessellation - count is not acceptable at full width. -- All three envelopes, both play modes, all sloped stages (attack/decay/release) — one paint - path, one fix. -- The established trace grammar is unchanged: one weight, `kEnvTracePx = 2.0`, through the - analytic stroker. Both overlay layout policies (AHDSR right-anchored schematic, AHD 1:1) - are honoured unchanged. The spline overlay's own trace is untouched. -- **Audio is unchanged** — this is a drawing defect only; a regression baseline proves it. +**Landed** — see `docs/COMPLETED.md` for the full narrative. Staged envelope segments now +draw as the curve their exponent defines, closing the defect where the mid-segment knot +floated off its own trace. A new pure module, `curve_tessellate`, draws every sloped stage +through the same curve the audio's evaluators use, so the drawn stage and the sound it makes +cannot diverge. All three envelopes, both play modes, every sloped stage, share the one fix; +audio is unchanged. #### Γ-W1-T4 — `editor-floor-and-row-law` -**Goal.** Commit the **canvas** — the window floor, the width budget it is derived from, and -the row every deck group belongs to — so every other UI track in the phase is drawn, tested and -judged at the final window size. The **arrangement** inside that canvas is Γ-W3-T1's. - -**Spec:** `docs/product/instrument-control-surface.md` §1.1 (the two categories), §1.2 (the -floor arithmetic block), §1.6 (the headroom ledger), §7.1 and §7.4 (the two invalidated -`knob_deck.h` notes). - -**Surface boundary — owns:** `core/instrument/ui/sample_bands.h` (`kEditorMinWidth`), -`core/instrument/ui/knob_deck.h` (the declared budget constants and the two invalidated header -notes), `core/instrument/ui/deck_groups.{h,cpp}` (**the new row predicate only**), and the five -test fixtures that read the floor — `test_sample_bands.cpp`, `test_deck_groups.cpp`, -`test_knob_deck.cpp`, `test_sample_chrome.cpp`, `test_keyboard_strip.cpp`. **Does not own** -`layoutDeck` / `deckRowCount` / `deckHeight` behaviour, the justification law, any descriptor, -MASTER's inventory or interior, any painter, or any parameter. It changes **no drawing code at -all.** - -**Behavior — what it commits.** -- **`kEditorMinWidth` 980 → 1190. `kEditorMinHeight` stays 680** (Γ-F1). -- **Three declared budget constants in `knob_deck.h`:** the row block both rows will justify - inside (**1020**), the right-anchored spanning deck's reserved width (**MASTER 142**), and - the ceiling (**1280**). These are *declarations of budget*, not measurements — nothing - computes them from a descriptor, and Γ-W3-T1's job is to prove its content fits inside them. -- **The floor is derived, not asserted as a literal.** `1020 + kDeckGroupGap(12) + 142 + - 2·kPad(8) = 1190`. `kEditorMinWidth` stays a literal in `sample_bands.h` — **do not add an - include edge from `sample_bands` to `knob_deck`**, which would invert the allocator's - deliberate independence from the deck (it takes `deckHeight` as a *parameter* for exactly - that reason). The identity is asserted in `test_deck_groups.cpp`, which already includes - both headers. This is the Θ-W6-T1 derived-floor precedent, landed once and never rewritten. -- **Row membership becomes a property of the group id:** `DeckRow { Sound, Contour, Spanning }` - + `deckRowFor(DeckGroupId)` in `deck_groups`, an **exhaustive switch** on the - `isLiveDeckParam` discipline, so a future group is a compile error rather than a silent - default. Partition: **Sound** = PITCH/RATE, FILTER, VELOCITY, VOICE; **Contour** = PITCH ENV, - FILTER ENV, AMP ENVELOPE; **Spanning** = MASTER. **Nothing consumes it until Γ-W3-T1** — that - is the seam, and it is why the predicate is safe to land now: **membership is a property of - the group, width is a property of the descriptor**, and only the widths are still moving. -- **Γ adds no new deck group**, so no later track amends this predicate. - -**The seam, stated as what this track can and cannot assert.** - -*Can assert today:* -- The derived floor identity above, and `kEditorMinWidth ≤ 1280` with **90 px** of headroom. -- `kEditorMinHeight == 680`, asserted so no later track drifts Γ-F1's ruling. -- `deckRowFor` is total over `DeckGroupId` and yields exactly the partition above. -- **Row 2's natural width already fits the block, in both play modes:** - 252 + 312 + 312 = **876 ≤ 1020**, leaving both its gutters ≥ `kDeckGroupGap`. Mode-stable - because FILTER ENV's and AMP's reserve slots hold them at 312 in Gate and Trigger alike. -- **MASTER's reserve is not yet spent:** `deckGroupWidth(MASTER) == 72 ≤ 142`. -- At the floor, deck band **216** and waveform band **358** — the reflow's 112 px arrives here, - two waves early (see the interim layout below). - -*Cannot assert yet, and must not force:* -- **Row 1's natural width does not fit the block.** Today it is PITCH 150 + FILTER 524 + - VELOCITY 192 + VOICE 164 = **1030**, against the 1020 block. The 50 px deficit is exactly - what the two descriptor changes buy: PITCH → PITCH/RATE **+42** (Γ-W2-T1) and FILTER's - `Band|Notch` moving to the caption corner **−92** (Γ-W3-T1), netting **980**. Record the - target and the two contributions as a test comment; **assert the fit in Γ-W3-T1, and do not - pre-empt either descriptor change to close it early.** -- Gutter distribution, the filter tie-line at x = 636, flush outer edges, MASTER's interior and - its meter — all Γ-W3-T1. Every one of them measures a descriptor that does not exist yet. - -**The interim editor, stated exactly so it is not filed as a defect.** At the new floor, -`availWidth = 1190 − 2·kPad = 1174`, and the **unchanged** greedy whole-group wrap packs: - -``` -row 1 PITCH 150 · PITCH ENV 252 · FILTER 524 = 950 used, 224 px ragged right -row 2 FILTER ENV 312 · AMP 312 · VELOCITY 192 · - VOICE 164 · MASTER 72 = 1100 used, 74 px ragged right -``` - -**Two rows, not three** — so the deck band is already 216 and the waveform already draws at its -final 358 px, in both Gate and Trigger. After Γ-W2-T1 lands PITCH/RATE the pack is row 1 = 992, -row 2 unchanged; still two rows. The composition is wrong in exactly the way the reflow exists -to fix — PITCH ENV sits up with the sound decks, VOICE and MASTER sit down with the envelopes, -MASTER is still a single-height 72 px box, and both rows are left-packed with dead space at the -right. **Worse than today in composition, better in proportion.** That is the accepted -transitional state for the rest of the phase. - -**Do not convert the two-row interim into a claim.** It is a coincidence of the greedy wrap at -exactly this width, not a guarantee — which is precisely why Γ-W3-T1's criterion is "two rows -**by construction**, asserted against the group inventory, not observed as a wrap outcome." -`testDeckFitsInsideTheEnforcedMinimumWindow` currently asserts `deckRowCount == 3`; relax it to -an **upper bound** (`<= 2`), which is a real regression canary throughout the interim and is -subsumed by Γ-W3-T1's exact claim. An exact `== 2` here is acceptable only with a comment -naming it as a wrap outcome the reflow replaces. - -**Acceptance criteria.** -- **The floor is 1190 × 680, reached by a derived test over the three budget constants**, not - by a literal — and the derivation is the one Γ-W3-T1 later reads rather than a second copy. -- **Headroom is exactly 90 px** against the 1280 ceiling, asserted. -- `deckRowFor` is exhaustive over `DeckGroupId`; adding a group without classifying it fails to - compile. -- Row 2's natural width and MASTER's unspent reserve are asserted, in **both** play modes. -- **All five floor-reading test fixtures pass at the new floor** — including the chrome row, - whose title slot gets *more* room at 1190, not less. -- **No drawing code changes, no descriptor changes, no parameter changes, no audio change.** - A regression baseline proves the last of those trivially. -- The two invalidated `knob_deck.h` notes (§7.1's fourteen-pixel headroom figure, §7.4's - cells-and-floor pairing) are **re-derived against the new floor, not deleted** — §7.4's - restatement is *the deck's cell metrics AND its group/row composition both drive - `kEditorMinWidth`; none of the three may move alone.* - -**Open questions.** **No [Daniel] questions.** **[propose at review]** whether the three budget -constants belong in `knob_deck.h` (the deck owns the row block and the spanning-deck reserve) -or in `sample_bands.h` (the allocator owns the floor they derive). The plan's lean is -`knob_deck.h` with the identity in the test, because it adds no include edge; either is -defensible, but the *derivation must live in exactly one place*. +**Landed** — see `docs/COMPLETED.md` for the full narrative. Commits the editor's canvas +ahead of the rest of the phase's UI work: `kEditorMinWidth` moves 980 → 1190 +(`kEditorMinHeight` stays 680, Γ-F1), derived from three budget constants — the row block +(1020), MASTER's reserved width (142), and the 1280 ceiling (`kEditorCeilingWidth`, relocated +into `sample_bands.h`) — leaving 90 px of headroom. Row membership becomes a property of the +group id via an exhaustive `deckRowFor(DeckGroupId)` switch (Sound / Contour / Spanning), +consumed by no one yet — **that consumption, and the fit inside the 1020 block, is +Γ-W3-T1's** to assert. No drawing code, descriptor, parameter, or audio changed in this +track. #### Γ-W1-T5 — `preserve-time-stretch` -**Goal.** A real pitch-preserving time-stretcher for Preserve mode, written from established -state-of-the-art literature — landed **before** the control that drives it, so Rate ships onto a -finished engine rather than onto a disposable stand-in. - -**Spec:** `docs/product/instrument-control-surface.md` §2.5. - -**Moved from Γ-W4-T1 (Daniel, 2026-08-01).** It is the longest pole in the phase and has zero -dependency on any UI work. **The consequence is the interesting one: it inverts the -relationship with Rate.** Under the old order the stretcher was Rate's quality upgrade and -Γ-W2-T1 shipped an interim resample-and-cancel path to make Rate complete on day one; under -this order the stretcher is Rate's **prerequisite** and **the interim path is not built at -all.** Skipping a stand-in that was only ever going to be deleted is the win; see Γ-W2-T1's -named contingency for what happens if this track's gate slips. - -**Precedent for landing a DSP module ahead of its consumer:** Θ-W1-T3 (`filter-dsp-port`) -landed the filter DSP as a standalone pure module a wave before Θ-W2-T1 wired it into the voice -path, for the same reason — the unknown is the DSP, not the wiring. - -**Surface boundary — owns:** `core/instrument/engine/pitch_shift` and whatever new pure module -the stretcher needs (each with its own `_tests` target), plus `voice.{h,cpp}`'s Preserve -read path. **Does not own** any parameter, any UI, the varispeed path, or the deck. It adds no -`ComponentState` field and takes **no rung of the payload ladder**. - -**Behavior and constraints.** The algorithm is **the engineer's call under a -measure-and-report gate — this plan deliberately names none.** The constraints: -- **The stretch ratio is an argument, not a parameter.** Nothing publishes a non-unity ratio - until Γ-W2-T1's Rate knob does. Until then the Preserve read path runs at ratio 1.0 and must - be **bit-identical to the shipped Preserve read** — a stronger and cheaper regression gate - than the old plan's A/B-against-an-interim-path, because the baseline is a build that exists. -- **CPU stance (Daniel, verbatim intent):** *"we should be efficient but accept the cost of - high-quality algorithm choices. It's 2026, most people's computers can handle audio with - ease. Just don't be wasteful."* -- **RT-safe:** no allocation, no I/O, no lock in `process()`; buffers sized at voice - allocation or at the off-audio-thread reload, on `pitch_shift`'s existing pre-warm - precedent. -- **Per-voice state, holding up at the 32-voice ceiling.** The gate is 32 simultaneous - Preserve voices at 50 % and 200 %, not one voice at 100 %. -- **No new third-party dependency** (`pitch_shift`'s standing property). -- **No dispatch on the per-sample path** — concrete, inlineable types; no `IStretcher`. -- **Onset behaviour is a regression surface.** GA2 eliminated Preserve's ~25 ms onset latency - by priming the ring with the actual upcoming source. **A stretcher that reintroduces an - onset delay or a first-frame smear is a regression, not a trade-off.** - -**Acceptance criteria.** -- **Ratio 1.0 with no shift is bit-identical to the shipped Preserve read**, asserted by a - regression baseline — the null case, and the criterion that makes landing this ahead of Rate - safe. -- Preserve speaks on frame 0 — no added onset latency, no first-frame smear, in any - ratio/shift combination. -- No audible metallic or phasey artefacting on sustained tonal material at ±6 st and - 75–133 % ratio; transient material at 50 % / 200 % is no worse smeared than **varispeed - playback at the equivalent ratio** — the honest "what does preserving pitch cost" reference, - and the one that needs **no disposable implementation built to serve the comparison.** -- The Gate sustain-loop contract is unchanged: **loop the source, shift the output** — loop - points remain source-frame facts. -- **Measure and report before the algorithm is final:** per-voice CPU at 32 voices, added - latency (must be zero at the onset), and A/B recordings on three material classes (one-shot, - tonal sustain, full-mix bounce). Report to Daniel; the choice is not final until he has heard - the A/Bs. - -**Open questions.** **[propose, with a measurement step]** the algorithm family itself. -**[verify]** that `core/instrument/CLAUDE.md`'s *"`WDL_Resampler` is not a Preserve engine — -never wire it as the duration-preserving path"* is honoured: under Preserve, Rate legitimately -changes duration, so a resampled read is an explicit duration control — but the -*pitch-preserving* mechanism must not be a resampler. **No [Daniel] questions.** +**Landed** — see `docs/COMPLETED.md` for the full narrative. A real pitch-preserving +time-stretcher for Preserve mode, moved up from a later wave (Daniel, 2026-08-01) so Rate +ships onto a finished engine instead of a disposable stand-in — the interim +resample-and-cancel path that had been planned for Γ-W2-T1 was not built at all. New +header-only pure module `time_stretch` alongside `pitch_shift`'s existing shift-ratio +control; rate 1.0 is exactly one source frame per output frame with no residue, keeping the +unity-ratio Preserve read bit-identical to the pre-stretch engine. No new third-party +dependency, no allocation/lock in `process()`, no per-sample dispatch. --- #### Γ-W1-T7 — `psola-preserve` -**Goal.** Preserve's splices become pitch-synchronous: the source's fundamental period is -detected offline at sample load, cached, and the splice jump becomes a whole number of that -period, so an aligned landing point exists by construction rather than being searched for. +**Landed** — see `docs/COMPLETED.md` for the full narrative, including the corrected closure +status below. Preserve's splices become pitch-synchronous: a new pure module, +`core/instrument/engine/period_detect` (two-pass YIN), estimates the source's fundamental +period once at load; `pitch_shift`'s splice jump becomes the multiple of that period nearest +the fixed window, so an aligned landing point exists by construction. Detection runs off the +audio thread by link graph — `sampler_core` does not link `period_detect` — and an unknown +period restores the fixed-window geometry byte for byte. A period is derived from the audio +at load, so it is cache, not state: no `ComponentState` field, no payload rung. It gates the +Rate control (Γ-W2-T1) on the plan's own stated principle that Rate must not ship before its +Preserve engine. -**Origin — Daniel's ruling, 2026-08-01, after Γ-W1-T5 landed.** T5's measurement pass showed -the shipped fixed 50 ms OLA window with a ±window/4 correlation search could only reach -landings spanning `[¾w, 1¼w]` — a 1.5:1 span that cannot contain a whole number of periods for -low material. A 30 Hz tone (1470 frames at 44.1 kHz) had no phase-aligned landing at all. A -second, distinct failure mode: splices recur every `window/|rate − shift|` frames and fail -when that interval is shorter than the output period. Three cheaper options were offered and -declined: widening `maxLag` to `window/2` (fixes the geometry, not the cadence); sizing the -window from the note's known fundamental at note-on (fixes both, but trusts root-note -tagging); enlarging the window to ~200 ms (fixes both, smears transients — the OLA crossfade -is `window/4`). - -**Why this is a new track and not an amendment to Γ-W1-T5.** It touches the sample-load / -analysis path, which is outside T5's stated surface boundary (`pitch_shift`, the stretcher -module, `voice`'s Preserve read path). - -**Why PSOLA is affordable here.** ReaSampler is a sampler, so the source is fixed and fully -known at load. Detection runs once during the reload that already happens, entirely off the -audio thread — the usual real-time objection to PSOLA does not apply. - -**Surface boundary — owns:** a new pure module `core/instrument/engine/period_detect` -(two-pass YIN, with its own `period_detect_tests` target), `pitch_shift`'s jump geometry, the -load-time hook in `map/sample_map`'s `buildSampleData`, and `voice`'s note-on. Adds no -`ComponentState` field and takes **no rung of the payload ladder** — a detected period is -derived from the audio, so it is cache, not state. - -**Behavior and constraints.** -- **Enforcement is by link graph, not by convention.** The detector runs off the audio thread - because `sampler_core` does not link `period_detect` — no translation unit on the render path - can name `detectPeriod`. An unknown period restores the fixed-window geometry byte for byte. -- **Dependency it discharges:** it gates the Rate control (Γ-W2-T1) on the plan's own stated - principle that Rate must not ship before its Preserve engine. - -**Status — landed, with open findings.** -- The **geometry** failure mode is closed and asserted. -- The **cadence** failure mode is **not** closed. It was re-characterized rather than fixed: - the previously-headlined 7–21 % artifact-energy readings turned out to be ~95 % the - measurement's own spectral leakage, leaving a real excess of 0.23–0.51 %. The track asserted - no-regression there rather than claiming an improvement. - -**Open questions.** -- **Unresolved review findings, not a design fork.** A later review of the follow-up fold - returned unresolved Major findings; remediation has not yet been dispatched. The - findings themselves live in the review, not here. +**Both failure modes this track set out to close are now closed.** The **geometry** failure +mode (no phase-aligned landing existing inside the search window for low material) closed at +the original merge. The **cadence** failure mode — splices recurring faster than the output +period — was left open at that point, with unresolved review findings from a later review of +a follow-up fold; three remediation commits have since landed and a re-review confirmed the +earlier findings closed. The closing measurement (one-machine, Debug-build) is recorded in +`docs/COMPLETED.md`, not restated here. --- @@ -4354,13 +3848,17 @@ Phase Ξ — The resample loop (W1 concurrency-safe with Θ from Θ-W T1 capture-signal-popup .................... 15 (popup abandoned; window derives) Phase Γ — The instrument's control surface (none of the seventeen; ends with VST3 params) - W1 Foundations [5 tracks, disjoint by surface] + W1 Foundations — landed [seven tracks, disjoint by surface] T1 knob-interaction-law ....... modifiers + ONE taper module + reset bypass + 10 s ceiling + AHDSR schematic axis [Ruling 2] T2 master-bus-audio ........... limiter + meter ballistics + dynamic PDC [rung 1] T3 contour-trace-curves ....... staged traces draw curved, knot on its trace T4 editor-floor-and-row-law ... floor 1190x680 + budget constants + row predicate T5 preserve-time-stretch ...... real stretcher [measure-and-report gate] + T6 exhaustive-switch gate on pure libraries ... /we4062, -Werror=switch on + pure libraries [no PLAN entry — see COMPLETED.md] + T7 psola-preserve ............. PSOLA-aligned splice jump; period_detect + [cadence closure — see COMPLETED.md] W2 New controls, and the overlay's marks [2 tracks] T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [rung 2] T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard From 1b4d0e67b735f55bd9171783f78dd2776b77e984 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 05:50:38 -0400 Subject: [PATCH 33/56] Loop-crossfade-ux review fixes: parked-drag no longer fakes LOOP OFF, waveform label contrast fixed, hover memoizes its bank read Also corrects the cap-area, em-dash, glyph-overhang and heuristic-comment findings noted in review. --- docs/TODO.md | 2 +- src/core/instrument/ui/loop_marks.cpp | 12 +++++- src/core/ui/theme.h | 8 ++++ .../instrument/editor_input_waveform.cpp | 11 +++--- .../instrument/editor_paint_waveform.cpp | 39 +++++++++++++++---- src/shell/instrument/editor_session.cpp | 24 +++++++++--- src/shell/instrument/reasampler_editor.h | 15 +++++-- tests/test_loop_marks.cpp | 16 ++++++++ tests/test_sample_chrome.cpp | 5 ++- tests/test_theme.cpp | 20 ++++++++++ 10 files changed, 125 insertions(+), 27 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index a36407c..acc9f6f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -172,7 +172,7 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r **The wart.** A zero-attack `AttackEnd` vertex is drawn at the same pixel as `Origin` (the envelope's non-draggable start anchor), which for an AHD envelope sits at the start marker's frame. Because a node's nominal pick-box area is smaller than the marker's full-height grab-column area, and `resolveWaveformClaim`'s rule is "smallest area among hit candidates wins," the draggable `AttackEnd` node still claims the click over the start marker when the two coincide — and, at a loop starting there, over the crossfade tab. Folding the staged pass into the shared arbitration slot did not change this specific outcome, since the rule that decides node-vs-marker priority is unchanged from what the contour-node fix established. `Origin` itself is excluded from `nodeAtPoint`'s candidate set entirely (never draggable, never a hit), so the common case — attack > 0, no coincidence — is unaffected. -**RESOLVED — Γ-W2-T2 (`loop-crossfade-ux`), incidentally.** Giving every mark the cap-grip the crossfade already had is what closed it: the start marker now carries an 11x10 cap in the overlay's top strip, whose nominal area (110) is smaller than the node's fixed pick box (169), so the cap wins the coincident pixel and the marker is reachable again. No priority rule was added and `resolveWaveformClaim` is byte-for-byte unchanged — every cap is one `markerHandleRect`, so the cap slot's nominal area did not move and the `cap < node < column` ordering still holds. Below the cap strip the node keeps the click, which is correct: that is where the node is actually drawn for any non-degenerate envelope. Pinned by `testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip` (`tests/test_spline_edit.cpp`). `Origin` was not touched and `isDraggable`'s shape rules are unchanged. +**RESOLVED — Γ-W2-T2 (`loop-crossfade-ux`), incidentally.** Giving every mark the cap-grip the crossfade already had is what closed it: the start marker now carries an 11x10 cap in the overlay's top strip, whose nominal area (110) is smaller than the node's fixed pick box (169), so the cap wins the coincident pixel and the marker is reachable again. No priority rule was added and `resolveWaveformClaim` is byte-for-byte unchanged — but the cap slot's own nominal area DID move, from the old clipped-actual measure (60 at frame 0) to the new nominal 110 every cap now feeds it (`markerHandleRect`'s own unclipped area). That move leaves the `cap < node < column` ordering unchanged only because 110 is still under the node's fixed 169 — the outcome held, not the area. Below the cap strip the node keeps the click, which is correct: that is where the node is actually drawn for any non-degenerate envelope. Pinned by `testAMarkCapOutranksACoincidentEnvelopeNodeInTheTopStrip` (`tests/test_spline_edit.cpp`). `Origin` was not touched and `isDraggable`'s shape rules are unchanged. ## Active-bank indicator placement (B4 polish) diff --git a/src/core/instrument/ui/loop_marks.cpp b/src/core/instrument/ui/loop_marks.cpp index 82cc51a..5c4f6af 100644 --- a/src/core/instrument/ui/loop_marks.cpp +++ b/src/core/instrument/ui/loop_marks.cpp @@ -45,8 +45,16 @@ LoopWrite applyLoopMarks(const LoopMarks& m) { LoopWrite w; const bool spanAlive = m.loopEnd > m.loopStart; w.loop.hasLoop = m.hasLoop && spanAlive; - w.loop.start = m.loopStart; - w.loop.end = m.loopEnd; + if (m.parked && !m.hasLoop) { + // A parked pair (never set) must round-trip back to parked, not to a real "LOOP OFF" + // span — resolveLoopMarks only re-parks a span spanUsable would refuse, so collapse it + // deliberately rather than writing back the default bounds' own alive span. + w.loop.start = m.loopStart; + w.loop.end = m.loopStart; + } else { + w.loop.start = m.loopStart; + w.loop.end = m.loopEnd; + } w.crossfade = (spanAlive && m.crossfade > 0) ? m.crossfade : 0; w.start = m.start; return w; diff --git a/src/core/ui/theme.h b/src/core/ui/theme.h index ab47446..ff53310 100644 --- a/src/core/ui/theme.h +++ b/src/core/ui/theme.h @@ -104,6 +104,14 @@ inline constexpr double kLoopSpanFillAlpha = 0.20; // body floor Font::Micro answers to. inline constexpr double kCardNameScrimAlpha = 0.75; +// The waveform mark caption/label scrim: bg/base composited at this alpha UNDER the loop-state +// caption and the four mark labels, so text/dim (Font::Micro, body class) stays readable when +// the envelope's accent/primary peak reaches into the label's rect. Higher than +// kCardNameScrimAlpha because text/dim needs more cover than text/primary to clear the same +// 4.5:1 body floor against the same lime worst case — test_theme.cpp composes this exact value +// against accent/primary to pin the floor both roles answer to. +inline constexpr double kWaveformLabelScrimAlpha = 0.90; + // Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha // is ignored — a translucent overlay's effective color is the caller's to compose first // (compositeOver). diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index c3a4c3a..2cb5130 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -64,11 +64,12 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { // keeps a cap apart from ITS OWN column; this is the cross-affordance case on top of that). // resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures each claimant's // own NOMINAL target area and lets the smallest hit win, since a fixed check order shadows - // whichever one loses the tie — this seam regressed twice from exactly that fix. Giving - // every mark a cap changed WHICH mark the cap slot resolves to, not the slot's nominal area - // (every cap is one markerHandleRect) and not the ordering cap < node < column, so the - // arbitration itself is unchanged. Never add here (kAdd is only tried once nothing else has - // claimed the click, below). + // whichever one loses the tie — this seam regressed twice from exactly that fix. Giving every + // mark a cap changed WHICH mark the cap slot resolves to AND the slot's own nominal area + // (every cap is one markerHandleRect, so it moved from the old clipped-actual measure — 60 at + // frame 0 — to the nominal 110); the `cap < node < column` ordering held anyway, because 110 + // is still under the node's fixed pick-box area. Never add here (kAdd is only tried once + // nothing else has claimed the click, below). WaveformClaim node; if (envNodeHit.hit) { constexpr std::int64_t side = 2 * kNodeGrabRadius + 1; diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index c398fb7..0038702 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -84,10 +84,11 @@ const char* markLabel(WaveMark m) { return ""; } -// Font::Micro is proportional, so this is a generous per-character estimate: the label box may -// end up wider than the glyphs, never narrower — an under-estimate would let the suppression -// rule place two boxes that visibly collide. -constexpr int kMicroCharPx = 6; +// Font::Micro is proportional, so this is a per-character estimate, not a measured font metric — +// an under-estimate doesn't produce a visible collision (the kit's own DT_END_ELLIPSIS clips the +// box first), it produces a silently TRUNCATED label ("LOO…"). Matches kTooltipCharPx +// (panel_state.h), the codebase's other unmeasured Segoe UI estimate. +constexpr int kMicroCharPx = 7; int markLabelWidth(WaveMark m) { int n = 0; for (const char* s = markLabel(m); *s; ++s) ++n; @@ -101,7 +102,11 @@ void drawMarkCap(LICE_IBitmap* bmp, WaveMark which, const Rect& cap, int mx, LIC if (cap.empty()) return; const int top = cap.y; const int bot = cap.bottom(); - const int arm = kMarkerHandleHalfWidth; // the cap's own half-width, so glyph == grip + // The cap's own half-width, so glyph == grip — true for LOOP/END/XFADE. START is the + // exception: its triangle tip (mx - 1 + arm + 2, below) reaches past markerHandleRect's own + // right edge rather than sitting inside it; not yet corrected, since narrowing the tip is a + // visual change past this fix's scope. + const int arm = kMarkerHandleHalfWidth; switch (which) { case WaveMark::kStart: // A play flag: it points into the material that will play. @@ -153,6 +158,16 @@ void drawCrossfadeWedge(LICE_IBitmap* bmp, const OverlayArea& overlay, std::int6 } } } + +// Backing for the state caption and the four mark labels: text/dim and text/primary both read +// under floor against a full-scale envelope peak (accent/primary) with no backing at all — see +// kWaveformLabelScrimAlpha's own note. Sized to the box the caller already resolved, never the +// whole band, so this only darkens the text's own row. +void scrimLabelBox(LICE_IBitmap* bmp, const Rect& box) { + if (box.empty()) return; + LICE_FillRect(bmp, box.x, box.y, box.width, box.height, toLice(roleColor(Role::BgBase)), + static_cast(kWaveformLabelScrimAlpha), 0); +} } // namespace void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band, @@ -236,11 +251,17 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band, // The state caption, centred in the span: the two OFF states say different things because // they mean different things, and Trigger's refusal names its own reason. const char* caption = nullptr; - if (!loopLive) caption = "LOOP - GATE ONLY"; + if (!loopLive) caption = "LOOP \xe2\x80\x94 GATE ONLY"; // "LOOP — GATE ONLY" (em dash, UTF-8) else if (!m.hasLoop) caption = m.parked ? "DRAG TO SET LOOP" : "LOOP OFF"; if (caption != nullptr && rx > lx) { - kitTextCentered(bmp, Rect::ltrb(lx, overlayRect.y, rx, overlayRect.bottom()), caption, - Font::Micro, Role::TextDim); + // Tight box (kMarkLabelHeight, not the whole overlay) centered on the same midline the + // full-height rect already centered DT_VCENTER text on, so the scrim darkens only the + // caption's own row. + const int capH = kMarkLabelHeight; + const int capY = overlayRect.y + (overlayRect.height - capH) / 2; + const Rect capBox = Rect::ltrb(lx, capY, rx, capY + capH); + scrimLabelBox(bmp, capBox); + kitTextCentered(bmp, capBox, caption, Font::Micro, Role::TextDim); } // Labels beneath the trace and the handles in z-order; the promoted one is re-drawn ON TOP @@ -254,6 +275,7 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band, const WaveMarkLabels labels = layoutMarkLabels(overlay, frames, marks, labelW, promoted); for (int i = 0; i < kWaveMarkCount; ++i) { if (i == promoted || labels.box[i].empty()) continue; + scrimLabelBox(bmp, labels.box[i]); kitTextCentered(bmp, labels.box[i], markLabel(static_cast(i)), Font::Micro, Role::TextDim); } @@ -285,6 +307,7 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band, paintEnvelopeOverlay(bmp, overlay, frames); if (promoted >= 0 && promoted < kWaveMarkCount && !labels.box[promoted].empty()) { + scrimLabelBox(bmp, labels.box[promoted]); kitTextCentered(bmp, labels.box[promoted], markLabel(static_cast(promoted)), Font::Micro, Role::TextPrimary); } diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 29179b2..3a40299 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -55,6 +55,7 @@ void ReaSamplerEditor::refreshFromBank() { thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks pcmCache_.clear(); // and its decoded PCM (the waveform + snap source) holdNeedValid_ = false; // …and the bake-Hold answer derived from the bank's loop intrinsic + marksValid_ = false; // …and pickedMarkers' own memo of the same intrinsic channelPcmId_.clear(); channelPcm_ = ChannelPcm{}; if (!processor_) { @@ -215,6 +216,16 @@ void ReaSamplerEditor::loadSelection(const std::string& id) { } ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { + // Answer once per distinct input; refreshFromBank drops the memo along with holdNeed's, + // which answers the same "is there a bank read to pay" question for the bake-Hold predicate + // — hoverWaveform calls this on every WM_MOUSEMOVE inside the band, not just marker grabs, + // so the bridge read + JSON parse below is not a cost to pay per pixel of mouse travel. Frames + // is not part of the key: it is a function of selectedId_ alone (monoPcmFor's own cache), + // busted by the same refreshFromBank that busts this memo. + const HoldNeedKey key{selectedId_, params_.loopOverride, params_.loopCrossfadeFrames, + params_.startPoint}; + if (marksValid_ && key == marksKey_) return marksCache_; + instrument::ui::StoredLoop stored; stored.override_ = params_.loopOverride; stored.crossfade = params_.loopCrossfadeFrames; @@ -222,9 +233,7 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram // Read the loop intrinsic from the live bank blob (the same path selectSample uses); when // that is not readable (extension absent / not yet parsed) the instance-owned ref carries // the same intrinsics. Skipped entirely once an override is already set — the resolve would - // discard it, and the bridge read + JSON parse it costs is real (mouseDownWaveform's - // arbitration calls this on every waveform click, not just marker grabs, to know whether a - // cap or column candidate hits at all). + // discard it, and the bridge read + JSON parse it costs is real. if (processor_ && !params_.loopOverride) { std::optional sel; auto banksJson = @@ -236,11 +245,16 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram } if (sel) stored.intrinsic = sel->loop; } - return instrument::ui::resolveLoopMarks(stored, frames); + const SetupMarkers m = instrument::ui::resolveLoopMarks(stored, frames); + marksKey_ = key; + marksCache_ = m; + marksValid_ = true; + return m; } bool ReaSamplerEditor::HoldNeedKey::operator==(const HoldNeedKey& o) const { - if (sampleId != o.sampleId || crossfade != o.crossfade) return false; + if (sampleId != o.sampleId || crossfade != o.crossfade || startPoint != o.startPoint) + return false; if (loopOverride.has_value() != o.loopOverride.has_value()) return false; if (!loopOverride) return true; return loopOverride->hasLoop == o.loopOverride->hasLoop && diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index a9cee85..08c883a 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -337,9 +337,10 @@ private: // instance's own SampleRefs as the self-contained fallback. "" when unresolvable. std::string samplePathFor(const std::string& sampleId) const; - // The effective loop + start markers for the loaded capture. The state machine behind them - // — which stored source wins, when the pair re-parks, what the two OFF states mean — is the - // pure loop_marks module's; this only reads the bank intrinsic it cannot see. + // The effective loop + start markers for the loaded capture; the state machine behind them + // is the pure loop_marks module's. With no override set this costs a bridge read plus a bank + // parse — hoverWaveform calls it on every WM_MOUSEMOVE, so the answer is memoized (see + // HoldNeedKey below, which this shares) rather than paid per move. SetupMarkers pickedMarkers(std::int64_t frames) const; // Whether the loop controls answer at all: the sustain loop is Gate-only, so in Trigger the @@ -365,16 +366,22 @@ private: bool resolveBakeHoldNeeded(); // What that answer was last computed against. Invalidated wholesale by refreshFromBank, - // which is where the bank half of the input changes. + // which is where the bank half of the input changes. pickedMarkers' own memo (below) shares + // this key shape rather than declaring a second one; resolveBakeHoldNeeded leaves startPoint + // at its default (nullopt) since its own answer doesn't depend on it. struct HoldNeedKey { std::string sampleId; std::optional loopOverride; std::int64_t crossfade = 0; + std::optional startPoint; bool operator==(const HoldNeedKey& other) const; }; HoldNeedKey holdNeedKey_; bool holdNeedValid_ = false; bool holdNeedAnswer_ = false; + mutable HoldNeedKey marksKey_; + mutable SetupMarkers marksCache_; + mutable bool marksValid_ = false; // Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload — // callers decide live-drag vs final commit. diff --git a/tests/test_loop_marks.cpp b/tests/test_loop_marks.cpp index 9d37b20..ae63db9 100644 --- a/tests/test_loop_marks.cpp +++ b/tests/test_loop_marks.cpp @@ -176,6 +176,21 @@ static void testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone() CHECK(w.start == 512); } +// Dragging the START marker while parked (never set) must not turn "DRAG TO SET LOOP" into +// "LOOP OFF" — the ordinary gesture that exposed the bug, distinct from the retained-span case +// above. +static void testEditingTheStartMarkerWhileParkedStaysParked() { + LoopMarks m = resolveLoopMarks(StoredLoop{}, kFrames); + CHECK(m.parked && !m.hasLoop); + m.start = 512; + const LoopMarks after = writeThenRead(m); + CHECK(!after.hasLoop); + CHECK(after.parked); + const LoopBounds d = defaultLoopBounds(kFrames); + CHECK(after.loopStart == d.start && after.loopEnd == d.end); + CHECK(after.start == 512); +} + static void testANegativeCrossfadeNeverReachesTheStore() { LoopMarks m = resolveLoopMarks(storedSpan(true, 4000, 6000, 0), kFrames); m.crossfade = -1; @@ -197,6 +212,7 @@ int main() { testDraggingALoopMarkWhileOffTurnsItOn(); testEditingTheStartMarkerWhileOffLeavesTheEnableAndCrossfadeAlone(); + testEditingTheStartMarkerWhileParkedStaysParked(); testANegativeCrossfadeNeverReachesTheStore(); if (g_fail == 0) { diff --git a/tests/test_sample_chrome.cpp b/tests/test_sample_chrome.cpp index 331c347..050195c 100644 --- a/tests/test_sample_chrome.cpp +++ b/tests/test_sample_chrome.cpp @@ -157,8 +157,9 @@ static void testHoldCellIsReservedAndFollowsTheVelocityCellGrammar() { // the agreed remedy if it cannot is to narrow the enable's segments, never to move the floor. static void testTheControlRunLeavesTheTitleReadableAtTheEditorFloor() { const ChromeRects r = chromeRects(chromeBand(), kKnob); - // "ReaSampler 9000" (15 chars) plus a bracketed 20-char capture name, at the toolbar font's - // generous ~7 px/char estimate: 38 * 7. + // "ReaSampler 9000" (15 chars) plus a bracketed 20-char capture name, at an assumed 7 px/char + // for the toolbar font: 38 * 7. A HEURISTIC, not a measured Segoe UI metric — no font-metric + // measurement backs this number; it gates a phase-wide acceptance criterion regardless. constexpr int kTitleTextFloorPx = 266; CHECK(r.title.width >= kTitleTextFloorPx); // Nothing in the run reaches into the title's slot. diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 799f570..1de6897 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -269,6 +269,25 @@ static void testCardNameScrimClearsBodyFloorOnItsWorstBackground() { < textFloor(TextClass::Body)); } +// The waveform band's state caption and its four mark labels (editor_paint_waveform.cpp) draw +// text/dim (the caption, the non-promoted labels) and text/primary (the promoted label) — both +// Font::Micro, both body class — over a bg/base scrim at kWaveformLabelScrimAlpha, itself drawn +// over whatever drawEnvelope left in that rect. Unlike the card name strip, the worst case here +// is text/DIM, not text/primary, so the alpha is higher than kCardNameScrimAlpha's — pin both +// roles against the worst background (a full-scale envelope peak, accent/primary) and pin the +// defect the scrim exists to close. +static void testWaveformLabelScrimClearsBodyFloorOnItsWorstBackground() { + const KitColor scrim = roleColor(Role::BgBase); + const KitColor onFill = compositeOver(scrim, roleColor(Role::AccentPrimary), + kWaveformLabelScrimAlpha); + CHECK(contrastRatio(roleColor(Role::TextDim), onFill) >= textFloor(TextClass::Body)); + CHECK(contrastRatio(roleColor(Role::TextPrimary), onFill) >= textFloor(TextClass::Body)); + // Without the scrim, text/dim on the bare accent-lime fill is UNDER floor (~1.58:1) — pins + // the defect the scrim exists to close, so a future removal of the scrim fails this first. + CHECK(contrastRatio(roleColor(Role::TextDim), roleColor(Role::AccentPrimary)) + < textFloor(TextClass::Body)); +} + // --- Single point of change (structural guarantee) ---------------------------- // // roleColor is the ONLY color source; there is no other public accessor that yields a @@ -366,6 +385,7 @@ int main() { testTextOnPastelFillClearsBodyFloor(); testTextOnHoverSurfaceClearsFloor(); testCardNameScrimClearsBodyFloorOnItsWorstBackground(); + testWaveformLabelScrimClearsBodyFloorOnItsWorstBackground(); testCurveTraceOnHoverSurfaceClearsFloor(); testSecondaryTertiaryAreDistinguishable(); testOverlayTraceClearsIndicatorFloorOnTheWaveform(); From e2981e83ee3d54842f669fcdbd3b6dfa9eb2a383 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 07:21:19 -0400 Subject: [PATCH 34/56] Bake reset: assert the limiter and bake Hold land neutral, prove the loop returns parked, and baseline the render's identity path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resetAfterBake is unchanged — every value already resets by construction. The render prints master gain but not the limiter, so §3.4's rationale is wrong; the invariant is corrected. --- src/core/instrument/bake/CLAUDE.md | 5 +- src/core/instrument/bake/CMakeLists.txt | 4 +- src/core/instrument/bake/bake_reset.h | 4 + tests/test_bake_render.cpp | 36 +++++ tests/test_bake_reset.cpp | 205 +++++++++++++++++++++--- 5 files changed, 225 insertions(+), 29 deletions(-) diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 6c3246a..6f25a7e 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -21,8 +21,9 @@ decision about what the render made obsolete. loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but even a pathological envelope cannot run past the window. -- **The whole signal chain is printed, master gain included** — the gain multiply in - `bake_render.cpp` carries the argument for why. +- **The voice chain and master gain are printed; the limiter is not.** The gain multiply in + `bake_render.cpp` carries the argument for the gain, and `bake_reset.h` records where the + printed master stage stops. - **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses a collapsed window, a non-positive rate, a window that rounds to no frames, and one past `kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the diff --git a/src/core/instrument/bake/CMakeLists.txt b/src/core/instrument/bake/CMakeLists.txt index cddcf58..7a94710 100644 --- a/src/core/instrument/bake/CMakeLists.txt +++ b/src/core/instrument/bake/CMakeLists.txt @@ -17,4 +17,6 @@ reasampler_test(bake_window LINK bake_plan bake_render) # sample_map carries InstrumentParams, which is the whole of what a reset rewrites. reasampler_pure_library(bake_reset SOURCES bake_reset.cpp LINK PUBLIC sample_map) -reasampler_test(bake_reset LINK bake_reset) +# loop_marks is a TEST-only edge: it defines what a neutral loop looks like on the band, so +# the reset's loop assertions read it rather than restating it. +reasampler_test(bake_reset LINK bake_reset loop_marks) diff --git a/src/core/instrument/bake/bake_reset.h b/src/core/instrument/bake/bake_reset.h index 289db16..df268f6 100644 --- a/src/core/instrument/bake/bake_reset.h +++ b/src/core/instrument/bake/bake_reset.h @@ -13,6 +13,10 @@ namespace reasampler::instrument::bake { // The two surfaces a bake resets. Master gain lives on the processor rather than in the // parameter set; it is answered here because renderBake prints it into the file (see // bake_render.cpp's gain multiply) rather than left to the shell. +// +// Gain is the ONLY master-stage control the render prints — the limiter runs in the +// processor's block, off the bake path — so "the bake prints the gain" does not generalize +// to the master stage as a whole, and cannot be used to classify anything else on it. struct BakeReset { map::InstrumentParams params; double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain diff --git a/tests/test_bake_render.cpp b/tests/test_bake_render.cpp index b06d493..5812064 100644 --- a/tests/test_bake_render.cpp +++ b/tests/test_bake_render.cpp @@ -231,6 +231,42 @@ int main() { CHECK(identical); } + // --- Regression baseline: the neutral render is the source, sample for sample -------- + // DERIVED rather than recorded, so it survives a compiler change: a Trigger voice at its + // own root under Varispeed, with the default flat amp and velocity shapes and no filter, + // reads at ratio exactly 1 — so every printed frame must BE its source frame. An added + // stage, a moved default, or a lost early-out anywhere in the chain moves a sample here. + { + SampleData s; + s.frames.resize(4000); + // A ramp, not DC: an off-by-one read or a reversed span is invisible in a constant. + for (std::size_t i = 0; i < s.frames.size(); ++i) + s.frames[i] = static_cast(i) / 4000.f - 0.5f; + s.sampleRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + + // Shorter than the play span, so the window closes before any note-end shaping. + const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); + const BakeAudio audio = renderBake(s, plan, kUnity); + CHECK(audio.channelCount == 1); + CHECK(audio.frameCount() == 1000); + + bool identity = audio.frameCount() == 1000; + for (std::size_t f = 0; identity && f < 1000; ++f) + identity = (audio.interleaved[f] == s.frames[f]); + CHECK(identity); + + // …and the gain rides that as an exact scalar, which is the only other thing the + // render is permitted to do to the signal. + const BakeAudio halved = renderBake(s, plan, 0.5); + bool scaled = halved.frameCount() == 1000; + for (std::size_t f = 0; scaled && f < 1000; ++f) + scaled = (halved.interleaved[f] == s.frames[f] * 0.5f); + CHECK(scaled); + } + // --- Refusals ----------------------------------------------------------------------- { SampleData empty; // nothing decoded diff --git a/tests/test_bake_reset.cpp b/tests/test_bake_reset.cpp index 2d14513..23d0f29 100644 --- a/tests/test_bake_reset.cpp +++ b/tests/test_bake_reset.cpp @@ -4,20 +4,34 @@ // Covers the ratified reset scope PER PARAMETER, in both directions: every control whose // effect the render printed comes back at its default, and every mapping fact comes back // untouched. Asserted field by field rather than by struct equality on purpose — a -// whole-struct compare would pass while silently resetting a survivor, or vice versa. +// whole-struct compare would pass while silently resetting a survivor, or vice versa. The +// sweep runs over TWO independently dialed fixtures that share only the survivors, which is +// what makes it a property of the survivors alone rather than of one input. #include "../src/core/instrument/bake/bake_reset.h" +// The loop's neutral is loop_marks' definition of it, not this test's reading of it: what a +// reset loop LOOKS like on the band is the thing worth asserting, and only that module says. +#include "../src/core/instrument/ui/loop_marks.h" + #include +#include using namespace reasampler; using namespace reasampler::instrument::bake; using reasampler::instrument::map::InstrumentParams; using reasampler::instrument::map::PlaySeconds; +using reasampler::instrument::ui::LoopMarks; +using reasampler::instrument::ui::StoredLoop; +using reasampler::instrument::ui::resolveLoopMarks; + +namespace note = reasampler::instrument::note; +namespace filter = reasampler::instrument::engine::filter; static int g_fail = 0; +static const char* g_ctx = ""; #define CHECK(cond) do { if(!(cond)) { \ - std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + std::printf("FAIL [%s] line %d: %s\n", g_ctx, __LINE__, #cond); ++g_fail; } } while(0) namespace { @@ -45,14 +59,18 @@ InstrumentParams dialed() { p.play.trigAhd.decaySeconds = 0.22; p.play.trigAhd.holdFraction = 0.33; p.play.trigAhd.attackCurve = 1.7; + p.play.trigAhd.decayCurve = 2.2; p.play.pitchEngine = PitchEngine::Varispeed; p.play.pitchEnv.enabled = true; p.play.pitchEnv.peakSemitones = -7.0; p.play.pitchEnv.shape.attackSeconds = 0.05; + p.play.pitchEnv.shape.decaySeconds = 0.15; p.play.pitchVelocityCurve = VelocityCurve::linear(); p.play.playRate = 0.5; p.play.pitchOffsetSemitones = -7.5; p.play.filter.enabled = true; + p.play.filter.settings = filter::FilterSettings{0.3f, 0.8f, 0.1f, 0.7f, + filter::MorphLaw::HighNotchLow}; p.play.filter.modAmount = -0.8; p.play.filter.velAmount = 0.6; p.play.filter.keyTrack = 1.5; @@ -62,7 +80,69 @@ InstrumentParams dialed() { p.play.ampSpline.mode = EnvMode::Spline; p.play.ampSpline.contour = VelocityCurve::linear(); p.play.pitchSpline.mode = EnvMode::Spline; + p.play.pitchSpline.contour = VelocityCurve::linear(); p.play.filterSpline.mode = EnvMode::Spline; + p.play.filterSpline.contour = VelocityCurve::linear(); + p.bakeHold = note::makeDivision(0, note::DivisionModifier::Dotted); + p.limiterEnabled = true; + return p; +} + +// A SECOND dialed instrument, agreeing with the first on the SURVIVORS and disagreeing on +// every other field — including play mode, which is a chosen neutral rather than a survivor. +// Both run the same neutral sweep: if the reset ever inverted into "copy the dialed set, then +// clear a blacklist", a non-survivor would come through and at most one fixture could still +// land on the defaults. A parameter added later is covered here by moving it in BOTH +// fixtures, which is the same work as adding it to the sweep. +InstrumentParams dialedOther() { + InstrumentParams p; + p.rootOverride = 43; // survivor — same as dialed() + p.keyTrack = 0.5; // survivor — same as dialed() + p.loopOverride = SampleLoop{true, 7, 909}; + p.startPoint = 12; + p.loopCrossfadeFrames = 64; + p.velocityCurve = VelocityCurve::rampDown(); + + p.play.playMode = PlayMode::Gate; + p.play.adsr.attackSeconds = 1.4; + p.play.adsr.holdSeconds = 1.3; + p.play.adsr.decaySeconds = 1.2; + p.play.adsr.sustainLevel = 0.7; + p.play.adsr.releaseSeconds = 1.9; + p.play.adsr.attackCurve = 0.3; + p.play.adsr.decayCurve = 3.4; + p.play.adsr.releaseCurve = 0.6; + p.play.trigger.lengthFraction = 0.75; + p.play.trigAhd.attackSeconds = 0.91; + p.play.trigAhd.decaySeconds = 0.82; + p.play.trigAhd.holdFraction = 0.13; + p.play.trigAhd.attackCurve = 0.7; + p.play.trigAhd.decayCurve = 0.9; + p.play.pitchEngine = PitchEngine::Preserve; + p.play.pitchEnv.enabled = true; + p.play.pitchEnv.peakSemitones = 11.0; + p.play.pitchEnv.shape.attackSeconds = 0.25; + p.play.pitchEnv.shape.decaySeconds = 0.35; + p.play.pitchVelocityCurve = VelocityCurve::rampDown(); + p.play.playRate = 1.75; + p.play.pitchOffsetSemitones = 3.25; + p.play.filter.enabled = true; + p.play.filter.settings = filter::FilterSettings{0.9f, 0.2f, 0.4f, 0.05f, + filter::MorphLaw::HighNotchLow}; + p.play.filter.modAmount = 0.45; + p.play.filter.velAmount = -0.35; + p.play.filter.keyTrack = -0.9; + p.play.filter.env.attackSeconds = 1.7; + p.play.filter.trigEnv.decaySeconds = 1.8; + p.play.filter.velocityCurve = VelocityCurve::rampDown(); + p.play.ampSpline.mode = EnvMode::Spline; + p.play.ampSpline.contour = VelocityCurve::flat(); + p.play.pitchSpline.mode = EnvMode::Spline; + p.play.pitchSpline.contour = VelocityCurve::flat(); + p.play.filterSpline.mode = EnvMode::Spline; + p.play.filterSpline.contour = VelocityCurve::flat(); + p.bakeHold = note::makeDivision(2, note::DivisionModifier::Triplet); + p.limiterEnabled = true; return p; } @@ -75,22 +155,13 @@ bool sameCurve(const VelocityCurve& a, const VelocityCurve& b) { return true; } -} // namespace - -int main() { - const InstrumentParams before = dialed(); - const BakeReset reset = resetAfterBake(before); +// THE reset neutral, field by field. Run against every fixture, so the two can never be +// asserted against two different notions of neutral. +void checkNeutral(const BakeReset& reset) { const InstrumentParams& after = reset.params; const InstrumentParams fresh; // the defaults every reset control must land on const PlaySeconds freshPlay; - // --- SURVIVE: mapping facts, absent from the printed audio ---------------------- - CHECK(after.rootOverride.has_value()); - CHECK(after.rootOverride == before.rootOverride); - CHECK(after.keyTrack == before.keyTrack); - CHECK(after.keyTrack == 0.5); // and it is the dialed value, not the default 1.0 - CHECK(fresh.keyTrack != before.keyTrack); // the fixture really did move it - // --- RESET: loop points, start point, crossfade --------------------------------- CHECK(!after.loopOverride.has_value()); CHECK(!after.startPoint.has_value()); @@ -98,26 +169,17 @@ int main() { // --- RESET: the velocity transfer curves ---------------------------------------- CHECK(sameCurve(after.velocityCurve, VelocityCurve::flat())); - CHECK(!sameCurve(after.velocityCurve, before.velocityCurve)); CHECK(sameCurve(after.play.pitchVelocityCurve, VelocityCurve::zero())); CHECK(sameCurve(after.play.filter.velocityCurve, VelocityCurve::zero())); // --- RESET: play mode, to TRIGGER rather than to the struct's Gate default ------- - // The bake's product is a finished one-shot; Trigger plays it back verbatim, Gate would - // re-gate its printed release tail and each iteration would truncate the last one's. + // bake_reset.cpp carries the argument at the assignment. CHECK(after.play.playMode == PlayMode::Trigger); CHECK(freshPlay.playMode == PlayMode::Gate); // and that really is NOT the default // The Trigger face it lands on plays the whole file flat: full span, unity throughout. CHECK(after.play.trigger.lengthFraction == 1.0); CHECK(after.play.trigAhd.attackSeconds == 0.0); CHECK(after.play.trigAhd.decaySeconds == 0.0); - { - // …and a GATE-dialed instrument lands there too: this is a reset to a chosen - // neutral, not the dialed value surviving. - InstrumentParams gated = dialed(); - gated.play.playMode = PlayMode::Gate; - CHECK(resetAfterBake(gated).params.play.playMode == PlayMode::Trigger); - } // --- RESET: the amp envelope, staged, every stage and every curve exponent ------ CHECK(after.play.adsr.attackSeconds == freshPlay.adsr.attackSeconds); @@ -133,6 +195,7 @@ int main() { CHECK(after.play.trigAhd.decaySeconds == freshPlay.trigAhd.decaySeconds); CHECK(after.play.trigAhd.holdFraction == freshPlay.trigAhd.holdFraction); CHECK(after.play.trigAhd.attackCurve == freshPlay.trigAhd.attackCurve); + CHECK(after.play.trigAhd.decayCurve == freshPlay.trigAhd.decayCurve); // --- RESET: pitch engine + pitch envelope --------------------------------------- CHECK(after.play.pitchEngine == freshPlay.pitchEngine); @@ -140,6 +203,7 @@ int main() { CHECK(!after.play.pitchEnv.enabled); CHECK(after.play.pitchEnv.peakSemitones == 0.0); CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds); + CHECK(after.play.pitchEnv.shape.decaySeconds == freshPlay.pitchEnv.shape.decaySeconds); // --- RESET: Rate and the baseline Pitch offset ----------------------------------- // Both are processing the bake already printed, so the whitelist leaves them at their @@ -150,11 +214,16 @@ int main() { CHECK(after.play.playRate == freshPlay.playRate); CHECK(after.play.pitchOffsetSemitones == freshPlay.pitchOffsetSemitones); - // --- RESET: the filter, including its velocity/key-tracking mod ----------------- + // --- RESET: the filter, its control positions, and its velocity/key-tracking mod -- CHECK(!after.play.filter.enabled); CHECK(after.play.filter.modAmount == 0.0); CHECK(after.play.filter.velAmount == 0.0); CHECK(after.play.filter.keyTrack == 0.0); + CHECK(after.play.filter.settings.cutoffNorm == freshPlay.filter.settings.cutoffNorm); + CHECK(after.play.filter.settings.resonanceNorm == freshPlay.filter.settings.resonanceNorm); + CHECK(after.play.filter.settings.morphNorm == freshPlay.filter.settings.morphNorm); + CHECK(after.play.filter.settings.driveNorm == freshPlay.filter.settings.driveNorm); + CHECK(after.play.filter.settings.morphLaw == freshPlay.filter.settings.morphLaw); CHECK(after.play.filter.env.attackSeconds == freshPlay.filter.env.attackSeconds); CHECK(after.play.filter.trigEnv.decaySeconds == freshPlay.filter.trigEnv.decaySeconds); @@ -165,10 +234,94 @@ int main() { CHECK(after.play.pitchSpline.mode == EnvMode::Staged); CHECK(after.play.filterSpline.mode == EnvMode::Staged); CHECK(sameCurve(after.play.ampSpline.contour, VelocityCurve::rampDown())); - CHECK(!sameCurve(after.play.ampSpline.contour, before.play.ampSpline.contour)); + CHECK(sameCurve(after.play.pitchSpline.contour, VelocityCurve::rampDown())); + CHECK(sameCurve(after.play.filterSpline.contour, VelocityCurve::rampDown())); + + // --- RESET: the bake's own Hold division ----------------------------------------- + // It sizes the render window for the one case that cannot derive one, so the length it + // chose is in the printed file, and the next bake re-derives from that file. + CHECK(after.bakeHold == fresh.bakeHold); + + // --- RESET: the master-bus limiter enable ---------------------------------------- + // Read the asymmetry recorded at BakeReset before reasoning about this one: it is not + // the master stage resetting as a unit. + CHECK(!after.limiterEnabled); + CHECK(!fresh.limiterEnabled); // --- RESET: master gain ---------------------------------------------------------- CHECK(reset.masterGainLinear == 1.0); +} + +} // namespace + +int main() { + const InstrumentParams before = dialed(); + const BakeReset reset = resetAfterBake(before); + const InstrumentParams& after = reset.params; + const InstrumentParams fresh; + + // The fixtures must really move every field the sweep asserts, or the sweep is vacuous. + CHECK(fresh.keyTrack != before.keyTrack); + CHECK(before.limiterEnabled != fresh.limiterEnabled); + CHECK(before.bakeHold != fresh.bakeHold); + CHECK(dialedOther().bakeHold != fresh.bakeHold); + CHECK(dialedOther().bakeHold != before.bakeHold); + CHECK(!sameCurve(before.velocityCurve, VelocityCurve::flat())); + CHECK(!sameCurve(before.play.ampSpline.contour, VelocityCurve::rampDown())); + CHECK(!sameCurve(dialedOther().play.ampSpline.contour, VelocityCurve::rampDown())); + + // --- SURVIVE: mapping facts, absent from the printed audio ---------------------- + CHECK(after.rootOverride.has_value()); + CHECK(after.rootOverride == before.rootOverride); + CHECK(after.keyTrack == before.keyTrack); + CHECK(after.keyTrack == 0.5); // and it is the dialed value, not the default 1.0 + + // --- The neutral, over both fixtures -------------------------------------------- + g_ctx = "dialed"; + checkNeutral(reset); + g_ctx = "dialedOther"; + { + const BakeReset other = resetAfterBake(dialedOther()); + checkNeutral(other); + // The survivors travel from the OTHER fixture too, so the sweep above is agreeing + // with a reset that read its input rather than one that ignores it wholesale. + CHECK(other.params.rootOverride == before.rootOverride); + CHECK(other.params.keyTrack == before.keyTrack); + } + g_ctx = ""; + + // --- A GATE-dialed instrument lands on Trigger too ------------------------------- + // This is a reset to a chosen neutral, not the dialed value surviving. + { + InstrumentParams gated = dialed(); + gated.play.playMode = PlayMode::Gate; + CHECK(resetAfterBake(gated).params.play.playMode == PlayMode::Trigger); + } + + // --- The loop enable and its points come back together --------------------------- + // What the band shows after a bake is the reset override read against the newly banked + // entry, which carries NO loop intrinsic (the shell records that pairing where it builds + // the entry) — so the enable has nothing left to fall back to. + { + constexpr std::int64_t kFrames = 1000; + const LoopMarks dialedMarks = + resolveLoopMarks(StoredLoop{before.loopOverride, std::nullopt, + before.loopCrossfadeFrames, before.startPoint}, + kFrames); + CHECK(dialedMarks.hasLoop); // the fixture really did dial a loop on… + CHECK(!dialedMarks.parked); // …at its own positions + CHECK(dialedMarks.crossfade == 512); + + const LoopMarks neutral = + resolveLoopMarks(StoredLoop{after.loopOverride, std::nullopt, + after.loopCrossfadeFrames, after.startPoint}, + kFrames); + CHECK(!neutral.hasLoop); + CHECK(neutral.parked); // re-offered on the defaults, not left coincident + CHECK(neutral.loopStart < neutral.loopEnd); + CHECK(neutral.crossfade == 0); + CHECK(neutral.start == 0); + } // --- An absent root override stays absent (nothing is invented) ------------------ { From bf7840020e3f41743508851fd6b0e4ef02c4738b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 06:41:24 -0400 Subject: [PATCH 35/56] docs: record Phase Gamma Wave 2's two landed tracks in COMPLETED --- docs/COMPLETED.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index 827e5d5..12aafa2 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -1049,3 +1049,87 @@ excess 18.52 %, pitch-synchronous excess 0.00 %** — PSOLA eliminates that corn than regressing it, with every splice at n = 1 landing exactly one source period away. **These figures are a one-machine, Debug-build measurement against the named control arm, not a general performance claim.** + +### Γ-W2-T1 — pitch-rate-deck + +PITCH became PITCH/RATE: three knobs (`Key Trk | Rate | Pitch`) under the existing +Varisp|Presrv toggle, both new controls wired through the engine. Rate is 50–200 % on a +taper linear in semitones over ±12 (the stated exception to the centre-expansion law), +note-on latched; Pitch is a ±24 st baseline offset, live. Keytrack × rate × pitch-offset +compound into a single read-increment multiply — the per-sample path gained nothing. +Merged as `9dbb8b8`. Spent the phase's second payload rung, v16, as a strict suffix; a +v15 blob lifts to rate 100 % / pitch 0 st. + +**`isLiveDeckParam` was renamed `deckParamCommit`** and now returns a three-state +`LiveCommit` (`Live` / `NoteOnLatched` / `Reload`) rather than a bool — one predicate +widened, not a second mechanism. **Γ-W4-T1 derives the VST3 exposed parameter set from +this predicate**, so the classification is load-bearing two waves out. + +**The clamp question the plan left open at `[propose at review]` was resolved as one +clamp:** `clampStretchRate` at the stretcher, with the taper taking its bounds as +parameters and `deck_values` aliasing them from `engine::kStretchRateMin`/`Max`. + +**Code review found one Critical, fixed before merge:** the resample bake derived its +frame window without the two new fields while rendering *with* them, so a bake at any +non-unity Rate — or, under Varispeed, a downward Pitch — wrote a truncated file into the +bank. Fixed by deriving the window from the rate the voice actually reads at; the +regression test judges against a measured reference render rather than a recomputed +formula, and was proved non-vacuous by reverting the fix (every non-unity case fails). + +**A ruling folded in during remediation:** Pitch was an uncompensated stage-time coupling +under Varispeed — a Trigger AHD's wall-clock attack was invariant under Rate but scaled +with Pitch. Pitch is now compensated; key-track and velocity remain deliberately +uncompensated, because those are shipped sounds whose compensation would break +bit-identity at non-root notes. + +**A Varispeed golden hash was added**, honestly labelled: unlike the Preserve constants +(witnessed against pre-track commit `0a7778b`), it was captured from the remediation +commit itself, so it stands as a witness for the *next* track rather than proof of this +one. + +### Γ-W2-T2 — loop-crossfade-ux + +An explicit loop enable, a legible mark grammar, and the crossfade painted where it is +heard. **No format change** — no `ComponentState` version moved, no new persisted field, +`resolveLoop` untouched, audio unchanged. The enable **is** `SampleLoop::hasLoop`, whose +provenance changes from marker-gesture-derived to user-owned, with the gestures as +shortcuts onto it. Merged as `a8e30a9`. A new pure module, +`core/instrument/ui/loop_marks`, holds the state machine (`resolveLoopMarks`/ +`applyLoopMarks`); the four marks (START/LOOP/END/XFADE) get one grammar — line + shaped +cap + label, the cap being the grip — with cap/label/suppression geometry pure and +unit-tested. + +**The crossfade moved to `[loopEnd − crossfade, loopEnd)`** and draws as a +top-and-bottom edge wedge, never a second fill; the loop fill's peak alpha stays exactly +0.20. + +**START draws in `overlay/trace`, NOT `accent/primary` as the spec table states** — +because `accent/primary` *is* the waveform fill, so a primary START would measure 1:1 +against the material it marks. `overlay/trace` measures 3.071:1 against the fill and +3.065:1 against `bg/base`, clearing the 3:1 non-text floor on both. **This is a +deliberate deviation from `docs/product/instrument-control-surface.md` §6.3's table**, +and the spec is what is wrong. + +**Code review found three Majors, all fixed before merge:** a parked-vs-off state leak +where dragging START on a fresh capture silently converted "never set" into "LOOP OFF"; +three text draws landing on the lime waveform fill at 1.58:1 and 1.10:1 (fixed with a +`bg/base` scrim at alpha 0.90, solved for the binding case rather than chosen by eye — it +lands at 4.666:1 / 8.091:1 and is pinned in `test_theme.cpp`); and a per-mouse-move +bridge read plus bank parse in the hover path, now memoized against the existing key +struct. + +**`docs/TODO.md`'s "Pre-existing staged-envelope-node shadow at zero-attack" entry was +resolved incidentally** — giving START a cap is what closed it — and rewritten in place +with the recorded outcome by the track itself. + +**One thing is deliberately NOT settled and is recorded here as open, not as accepted:** +the audible wedge draws `accent/secondary` against the `overlay/trace` envelope trace at +**1.60:1** against a 3:1 floor. The 0.20 fill-alpha constraint is met and the +pre-existing accepted 2.25:1 pair is unmoved, but the under-floor *extent* inside the +loop span grows from two 2 px marker columns to two `crossfadeWidth × 10 px` bands. No +alpha fixes it — the pair is intrinsic to teal-on-violet. **Daniel has this: he is +judging it visually in the DAW and has not yet ruled.** + +**Neither track has been verified in a running DAW; both are asserted in CTest only.** +The full test suite passes on the merged result — **99/99, Debug config, on one +machine** — not a general cross-platform or Release-config claim. From 1490c2525044703fd5317bc33e3651842c10cde1 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 07:41:27 -0400 Subject: [PATCH 36/56] =?UTF-8?q?Close=20=CE=93-W3-T2=20review=20remediati?= =?UTF-8?q?on:=20widen=20the=20bake-reset=20test's=20per-parameter=20cover?= =?UTF-8?q?age,=20correct=20four=20overclaiming=20comments,=20state=20the?= =?UTF-8?q?=20stage-time=20ceiling's=20disposition,=20and=20fix=20a=20miss?= =?UTF-8?q?ing=20include?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dials and asserts pitch-env/filter-env/trigEnv fields the sweep previously skipped in both fixtures; no reset behavior changed. --- src/core/instrument/bake/CLAUDE.md | 3 ++ tests/test_bake_render.cpp | 11 +++-- tests/test_bake_reset.cpp | 65 +++++++++++++++++++++++++----- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 6f25a7e..4656b90 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -51,6 +51,9 @@ decision about what the render made obsolete. - **Play mode resets to TRIGGER, not to the value struct's Gate default** — the one classification this track made against the ratified rule rather than reading off it. `bake_reset.cpp` carries the argument at the assignment. +- **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list + candidate at all** — it bounds a knob's taper, is never itself a dialed value, and so has + no disposition to classify against the ratified reset rule. ## Modules diff --git a/tests/test_bake_render.cpp b/tests/test_bake_render.cpp index 5812064..4be2fc1 100644 --- a/tests/test_bake_render.cpp +++ b/tests/test_bake_render.cpp @@ -232,10 +232,12 @@ int main() { } // --- Regression baseline: the neutral render is the source, sample for sample -------- - // DERIVED rather than recorded, so it survives a compiler change: a Trigger voice at its - // own root under Varispeed, with the default flat amp and velocity shapes and no filter, - // reads at ratio exactly 1 — so every printed frame must BE its source frame. An added - // stage, a moved default, or a lost early-out anywhere in the chain moves a sample here. + // A Trigger voice at its own root under Varispeed reads at ratio exactly 1 and hits no + // filter, so every printed frame equals its source frame PROVIDED the amp curve's gain at + // the plan's velocity is exactly 1.0 too (asserted below rather than assumed) — that exact + // value is a property of flat()'s two endpoints cancelling at velocity 100, not a + // guarantee of eval() at an arbitrary velocity. An added stage, a moved default, or a lost + // early-out anywhere in the chain moves a sample here. { SampleData s; s.frames.resize(4000); @@ -249,6 +251,7 @@ int main() { // Shorter than the play span, so the window closes before any note-end shaping. const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); + CHECK(s.velocityCurve.eval(100.0) == 1.0); // names the real cause if this ever fails const BakeAudio audio = renderBake(s, plan, kUnity); CHECK(audio.channelCount == 1); CHECK(audio.frameCount() == 1000); diff --git a/tests/test_bake_reset.cpp b/tests/test_bake_reset.cpp index 23d0f29..157b4f4 100644 --- a/tests/test_bake_reset.cpp +++ b/tests/test_bake_reset.cpp @@ -1,12 +1,9 @@ // Standalone tests for reasampler::instrument::bake::bake_reset — no VST3, no REAPER, no // framework. Same fast assert loop as the sibling pure tests. // -// Covers the ratified reset scope PER PARAMETER, in both directions: every control whose -// effect the render printed comes back at its default, and every mapping fact comes back -// untouched. Asserted field by field rather than by struct equality on purpose — a -// whole-struct compare would pass while silently resetting a survivor, or vice versa. The -// sweep runs over TWO independently dialed fixtures that share only the survivors, which is -// what makes it a property of the survivors alone rather than of one input. +// The ratified reset scope itself is bake/CLAUDE.md's; this sweep checks it field by field +// (never struct equality, which would pass while silently resetting a survivor) over TWO +// independently dialed fixtures, so a pass is a property of the survivors, not of one input. #include "../src/core/instrument/bake/bake_reset.h" @@ -14,6 +11,7 @@ // reset loop LOOKS like on the band is the thing worth asserting, and only that module says. #include "../src/core/instrument/ui/loop_marks.h" +#include #include #include @@ -65,6 +63,9 @@ InstrumentParams dialed() { p.play.pitchEnv.peakSemitones = -7.0; p.play.pitchEnv.shape.attackSeconds = 0.05; p.play.pitchEnv.shape.decaySeconds = 0.15; + p.play.pitchEnv.shape.holdFraction = 0.6; + p.play.pitchEnv.shape.attackCurve = 2.1; + p.play.pitchEnv.shape.decayCurve = 0.4; p.play.pitchVelocityCurve = VelocityCurve::linear(); p.play.playRate = 0.5; p.play.pitchOffsetSemitones = -7.5; @@ -75,7 +76,18 @@ InstrumentParams dialed() { p.play.filter.velAmount = 0.6; p.play.filter.keyTrack = 1.5; p.play.filter.env.attackSeconds = 0.7; + p.play.filter.env.holdSeconds = 0.15; + p.play.filter.env.decaySeconds = 0.35; + p.play.filter.env.sustainLevel = 0.25; + p.play.filter.env.releaseSeconds = 0.55; + p.play.filter.env.attackCurve = 2.3; + p.play.filter.env.decayCurve = 0.5; + p.play.filter.env.releaseCurve = 2.8; + p.play.filter.trigEnv.attackSeconds = 0.12; p.play.filter.trigEnv.decaySeconds = 0.8; + p.play.filter.trigEnv.holdFraction = 0.44; + p.play.filter.trigEnv.attackCurve = 1.6; + p.play.filter.trigEnv.decayCurve = 0.6; p.play.filter.velocityCurve = VelocityCurve::linear(); p.play.ampSpline.mode = EnvMode::Spline; p.play.ampSpline.contour = VelocityCurve::linear(); @@ -92,8 +104,8 @@ InstrumentParams dialed() { // every other field — including play mode, which is a chosen neutral rather than a survivor. // Both run the same neutral sweep: if the reset ever inverted into "copy the dialed set, then // clear a blacklist", a non-survivor would come through and at most one fixture could still -// land on the defaults. A parameter added later is covered here by moving it in BOTH -// fixtures, which is the same work as adding it to the sweep. +// land on the defaults. The inherent limit this does NOT cover: nothing forces a newly added +// `InstrumentParams` field to be dialled in either fixture at all, let alone asserted. InstrumentParams dialedOther() { InstrumentParams p; p.rootOverride = 43; // survivor — same as dialed() @@ -123,6 +135,9 @@ InstrumentParams dialedOther() { p.play.pitchEnv.peakSemitones = 11.0; p.play.pitchEnv.shape.attackSeconds = 0.25; p.play.pitchEnv.shape.decaySeconds = 0.35; + p.play.pitchEnv.shape.holdFraction = 0.2; + p.play.pitchEnv.shape.attackCurve = 0.5; + p.play.pitchEnv.shape.decayCurve = 2.4; p.play.pitchVelocityCurve = VelocityCurve::rampDown(); p.play.playRate = 1.75; p.play.pitchOffsetSemitones = 3.25; @@ -133,7 +148,18 @@ InstrumentParams dialedOther() { p.play.filter.velAmount = -0.35; p.play.filter.keyTrack = -0.9; p.play.filter.env.attackSeconds = 1.7; + p.play.filter.env.holdSeconds = 0.95; + p.play.filter.env.decaySeconds = 0.75; + p.play.filter.env.sustainLevel = 0.85; + p.play.filter.env.releaseSeconds = 0.15; + p.play.filter.env.attackCurve = 0.4; + p.play.filter.env.decayCurve = 2.9; + p.play.filter.env.releaseCurve = 0.35; + p.play.filter.trigEnv.attackSeconds = 0.62; p.play.filter.trigEnv.decaySeconds = 1.8; + p.play.filter.trigEnv.holdFraction = 0.77; + p.play.filter.trigEnv.attackCurve = 0.3; + p.play.filter.trigEnv.decayCurve = 2.5; p.play.filter.velocityCurve = VelocityCurve::rampDown(); p.play.ampSpline.mode = EnvMode::Spline; p.play.ampSpline.contour = VelocityCurve::flat(); @@ -204,6 +230,9 @@ void checkNeutral(const BakeReset& reset) { CHECK(after.play.pitchEnv.peakSemitones == 0.0); CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds); CHECK(after.play.pitchEnv.shape.decaySeconds == freshPlay.pitchEnv.shape.decaySeconds); + CHECK(after.play.pitchEnv.shape.holdFraction == freshPlay.pitchEnv.shape.holdFraction); + CHECK(after.play.pitchEnv.shape.attackCurve == freshPlay.pitchEnv.shape.attackCurve); + CHECK(after.play.pitchEnv.shape.decayCurve == freshPlay.pitchEnv.shape.decayCurve); // --- RESET: Rate and the baseline Pitch offset ----------------------------------- // Both are processing the bake already printed, so the whitelist leaves them at their @@ -225,7 +254,18 @@ void checkNeutral(const BakeReset& reset) { CHECK(after.play.filter.settings.driveNorm == freshPlay.filter.settings.driveNorm); CHECK(after.play.filter.settings.morphLaw == freshPlay.filter.settings.morphLaw); CHECK(after.play.filter.env.attackSeconds == freshPlay.filter.env.attackSeconds); + CHECK(after.play.filter.env.holdSeconds == freshPlay.filter.env.holdSeconds); + CHECK(after.play.filter.env.decaySeconds == freshPlay.filter.env.decaySeconds); + CHECK(after.play.filter.env.sustainLevel == freshPlay.filter.env.sustainLevel); + CHECK(after.play.filter.env.releaseSeconds == freshPlay.filter.env.releaseSeconds); + CHECK(after.play.filter.env.attackCurve == freshPlay.filter.env.attackCurve); + CHECK(after.play.filter.env.decayCurve == freshPlay.filter.env.decayCurve); + CHECK(after.play.filter.env.releaseCurve == freshPlay.filter.env.releaseCurve); + CHECK(after.play.filter.trigEnv.attackSeconds == freshPlay.filter.trigEnv.attackSeconds); CHECK(after.play.filter.trigEnv.decaySeconds == freshPlay.filter.trigEnv.decaySeconds); + CHECK(after.play.filter.trigEnv.holdFraction == freshPlay.filter.trigEnv.holdFraction); + CHECK(after.play.filter.trigEnv.attackCurve == freshPlay.filter.trigEnv.attackCurve); + CHECK(after.play.filter.trigEnv.decayCurve == freshPlay.filter.trigEnv.decayCurve); // --- RESET: the three spline contours AND their mode flags ---------------------- // The flag selects which shape ran, so the shape it selected is in the audio; with @@ -260,7 +300,9 @@ int main() { const InstrumentParams& after = reset.params; const InstrumentParams fresh; - // The fixtures must really move every field the sweep asserts, or the sweep is vacuous. + // A sample of the fields checkNeutral asserts, confirming the fixtures actually moved them + // off default — not the whole sweep, but enough spot checks that a fixture regressing to + // the defaults (making the sweep vacuous) would show here first. CHECK(fresh.keyTrack != before.keyTrack); CHECK(before.limiterEnabled != fresh.limiterEnabled); CHECK(before.bakeHold != fresh.bakeHold); @@ -300,8 +342,9 @@ int main() { // --- The loop enable and its points come back together --------------------------- // What the band shows after a bake is the reset override read against the newly banked - // entry, which carries NO loop intrinsic (the shell records that pairing where it builds - // the entry) — so the enable has nothing left to fall back to. + // entry's loop intrinsic — assumed std::nullopt below, which is what the shell lands + // today; if it ever populated one, the enable would have something to fall back to and + // this assumption, not just this test, would need revisiting. { constexpr std::int64_t kFrames = 1000; const LoopMarks dialedMarks = From f60c05c042a9a13d4865860712b861dc29652a59 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 06:51:04 -0400 Subject: [PATCH 37/56] docs: collapse Phase Gamma Wave 2 to its landed record, and correct five spec claims the implementation disproved --- docs/PLAN.md | 320 ++++----------------- docs/product/instrument-control-surface.md | 72 +++-- docs/product/parameter-automation.md | 6 +- 3 files changed, 101 insertions(+), 297 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 636aa29..bf8a770 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -715,7 +715,7 @@ caution: - **after W1-T2 and W2-T1**, because every control that could be a parameter must exist before the list is declared — the list is derived from the control inventory, and an inventory that is still growing produces a list that has to be re-frozen; -- **after W2-T1 specifically**, because `isLiveDeckParam` becoming three-valued is the +- **after W2-T1 specifically**, because `deckParamCommit` becoming three-valued is the *prerequisite* of the classification, not an incidental of it: the exposed set is exactly `Live ∪ NoteOnLatched`; - **after W3-T1**, because MASTER's inventory (the limiter toggle, the GR bubble, the @@ -824,7 +824,7 @@ exact interim layout; do not "fix" it in a track that does not own it. Full wording: `docs/product/parameter-automation.md` §6.3. - **The exposed parameter set is DERIVED, never hand-maintained.** A control is a parameter if and only if its commit class is `Live` or `NoteOnLatched`. There is no second table - beside `isLiveDeckParam` / `liveCommitFor`, and no list that can drift from it. + beside `deckParamCommit` / `liveCommitFor`, and no list that can drift from it. - **The window floor is 1190 × 680 and must not exceed 1280 × 720.** **Γ-W1-T4 sets it, in wave 1; no other track in the phase may move it**, and from that point every track is authored and judged at it. A track that pushes the floor past 1280 has failed, not overrun. @@ -940,280 +940,59 @@ earlier findings closed. The closing measurement (one-machine, Debug-build) is r ### Γ-W2 — New controls, and the overlay's marks -**Depends on Γ-W1 for — four dependencies, two of them new:** -1. **T1 ← W1-T1 (taper law).** Rate and Pitch must be authored into the finished - taper/modifier law, not retro-fitted into it, and the semitone taper must exist before a - second semitone knob does. -2. **T1 ← W1-T5 (the stretcher) — NEW, and the reason the interim path is gone.** Preserve - Rate has no engine without it. Under the prior four-wave order this dependency ran the other - way and was paid for with a disposable resample-and-cancel stand-in; the resequencing - inverts it. **Rate must not ship before its Preserve engine.** -3. **T2 ← W1-T3 (the contour trace).** Both write `editor_paint_waveform.cpp`; running them - together is a merge fight in one file. -4. **T2 ← W1-T1 and W1-T4, weakly.** W1-T1 also edits `editor_input_waveform.cpp` (the - modifier read), which T2 rewrites for marker hit-test routing — serial, so not a conflict, - but T2 rebases onto it. And T2's "the enable costs no window width" criterion is now - asserted against **W1-T4's** derived floor test rather than one this track has to write. +**Depended on Γ-W1** for the taper law and the Preserve stretcher (T1), and the contour trace +and the floor/row law (T2) — see `docs/COMPLETED.md` for the full narrative. -T1 additionally inherits `engine/voice.{h,cpp}` from W1-T5 — a **hand-off, not a conflict**: -W1-T5 defines the Preserve ratio seam, and T1 feeds it. Serial across waves by construction. - -**Disjointness — re-verified against this wave's membership, not carried over.** Both tracks -stayed in W2 and nothing entered or left it, so the prior finding is re-checked and stands. T1 -owns the parameter model, the engine and the deck descriptors; T2 owns the waveform band's -marks and their pure geometry **and the chrome row's loop enable**. The two are disjoint at the -module level with **one named exception: `shell/instrument/editor_session.cpp`.** T1 may touch -it for the third commit tier's routing; **T2 owns `pickedMarkers` and `applyMarkers` there and -nothing else.** The partition is by function and the two do not overlap — **textual merge -adjacency, not semantic contention** — but it is a shared file in a phase whose wave boundaries -are otherwise single-writer surfaces, so it is stated rather than discovered at merge. Whichever -track lands second rebases onto the first. No *new* in-wave adjacency was created by the -resequencing: T2 touches neither `deck_values` nor `deck_groups` nor `voice`. - -**One cross-wave hand-off, new with Ruling 2 and NOT a contention.** Rate's taper — linear in -semitones over ±12, the stated exception to the centre-expansion law — belongs in the **taper -module Γ-W1-T1 extracts**, since that module is the one home of every map. T1 therefore -appends a law to a module a previous wave created. Serial across waves by construction, the -same shape as its `engine/voice.{h,cpp}` hand-off from W1-T5. **What would be wrong is a -second taper defined inside `deck_values`' binding** — one home, appended to, not forked. - -**The format ladder stays clean.** The loop enable maps onto the existing -`SampleLoop::hasLoop`, which is already persisted and whose `start`/`end` are already written -unconditionally — **no new field, no version bump** — so T1 keeps sole ownership of **the -phase's second payload rung** exactly as specced (v16 on this branch as of 2026-08-01, which -has already integrated Ξ; read the ladder rather than assuming the number). +**Both tracks have landed** — Γ-W2-T1 (`pitch-rate-deck`) and Γ-W2-T2 (`loop-crossfade-ux`) — +see `docs/COMPLETED.md` for the full narrative of each. #### Γ-W2-T1 — `pitch-rate-deck` -**Goal.** PITCH becomes **PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the -existing Varisp|Presrv toggle, with both new controls wired through the engine. +**Landed** — see `docs/COMPLETED.md` for the full narrative. PITCH became PITCH/RATE: three +knobs (`Key Trk | Rate | Pitch`) under the existing Varisp|Presrv toggle, both new controls +wired through the engine. Rate is 50–200 % on a taper linear in semitones over ±12 (the stated +exception to the centre-expansion law), note-on latched; Pitch is a ±24 st baseline offset, +live. Keytrack × rate × pitch-offset compound into a single read-increment multiply — the +per-sample path gained nothing. Merged as `9dbb8b8`; spent the phase's second payload rung, +v16, as a strict suffix, a v15 blob lifting to rate 100 % / pitch 0 st. -**Spec:** `docs/product/instrument-control-surface.md` §2. +**`isLiveDeckParam` was renamed `deckParamCommit`** and now returns a three-state `LiveCommit` +(`Live` / `NoteOnLatched` / `Reload`) rather than a bool — one predicate widened, not a second +mechanism. **Rate classifies `NoteOnLatched`, Pitch classifies `Live`; Γ-W4-T1 derives the +VST3 exposed parameter set from this predicate**, so the classification is load-bearing two +waves out. -**Surface boundary — owns:** `core/instrument/engine/play_params.h` + -`core/instrument/map/play_seconds.h` (the two new fields), -`core/instrument/map/component_state_io` + `params_payload` (**the phase's SECOND payload -rung** — v16 on this branch as of 2026-08-01, which has already integrated Ξ; read the ladder, -do not assume the number), -`core/instrument/engine/voice.{h,cpp}` (the compounding and the note-on latch), -`core/instrument/ui/deck_groups` (the PITCH/RATE descriptor **and** the three-state live -predicate), `core/instrument/ui/deck_values` (the two new bindings). **Does not own** the -deck's row layout — that is Γ-W3-T1 — nor the time-stretcher itself (Γ-W1-T5, already landed -by the time this track runs) — nor the pitch-synchronous splice geometry that gates Preserve -Rate (Γ-W1-T7, also already landed by the time this track runs). - -**Behavior.** -- **Rate: 50 %–200 %, default 100 % at true knob centre, exponential taper** — 50 % = −12 st, - 200 % = +12 st, musically symmetric. This is **linear in semitones over ±12** and is the - stated exception to W1-T1's centre-expansion law (which applies to semitone knobs whose - throw exceeds ±12). -- **Pitch: a baseline pitch offset, ±24 semitones**, centred, on W1-T1's centre-expanded - semitone taper. **Reads `kPitchDepthMaxSemis`; does not mint a second constant.** -- **Varispeed:** keytrack ratio × rate ratio × pitch-offset ratio **compound into a single - read-increment multiply**; the rate offset applies to the varispeed pitch. Composes with - the pitch envelope's existing per-frame `ratio_` multiply — **no new per-sample stage**. -- **Preserve:** rate is an **absolute** value driving **duration only**; keytrack and pitch - offset drive the pitch shifter. **Rate drives the stretch ratio Γ-W1-T5's stretcher already - consumes — there is no interim path.** The stretcher is this track's prerequisite, not its - successor; the resample-and-cancel stand-in the prior plan carried is retired unbuilt (see - Open questions for the contingency). `core/instrument/CLAUDE.md`'s "never wire - `WDL_Resampler` as the duration-preserving path" is honoured by construction. -- **Rate is latched at note-on**, delivered by a **third commit class**: published into the - live block like any live parameter, read only by `snapLive`, never by `applyLive`. - `isLiveDeckParam`/`liveCommitFor` widens from two states to three - (`Live` / `NoteOnLatched` / `Reload`) in that one predicate — **not** a second table, and - **not** the reload tier (a swept knob must never trigger a WAV re-decode). **Record the - reason in the header:** loop resolution and contour mapping are note-on folds, so live rate - means re-folding a resolved loop and re-mapping a contour mid-note. -- **Pitch is live** — under Varispeed one more factor in a multiply the pitch envelope already - performs; under Preserve an addend to a shift the pitch envelope already modulates. -- **Loop points scale with rate; contours scale with rate.** Neither rewrites stored values: - the loop is source-frame facts traversed at the new increment (Varispeed) or the new read - rate (Preserve), and a contour is a function of normalized position. **Staged envelope stage - times do NOT scale** — 30 ms is 30 ms at any rate. That asymmetry is deliberate: a contour is - of the sample, a staged envelope is of the performance. -- **Deck descriptor:** three cells; `captionWidth` **70**, hard ceiling **80** (above that the - caption row overtakes the 180 px knob row and the group exceeds 192). If the text will not - fit at 80, narrow the `Varisp|Presrv` segments 48 → 44 (ceiling becomes 88) — **do not widen - the group**. -- **The payload rung** appends both fields as a strict suffix; the preceding version's blob - lifts to rate 100 % / pitch 0 st, bit-identical playback. -- **Both new `DeckParam`s are classified in the three-state predicate, and that classification - is what puts them in the VST3 parameter list three waves later** — Rate `NoteOnLatched`, - Pitch `Live`. Γ-W4-T1 derives the exposed set from this predicate rather than from a list - of its own, so a mis-classification here is a mis-declared parameter there. - -**Acceptance criteria.** -- Rate at 50 % plays an octave down and half speed under Varispeed; at 200 %, an octave up and - double speed. Under Preserve the same settings change duration only — pitch is unchanged - within the stretcher's tolerance. **Preserve Rate is a finished feature the day this lands**, - because Γ-W1-T5 already shipped its engine; a degraded or inert Preserve Rate is a failed - track, not an acceptable interim. -- Rate at exactly 100 % and Pitch at exactly 0 st render **bit-identical** to the - pre-change build, in both engines. -- Shift-drag on Rate lands on whole semitones (so an octave and a fifth are reachable by - hand); Shift-drag on Pitch lands on whole semitones; Ctrl gives cents on both. -- **A Rate change while a note sounds does not alter that note**; the next note-on takes it. - **It does not trigger a reload or an engine rebuild** — assert the tier, not just the sound. -- A Pitch change **does** move a sounding note, in both engines. -- With a loop set, changing Rate changes the loop's audible period without moving either - waveform marker. -- The PITCH/RATE group measures **exactly 192 px**; adding the two `DeckParam`s produces a - compile error in `isLiveDeckParam`'s exhaustive switch until they are classified. -- A project saved at the preceding payload version reopens at rate 100 % / pitch 0 st and - sounds identical. - -**Open questions.** -- **None [Daniel].** -- **[propose at review]** Rate's clamp behaviour at the range extremes as it meets the - stretcher's own ratio bounds — one clamp, resolved where the two meet, not two that can - disagree. -- **Named contingency, not a plan item, and not to be taken silently.** If Γ-W1-T5's - measure-and-report gate has not passed when this track is ready to dispatch, the pre-agreed - fallback is the **resample-and-cancel composition** spec §2.5 records — a resampled read with - the resulting pitch change cancelled in the existing SOLA shifter — shipped as an interim - Preserve path with the stretcher as its later quality upgrade, i.e. a return to the prior - four-wave order. **Escalate to Daniel rather than taking it:** it revives a disposable - implementation and re-opens the `WDL_Resampler` guardrail conversation, and the whole point of - the resequencing was to avoid building it. +Code review found one Critical (the resample bake read a stale frame window at non-unity Rate; +fixed by deriving the window from the rate the voice actually reads at, proved non-vacuous by +reverting the fix) and folded in a ruling that Pitch, unlike key-track and velocity, is now +compensated against Varispeed's stage-time coupling. A Varispeed golden hash was added, +honestly witnessed from the remediation commit rather than pre-track, so it stands as a +witness for the next track rather than proof of this one. #### Γ-W2-T2 — `loop-crossfade-ux` -**Goal.** Give the loop an explicit enable, make the loop and crossfade marks legible, and -paint the crossfade where it is actually heard. +**Landed** — see `docs/COMPLETED.md` for the full narrative. An explicit loop enable, a +legible mark grammar, and the crossfade painted where it is heard. **No format change** — no +`ComponentState` version moved, no new persisted field, `resolveLoop` untouched, audio +unchanged. The enable **is** `SampleLoop::hasLoop`, whose provenance changes from +marker-gesture-derived to user-owned, with the gestures as shortcuts onto it. Merged as +`a8e30a9`. A new pure module, `core/instrument/ui/loop_marks`, holds the state machine +(`resolveLoopMarks`/`applyLoopMarks`); the four marks (START/LOOP/END/XFADE) get one grammar — +line + shaped cap + label, the cap being the grip — with cap/label/suppression geometry pure +and unit-tested. The crossfade moved to `[loopEnd − crossfade, loopEnd)`, drawing as a +top-and-bottom edge wedge, never a second fill; the loop fill's peak alpha stays exactly 0.20. -**Spec:** `docs/product/instrument-control-surface.md` §6 — **read §6.1 (the diagnosis), -§6.4 (the enable) and §6.5 (trade-offs) in full before starting.** This is the phase's one -genuinely designed surface; the sections are the brief. +**START draws in `overlay/trace`, a deliberate deviation from +`docs/product/instrument-control-surface.md` §6.3's table**, because the spec's +`accent/primary` choice would measure 1:1 against the waveform fill it marks. -**Surface boundary — owns:** `shell/instrument/editor_paint_waveform.cpp`'s marker/loop draw, -`shell/instrument/editor_input_waveform.cpp`'s marker hit-test routing, -`core/instrument/ui/waveform_view` (cap rects, label boxes, the label-suppression rule — all -pure, all CTest-covered), and — **new, from the Γ-F4 ruling** — -`core/instrument/ui/sample_chrome` (the enable's rect in the toolbar control run), -`shell/instrument/editor_paint_chrome` + `editor_input_chrome` (its draw and hit-test), and -`shell/instrument/editor_session.cpp`'s **`pickedMarkers` / `applyMarkers` only** (the -retention rule — see the wave header for the shared-file partition). **Does not own** -`loop_span`, the crossfade model, any parameter, or any `ComponentState` version. **This -track changes drawing, hit-testing and one editor-state retention rule — no format change.** - -**Behavior.** -- **An explicit loop enable on the CHROME ROW (Γ-F4, ruled).** A two-segment `Loop Off|On` - toggle joins the toolbar row's right-anchored control run, **immediately left of the - `Mono|Stereo` toggle**, with Browse still rightmost. Loop is a waveform-overlay concept and - **has no deck** — a deck cell was never the right home. Because the run is right-anchored - and the title slot absorbs it, **this costs zero window width and none of the 90 px - headroom**; if the title will not hold its text at the 1190 floor, **the enable's segments - narrow — the floor does not move.** -- **The enable IS `SampleLoop::hasLoop`. No new field, no version bump.** The field already - exists (`play_params.h:210`), is already what `resolveLoop` refuses on - (`loop_span.cpp:12`), and is already persisted in the payload's `loopOverride` block — - where **`start`/`end` are written unconditionally whatever `hasLoop` says** - (`params_payload.cpp:31-36`), so the wire can already carry "off, with a span remembered." - What changes is the field's *provenance*: today it is derived from the marker gesture, and - after this track it is **user-owned**, with the gestures as shortcuts onto it. -- **Collapse-to-off survives as a shortcut, not as a second state machine.** `hasLoop` is the - single authority; four gestures reach it: - | Gesture | Enable | Span | Crossfade | - |---|---|---|---| - | Enable → On | on | retained | retained | - | Enable → Off | off | **retained** | **retained** | - | Collapse the span onto itself | off | **destroyed**, re-parked at `defaultLoopBounds` | **zeroed** | - | Drag either loop mark while off | **on** | takes the drag | retained, re-clamped | -- **Two consequent behaviour changes, each with its reason.** (a) `pickedMarkers`' - re-park (`editor_session.cpp:221-226`) currently triggers on `!hasLoop`; it must become - conditional on the span being **invalid** (collapsed / inverted / out of range) rather than - on the enable being off — a toggle whose off→on does not restore what was there is a delete - button, not a toggle. (b) `applyMarkers`' crossfade zeroing (`:240-243`) moves from "the - enable is off" to "the span was destroyed." **The original reasoning is preserved, not - overruled:** it zeroes so a stale length cannot silently re-apply against a span that no - longer exists; with the span retained, its clamp bound is retained too and there is nothing - stale. -- **The "drag me" affordance splits into two off-states.** Off with **no span ever set** — - pair parked at `defaultLoopBounds`, Disabled, caption `DRAG TO SET LOOP`. Off with a **span - retained** — pair Disabled *at its own positions*, caption `LOOP OFF` (there is nothing to - "set"). In both, **dragging a mark turns the enable on** — the shipped drag-to-create - gesture survives and now teaches the enable by demonstration. -- **In Trigger the enable draws Disabled and inert, and does NOT clear `hasLoop`** — - Disabled-not-hidden, the same grammar as the marks, with its state restored on the return to - Gate. This transitively covers the drawn-EG case via `enforceGateUnavailableWhileDrawn` - (`play_params.h:198-205`), which forces Trigger whenever an envelope is drawn — one - predicate, not a second rule. **Disabled-but-grabbable (the off marks) vs. - Disabled-and-inert (Trigger) is deliberate:** the user's own off is reversible by the very - gesture on offer; Trigger's refusal comes from the engine and no drag can talk it out of it. -- **One mark grammar: line + shaped cap + label. The cap IS the grip.** Four marks: - **START** (`accent/primary`, solid right-pointing triangle cap, solid line — the only - primary-ink mark, because it is the only one always in effect); **LOOP** (`accent/secondary`, - L-cap opening right); **END** (`accent/secondary`, L-cap opening left); **XFADE** - (`accent/secondary` reduced alpha, ramp cap, **dashed** line — a soft boundary). This - replaces the bare 10 px orphan tab that today marks the crossfade with no line of its own. -- **Labels** in `Font::Micro`/`TextDim`, drawn **beneath** the trace and handles in z-order. - A mark's label **re-draws on top on hover or drag** of that mark. **A label is suppressed if - its box would overlap one already placed**; placement order is grabbed/hovered first, then - START, LOOP, END, XFADE. Occlusion by an envelope node is **accepted and named** — the cap - shape carries the identity permanently, the label is for learning. -- **The crossfade moves to `[loopEnd − crossfade, loopEnd)`** — where it is audible. The - handle moves to the loop-end side; **drag direction is unchanged** (left lengthens), so the - muscle memory survives. -- **The crossfade region draws as a top-and-bottom edge wedge, NEVER as a second fill.** A - triangular band at the overlay's top and bottom edges growing from zero at - `loopEnd − crossfade` to ~10 px at `loopEnd`. **This is a hard constraint:** the region is - now *inside* the loop span, where a translucent fill would stack on the 0.20 loop fill, and - the envelope trace crossing that fill is a known, accepted under-floor pair at 2.25:1 - (`editor_paint_waveform.cpp:28-34`), whose own note says the FILL is what changes if it is - ever resolved. **The loop fill's peak alpha must stay exactly 0.20.** -- **The ingredient draws as a ghost.** `[loopStart − crossfade, loopStart)` draws the mirror - wedge at half alpha, no handle — **a hairline dashed outline at rest, filling in on hover or - drag of the crossfade handle**. This makes the `crossfade ≤ min(start, loopLength)` clamp - self-explanatory: the fade stops growing exactly when the ghost's left edge reaches START or - LOOP, so the user sees the reason instead of hitting an invisible wall. -- **Trigger mode:** the loop pair and the crossfade mark draw **Disabled and are not - grabbable**, with a dim `LOOP — GATE ONLY` caption — Disabled rather than hidden, matching - the editor's existing Gate-segment grammar, and because hiding a set loop on a mode flip - destroys information the user put there. START stays fully live. - -**Acceptance criteria.** -- The four marks are distinguishable by ink and cap shape with the labels suppressed, and - named when they are not. -- **The shaded crossfade region sits over the frames where the fade is audible** — verify - against a rendered loop, not by reading the code. -- Every mark is grabbable by its cap; grabbing a mark shows its label. -- The crossfade at its clamp shows the ghost's left edge coincident with the bounding mark. -- **The loop fill's peak alpha is unchanged at 0.20** and the accepted 2.25:1 trace pair is - neither improved nor worsened. -- In Trigger, no loop mark accepts a grab, the chrome enable is Disabled and inert, and the - reason is on screen. Returning to Gate restores the enable's prior state. -- **Turning the enable off and on again restores the loop exactly** — same span, same - crossfade, no re-park. Collapsing the span instead turns it off, re-parks at - `defaultLoopBounds` and zeroes the crossfade. Both paths asserted. -- **Dragging a loop mark while the enable is off turns it on**, in both off-states. -- **The enable costs no window width:** `kEditorMinWidth` is unchanged by this track, asserted - by the same derived test that guards the floor. -- **No `ComponentState` version moves; no new persisted field; `resolveLoop` is untouched; - audio is unchanged.** The enable round-trips save/reload through the existing - `loopOverride` block, in both states, with the span retained across an off. -- All cap/label/suppression geometry is pure and unit-tested; no hit-test math in the painter. - The enable's rect lands in `sample_chrome` alongside the rest of the control run. - -**Open questions.** -- **[propose at review, then verify by hand]** The claim-arbitration inputs change: - `markerHandleRect` today gives a tab to the crossfade only, and `resolveWaveformClaim` - breaks ties by smallest nominal target area. Giving every mark a cap-grip changes the - candidate set **and every nominal area in it**. The arbitration must be re-derived, and - `docs/TODO.md`'s open entry *"Pre-existing staged-envelope-node shadow at zero-attack"* - must be **re-evaluated against the new cap geometry and its outcome recorded** — resolved or - worsened, either is acceptable, silence is not. -- **No [Daniel] questions.** Fork Γ-F4 is ruled — there **is** an explicit enable and it is on - the chrome row, in this track. The prior framing ("an enable needs a cell, so it is a Γ-W3 - layout decision") was wrong and is retired: loop has no deck, so it never needed one. -- **[propose at review]** every site that currently *infers* `hasLoop` — two in - `editor_input_waveform` (`:255`, `:258`), two in `editor_session` (`:222`, `:236`) — is now - writing to a user-visible control rather than to an internal flag. Re-read each in that - light; "it still compiles" is not a disposition. -- **Named escalation, not a fallback to take silently:** if the top strip reads crowded in the - DAW, the pre-designed answer is the marker rail (spec §6.2, Direction 2) — a larger build - that would also dissolve the arbitration problem structurally. Escalate; do not improvise a - half-rail. +Code review found three Majors, all fixed before merge. `docs/TODO.md`'s "Pre-existing +staged-envelope-node shadow at zero-attack" entry was resolved incidentally — giving START a +cap is what closed it — and rewritten in place with the recorded outcome by the track itself. +**One thing is deliberately left open, not accepted:** the audible crossfade wedge draws at +1.60:1 against the envelope trace, under the 3:1 floor, with no alpha fix available; Daniel is +judging it visually in the DAW and has not yet ruled. **Neither track has been verified in a +running DAW; both are asserted in CTest only** — 99/99, Debug config, on one machine. --- @@ -1437,7 +1216,7 @@ courtesy:** 2. **← W1-T2 and W2-T1.** Every control that could be a parameter must exist before the list is declared. The list is derived from the control inventory; an inventory still growing produces a list that has to be re-frozen, and it cannot be. -3. **← W2-T1 specifically.** `isLiveDeckParam` becoming three-valued is the *prerequisite* of +3. **← W2-T1 specifically.** `deckParamCommit` becoming three-valued is the *prerequisite* of the classification, not an incidental: the exposed set is exactly `Live ∪ NoteOnLatched`. 4. **← W3-T1.** MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last change to what controls exist at all. @@ -1493,7 +1272,7 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). derivation and why the within-block order is seeded ONCE rather than tracked against `cellIds`; **§6.3 for the freeze invariant, which is to be stated in the table's header with the same force as the command-id strings, the class UIDs and the payload field order.** -- **The exposed set is DERIVED from `isLiveDeckParam` / `liveCommitFor`, never +- **The exposed set is DERIVED from `deckParamCommit` / `liveCommitFor`, never hand-maintained** — a control is a parameter iff its class is `Live` or `NoteOnLatched`. **44 parameters** at the end of Γ-W3, enumerated by group in §7.1. - **Everything else is OMITTED from the list entirely**, not exposed-and-flagged: the reload @@ -3731,8 +3510,9 @@ proof it exists to give. from `TODO-1.0.md`, and not a track this plan originally scoped. The second such track in this plan today; if others appear, they belong on this list rather than in the table. -- **All of Phase Γ** (`pg-*`). **Ten tracks across four waves**, from a direct interview with - Daniel (2026-08-01) and his four later rulings the same day, not from `TODO-1.0.md`. Listed +- **All of Phase Γ** (`pg-*`). **Twelve tracks across four waves** (W1 seven, W2 two, W3 two, + W4 one), from a direct interview with Daniel (2026-08-01) and his four later rulings the + same day, not from `TODO-1.0.md`. Listed here as a block rather than per track, because the whole phase is outside the source doc; the product reasoning lives in `docs/product/instrument-control-surface.md` and the parameter system's in `docs/product/parameter-automation.md` §§6–10. **Two `docs/TODO.md` @@ -3859,7 +3639,7 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends pure libraries [no PLAN entry — see COMPLETED.md] T7 psola-preserve ............. PSOLA-aligned splice jump; period_detect [cadence closure — see COMPLETED.md] - W2 New controls, and the overlay's marks [2 tracks] + W2 New controls, and the overlay's marks — landed [2 tracks] T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [rung 2] T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard W3 The reflow, and the bake correction [2 tracks] diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index a2ba3cc..dab82c6 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -344,16 +344,16 @@ shift unless that contingency is taken. **Settled: Rate is latched at note-on for this phase (not live on sustaining voices).** Two consequences the implementation must get right: -**It is a latch, not a reload.** `isLiveDeckParam` is currently a binary predicate whose -`false` branch routes an edit to a **full reload** (bridge read, WAV re-decode, fresh +**It is a latch, not a reload.** `deckParamCommit` was a binary predicate whose +`false` branch routed an edit to a **full reload** (bridge read, WAV re-decode, fresh engine) or an engine rebuild. Routing a swept knob down that path is unacceptable. Rate is therefore a **third commit class**: *published into the live block like any live parameter, but read only by `snapLive` at note-on and never by `applyLive` on a sounding voice.* The mechanism already exists — the invariant "A fresh note SNAPS, a sounding one holds φ" is -exactly this split — but the *classification* does not. +exactly this split — but the *classification* did not, until this phase widened it. > **Where this is recorded.** `core/instrument/CLAUDE.md` states that *"which controls are -> live is ONE decision, recorded in ONE place"* — `isLiveDeckParam` / `liveCommitFor` in +> live is ONE decision, recorded in ONE place"* — `deckParamCommit` / `liveCommitFor` in > `ui/deck_groups`. Phase Γ widens that one decision from two states to three > (`Live` / `NoteOnLatched` / `Reload`) rather than adding a second predicate elsewhere. > This is also precisely the seam the automation work needs — see @@ -374,12 +374,23 @@ Preserve it is an addend to a shift amount the pitch envelope already modulates. ### 2.4 Rate scaling — what "scales with rate" means, concretely -- **Loop points scale with rate.** The loop is a pair of *source-frame* facts. Under - Varispeed the read increment changes and the loop is traversed proportionally faster — - scaling is automatic and the stored frames are untouched. Under Preserve the read - advances at `rate ×` the source rate, so the loop's wall-clock period scales by `1/rate` - while its source-frame span is unchanged. **In neither mode are the stored loop frames - rewritten**; the marks on the waveform do not move when Rate moves. +- **Loop points scale with rate under Varispeed; under Preserve, the loop's *traversal* + scales and its audible period does not.** The loop is a pair of *source-frame* facts. + Under Varispeed the read increment changes and the loop is traversed proportionally + faster — scaling is automatic, the stored frames are untouched, and the audible period + scales by `1/rate` along with everything else the voice plays. **Under Preserve this is + the opposite of what the Varispeed case suggests, and the obvious extension of it is + wrong** — which is why an engineer measured this before writing code against it rather + than inferring it from the Varispeed case above. What scales with rate under Preserve is + the loop's *traversal* — how fast the source is consumed (the feed-side witness is + `testPreserveStretchLoopsTheSourceSpan`) — not its audible period: holding the source's + period constant while its duration changes is what Preserve *is*. **Measured** (Debug + build, one machine): with a ring long enough to hold the whole loop, the rendered + sawtooth period is ~3999.9 output frames at rate 0.5, 1.0, and 2.0 alike; at shorter + rings, where splice cadence intrudes instead of the design property being isolated, the + same fixture measured 3064 and 4130 frames at rate 0.5 — never the 8000 a scaling period + would give either. **In neither mode are the stored loop frames rewritten**; the marks on + the waveform do not move when Rate moves. - **Contours scale with rate.** A drawn contour is a pure function of *normalized* sample position (`core/instrument/CLAUDE.md`: "Normalized is what makes a contour length-independent"), so it follows the read head by construction. **The staged @@ -1042,13 +1053,20 @@ being a bare orphan rectangle and becomes the same kind of object as every other | Mark | Ink | Cap | Line | Label | |---|---|---|---|---| -| **Start** | `accent/primary` | solid **right-pointing triangle** (a play flag — it points into the material that will play) | solid | `START`, right of the line | +| **Start** | `overlay/trace` | solid **right-pointing triangle** (a play flag — it points into the material that will play) | solid | `START`, right of the line | | **Loop start** | `accent/secondary` | **L-cap opening right** | solid | `LOOP`, right of the line | | **Loop end** | `accent/secondary` | **L-cap opening left** | solid | `END`, left of the line | | **Crossfade** | `accent/secondary`, reduced alpha | **ramp cap** — a small right triangle whose hypotenuse rises left→right, drawing the fade-in shape | **dashed** — a soft boundary, not a hard one | `XFADE`, left of the line | -Start is the only `accent/primary` mark in the band, because it is the only one that is -always in effect (both Gate and Trigger). The loop pair's opposed L-caps read as `[ … ]` +Start draws in `overlay/trace`, not `accent/primary`: `accent/primary` **is** the waveform +fill, so a primary START would measure 1:1 against the material it marks. `overlay/trace` +measures 3.071:1 against the fill and 3.065:1 against `bg/base`, clearing the 3:1 non-text +floor on both — provably optimal, since `core/ui/CLAUDE.md`'s two-neighbour rule derives +`sqrt(9.41) ≈ 3.07` as the ceiling any single value can hold against both neighbours at +once. Start is still the only mark always in effect (both Gate and Trigger), but that is no +longer what its ink says, now that `overlay/trace` is shared with the envelope trace: the +distinction is carried by shape instead — a straight full-height column under a solid +triangle cap, never a curve. The loop pair's opposed L-caps read as `[ … ]` without needing to be explained. All four caps use primitives already in the kit (axis-aligned fills, AA-restroked triangles per `visual-design-language.md` §8). @@ -1088,17 +1106,23 @@ cosmetic gain. > already-accepted failure worse.** The edge wedge leaves the loop fill's peak alpha at > 0.20 exactly as today, so the pair is untouched. - **The ingredient draws as a ghost.** `[loopStart − crossfade, loopStart)` — the material - actually being mixed in — draws the **mirror** wedge (growing right-to-left, peaking at - `loopStart`) at half alpha, outside the loop fill. It carries no handle. **At rest it is a - hairline dashed outline; it fills in on hover or drag of the crossfade handle** — a hover - state in the sense §3.3 of the visual language means, revealing the relationship only when - the user is asking about it. + actually being mixed in — draws the **same ramp** the audible wedge draws (growing + left-to-right, peaking at `loopStart`) at half alpha, outside the loop fill — not a + mirror of it: the incoming tap's weight at ingredient frame `loopStart − crossfade + k` is + the same `crossfadeWeight` as audible frame `loopEnd − crossfade + k`, so both spans carry + the identical ramp, which is exactly why one `crossfadeWedgeHeight` function draws both. + It carries no handle. **At rest it is a hairline dashed outline; it fills in on hover or + drag of the crossfade handle** — a hover state in the sense §3.3 of the visual language + means, revealing the relationship only when the user is asking about it. - **This makes the clamp self-explanatory.** The hard clamp is - `crossfade ≤ min(start, loopLength)` (`loop_span.h:19`, and its "no material ahead of the - loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn, **the fade stops - growing exactly when the ghost's left edge reaches the START mark or the LOOP mark** — the - user sees the reason instead of hitting an invisible wall. That is the single best payoff - in this design and it costs nothing extra. + `crossfade ≤ min(loopStart, loopLength)` (`loop_span.h:19` — `maxCrossfade(loopStart, + loopEnd − loopStart)`; `start` there names `loopStart`, not the START mark — and its "no + material ahead of the loop" reasoning in `engine/loop/CLAUDE.md`). With the ghost drawn, + **each half of the clamp is now visible, on a different mark:** the ghost's left edge + reaches frame 0 — the overlay's own left edge, not a mark — exactly at the `loopStart` + bound, and the audible wedge's left edge reaches the LOOP mark exactly at the `loopLength` + bound. The user sees why the fade stopped growing instead of hitting an invisible wall. + That is the single best payoff in this design and it costs nothing extra. **(d) The off-state and the Trigger state get words, not just alpha.** @@ -1309,7 +1333,7 @@ the floor without touching cell metrics, because the **group inventory and its r assignment** now also drive it. Restate as: *the deck's cell metrics AND its group/row composition both drive `kEditorMinWidth`; none of the three may move alone.* -**7.5 — `isLiveDeckParam` becomes three-valued.** See §2.3. The exhaustive switch must +**7.5 — `deckParamCommit` becomes three-valued.** See §2.3. The exhaustive switch must classify the two new `DeckParam`s or fail to compile — which is exactly what it is designed to do, and which is why the two new parameters are cheap to add *now*. diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index 6bbaedb..8718885 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -124,7 +124,7 @@ than balancing it — a fork with a dominated option in it is not a fork. ### 3.4 Which controls can be parameters at all — three classes -The good news: **this analysis is already done once, in one place.** `isLiveDeckParam` / +The good news: **this analysis is already done once, in one place.** `deckParamCommit` / `liveCommitFor` (`ui/deck_groups`) is exactly "which controls can change without a rebuild," which is the same question automation asks. Phase Γ widens it from two states to three (§3.5). The parameter work should widen the *same* decision point again rather than start a @@ -825,7 +825,7 @@ already names that door from the other side. > **A control is an exposed VST3 parameter if and only if its commit class is `Live` or > `NoteOnLatched`.** Everything else is omitted from the parameter list entirely. -That makes `isLiveDeckParam` / `liveCommitFor` — already *"THE home for why each excluded +That makes `deckParamCommit` / `liveCommitFor` — already *"THE home for why each excluded control is excluded"* — the single source for the parameter list too, which is the standing rule (`core/instrument/CLAUDE.md`: *"which controls are live is ONE decision, recorded in ONE place"*) applied once more rather than a second table opened beside it. @@ -890,7 +890,7 @@ blob, which is exactly what §6.1's split is for. set **for the note-on-latch reason** currently route through the reload tier, and the new state fits them exactly: -- **Key-track** — `isLiveDeckParam`'s header already says it *"feed[s] values a voice +- **Key-track** — `deckParamCommit`'s header already says it *"feed[s] values a voice latches at note-on by design (the pitch ratio…), so live delivery would retune… a note already struck."* That sentence describes `NoteOnLatched`, not `Reload`. - **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."* From 0627398bbb88fea89c221dc540637b415da1ec43 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 07:41:10 -0400 Subject: [PATCH 38/56] Reflow the deck into two categorical rows plus a double-height MASTER bus deck Row membership is now the group's own property, not a wrap outcome. FILTER's Band|Notch moves to its caption slack, which is what makes the sound row fit. MASTER gains the limiter toggle, the output meter and the GR lamp. --- src/core/instrument/CLAUDE.md | 7 +- src/core/instrument/ui/CMakeLists.txt | 10 + src/core/instrument/ui/deck_groups.cpp | 27 +- src/core/instrument/ui/deck_groups.h | 17 +- src/core/instrument/ui/knob_deck.cpp | 198 ++++++++--- src/core/instrument/ui/knob_deck.h | 88 +++-- src/core/instrument/ui/master_meter.cpp | 70 ++++ src/core/instrument/ui/master_meter.h | 71 ++++ src/shell/instrument/CLAUDE.md | 9 +- src/shell/instrument/CMakeLists.txt | 2 +- src/shell/instrument/editor_controls.cpp | 4 +- src/shell/instrument/editor_input_deck.cpp | 23 ++ src/shell/instrument/editor_paint_deck.cpp | 115 ++++++- src/shell/instrument/editor_session.cpp | 20 ++ src/shell/instrument/reasampler_editor.h | 6 + tests/test_deck_groups.cpp | 366 ++++++++++++++++----- tests/test_knob_deck.cpp | 219 +++++++++--- tests/test_master_meter.cpp | 215 ++++++++++++ tests/test_sample_bands.cpp | 8 +- 19 files changed, 1239 insertions(+), 236 deletions(-) create mode 100644 src/core/instrument/ui/master_meter.cpp create mode 100644 src/core/instrument/ui/master_meter.h create mode 100644 tests/test_master_meter.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index ad0f2ba..bd3aa17 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -311,7 +311,7 @@ anything for a trigger shape. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. - `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle. -- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. +- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`. ### `map/` @@ -339,7 +339,7 @@ anything for a trigger shape. - `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required. - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain. -- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots. +- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n−1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots. - `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups` says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` / @@ -357,7 +357,8 @@ anything for a trigger shape. drag the bank model and the WAV codec in behind it. The shell keeps only the controls the parameter set does not carry (key-track, voice count, master gain, preview velocity) and the labels for them. -- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there. +- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveformSurface` folds** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Composes `engine/meter_ballistics` per channel and adds the gain-reduction lamp's own decay; the audio thread's clip flag is ORed in because it is the only latch that sees the blocks between two UI frames. `meterDrawEqual` is what lets the UI tick repaint on change alone. +- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the −92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164. - `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 53d87b6..8a9cde9 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -57,6 +57,16 @@ reasampler_test(envelope_edit LINK envelope_edit) reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry) reasampler_test(knob_deck LINK knob_deck) +# The spanning deck's meter column, split from knob_deck on the axis sample_chrome has to +# sample_bands: that says where the column is, this lays out inside it. sample_bands is PUBLIC +# for LaneSplit — the bar count is the SAME resolved decision the waveform's lane split is. +reasampler_pure_library(master_meter + SOURCES master_meter.cpp + LINK PUBLIC editor_geometry sample_bands meter_ballistics) +# waveform_view is linked for the test only: proving the bar count is not a second rule takes +# the real waveformSurface fold, over channel mode x source channel count. +reasampler_test(master_meter LINK master_meter waveform_view) + # The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see # core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads # PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index a005612..0d4ed3d 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -11,9 +11,14 @@ int id(DeckParam p) { return static_cast(p); } double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); } // Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays -// no wider than its knob row — the ceiling is PITCH ENV's, whose caption row lands exactly on -// its four-cell knob row at 23. Raising it reflows the deck's first row. +// no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob +// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen +// the CONTOUR row, which has 144px of slack, not the SOUND row. constexpr int kEnvModeSegW = 23; + +// The output meter's column, right of MASTER's cell slots. 62 + the 60px cell + the gap + the +// group's own padding is exactly kDeckSpanningW. +constexpr int kMasterMeterW = 62; } // namespace double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; } @@ -63,7 +68,9 @@ std::vector sampleDeckGroups(PlayMode playMode) { id(DeckParam::kFilterModAmt), id(DeckParam::kFilterVel), id(DeckParam::kFilterKeyTrack)}; - filter.rowToggle = {id(DeckParam::kFilterLaw), 44}; + // The morph law rides the caption slack. Moving it back to the knob row costs the + // group 92px and the SOUND row stops fitting its block. + filter.captionToggle2 = {id(DeckParam::kFilterLaw), 44}; out.push_back(std::move(filter)); } { @@ -126,12 +133,19 @@ std::vector sampleDeckGroups(PlayMode playMode) { out.push_back(std::move(voice)); } { + // The lower slot is reserved and draws NOTHING: blank reads as breathing room where a + // dashed placeholder would read as unfinished. It is one cell, not two — a second + // would spend 60 of the layout's whole 90px budget on a control nobody has named. DeckGroupDesc master; master.id = kGroupMaster; master.captionWidth = 46; - master.cellIds = {id(DeckParam::kMasterGain)}; + master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true}; + master.captionToggle = {id(DeckParam::kLimiterEnable), 32}; + master.cellIds = {id(DeckParam::kMasterGain), -1}; + master.column = {id(DeckParam::kMasterMeter), kMasterMeterW}; out.push_back(std::move(master)); } + for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast(d.id)); return out; } @@ -257,6 +271,11 @@ LiveCommit deckParamCommit(DeckParam id) { case DeckParam::kVoiceMode: case DeckParam::kMonoTrigger: case DeckParam::kMasterGain: + case DeckParam::kLimiterEnable: + // MASTER's two readouts reach no parameter at all — the same footing as the overlay + // radios above. + case DeckParam::kMasterMeter: + case DeckParam::kMasterGr: case DeckParam::kCount: // not a control return LiveCommit::Reload; } diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index c93cbb8..42443de 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -87,6 +87,11 @@ enum class DeckParam { kVoiceMode, // Poly | Mono caption toggle (VOICE group) kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono) kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group) + kLimiterEnable, // master-bus limiter Off | On caption toggle (MASTER group) + // MASTER's two readouts. Neither reaches a parameter: the meter's only gesture is the + // click that clears its latched clip cap, and the bubble is a passive lamp. + kMasterMeter, + kMasterGr, kCount }; @@ -103,14 +108,10 @@ enum DeckGroupId { kGroupMaster, }; -// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice -// IS, Contour is how it moves over time, Spanning is what happens after the mixer. -enum class DeckRow { Sound, Contour, Spanning }; - -// Which row a group belongs to. Membership is a property of the GROUP; width is a property of -// its descriptor — separating them is what lets the row law be settled while the descriptors -// are still moving. Total over DeckGroupId by an exhaustive switch with no default, so a group -// added without a row cannot silently become Sound. +// Which row a group belongs to (the row vocabulary itself is knob_deck's — the layout is what +// reads it). Membership is a property of the GROUP; width is a property of its descriptor. +// Total over DeckGroupId by an exhaustive switch with no default, so a group added without a +// row cannot silently become Sound. DeckRow deckRowFor(DeckGroupId group); // Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE diff --git a/src/core/instrument/ui/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp index 66480ea..680dbb5 100644 --- a/src/core/instrument/ui/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -10,8 +10,17 @@ namespace { // The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell // already carries its own breathing room around the 28px knob), plus the optional row -// toggle after a kDeckToggleGap. +// toggle after a kDeckToggleGap. A spanning group's cells stack, so its knob row is one +// cell wide plus whatever readout column sits beside it. int knobRowWidth(const DeckGroupDesc& g) { + if (g.row == DeckRow::Spanning) { + int w = g.cellIds.empty() ? 0 : kDeckCellW; + if (g.column.id >= 0) { + if (w > 0) w += kDeckColumnGap; + w += g.column.width; + } + return w; + } int w = static_cast(g.cellIds.size()) * kDeckCellW; if (g.rowToggle.id >= 0) { if (w > 0) w += kDeckToggleGap; @@ -29,6 +38,24 @@ int captionRowWidth(const DeckGroupDesc& g) { return w; } +// One knob cell inside `cell`: the centered dial square, its concentric inner disc, and the +// label band beneath. +DeckCellLayout layoutCell(int id, const Rect& cell) { + DeckCellLayout c; + c.id = id; + c.cell = cell; + const int knobLeft = cell.x + (cell.width - kDeckKnobSize) / 2; + const int knobTop = cell.y + 4; + c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize); + const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2; + const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2; + c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize, + innerTopPx + kDeckInnerDialSize); + const int labelTop = knobTop + kDeckKnobSize + 4; + c.label = Rect::ltrb(cell.x, labelTop, cell.right(), labelTop + kDeckCellLabelH); + return c; +} + // Place one group's inner geometry given its box. DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { DeckGroupLayout out; @@ -46,7 +73,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2; out.captionRadio = DeckRadioLayout{ g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight, - radioTop + kDeckRadioSize)}; + radioTop + kDeckRadioSize), + g.captionRadio.passive}; captionRight = out.captionRadio.box.x - kDeckToggleGap; out.caption.width = captionRight - out.caption.x; } @@ -65,10 +93,34 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { placeToggle(g.captionToggle, out.captionToggle); placeToggle(g.captionToggle2, out.captionToggle2); + const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap; + + if (g.row == DeckRow::Spanning) { + // FIXED slots down the left column, one per declared id (reserves advance the slot + // without drawing a cell), spaced by a whole row pitch so slot k lands exactly on + // categorical row k's knob baseline. Deliberately NOT the run-division law below. + int slotTop = cellTop; + for (int id : g.cellIds) { + if (id >= 0) { + out.cells.push_back(layoutCell( + id, Rect::ltrb(innerLeft, slotTop, innerLeft + kDeckCellW, + slotTop + kDeckCellH))); + } + slotTop += kDeckGroupH + kDeckRowGap; + } + if (g.column.id >= 0) { + // ONE rect spanning every slot, not a readout per row. + const int colX = innerLeft + (g.cellIds.empty() ? 0 : kDeckCellW + kDeckColumnGap); + out.column = DeckColumnLayout{ + g.column.id, Rect::ltrb(colX, cellTop, colX + g.column.width, + box.bottom() - kDeckGroupPadY)}; + } + return out; + } + // Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared // id, reserves included). Integer division puts an indivisible residue in symmetric end // margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule. - const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap; const int runWidth = static_cast(g.cellIds.size()) * kDeckCellW; int presentCells = 0; for (int id : g.cellIds) { @@ -78,19 +130,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { int x = innerLeft + (runWidth - presentCells * cellW) / 2; for (int id : g.cellIds) { if (id < 0) continue; - DeckCellLayout c; - c.id = id; - c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH); - const int knobLeft = x + (cellW - kDeckKnobSize) / 2; - const int knobTop = cellTop + 4; - c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize); - const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2; - const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2; - c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize, - innerTopPx + kDeckInnerDialSize); - const int labelTop = knobTop + kDeckKnobSize + 4; - c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH); - out.cells.push_back(c); + out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW, + cellTop + kDeckCellH))); x += cellW; } if (g.rowToggle.id >= 0) { @@ -107,65 +148,110 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { return out; } +// The gutters between `count` groups whose widths total `total`, justified space-between +// inside `blockW`. Empty for a single group. +std::vector justifyGutters(int count, int total, int blockW) { + const int gutters = count - 1; + if (gutters <= 0) return {}; + const int slack = blockW - total; + if (slack < gutters * kDeckGroupGap) { + // The block cannot hold the row: minimum gutters, and the row overruns to the right + // rather than wrapping. Unreachable in the editor — see layoutDeck's header note. + return std::vector(static_cast(gutters), kDeckGroupGap); + } + const int base = slack / gutters; + const int residue = slack % gutters; // both non-negative: slack >= gutters * 12 > 0 + std::vector out(static_cast(gutters), base); + for (int i = 0; i < residue; ++i) ++out[static_cast(i)]; + return out; +} + } // namespace int deckGroupWidth(const DeckGroupDesc& g) { return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX; } -int deckRowCount(const std::vector& groups, int availWidth) { - if (groups.empty()) return 0; - int rows = 1; - int x = 0; +int deckRowCount(const std::vector& groups) { + bool sound = false, contour = false; for (const DeckGroupDesc& g : groups) { - const int w = deckGroupWidth(g); - if (x > 0 && x + kDeckGroupGap + w > availWidth) { - ++rows; - x = w; - } else { - x += (x > 0 ? kDeckGroupGap : 0) + w; - } + if (g.row == DeckRow::Sound) sound = true; + else if (g.row == DeckRow::Contour) contour = true; } - return rows; + return (sound ? 1 : 0) + (contour ? 1 : 0); } -int deckHeight(const std::vector& groups, int availWidth) { - const int rows = deckRowCount(groups, availWidth); - if (rows == 0) return 0; - return rows * kDeckGroupH + (rows - 1) * kDeckRowGap; +int deckHeight(const std::vector& groups) { + const int rows = deckRowCount(groups); + int h = rows > 0 ? rows * kDeckGroupH + (rows - 1) * kDeckRowGap : 0; + for (const DeckGroupDesc& g : groups) { + if (g.row == DeckRow::Spanning) h = (std::max)(h, kDeckSpanningH); + } + return h; } DeckLayout layoutDeck(const std::vector& groups, int left, int top, int availWidth) { DeckLayout out; if (groups.empty()) return out; - int x = left; - int y = top; - bool rowHasGroup = false; - out.rowCount = 1; - for (const DeckGroupDesc& g : groups) { - const int w = deckGroupWidth(g); - if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) { - // Wrap: whole trailing group onto the next row (mirror of deckRowCount). - ++out.rowCount; - x = left; - y += kDeckGroupH + kDeckRowGap; - rowHasGroup = false; - } - if (rowHasGroup) x += kDeckGroupGap; - const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH); - out.groups.push_back(layoutGroup(g, box)); - x = box.right(); - rowHasGroup = true; + + // Partition by the group's OWN row (indices, so the OUTPUT keeps deck order — the shell + // and the tests pair a layout with the descriptor at the same position). + std::vector rows[2]; + std::vector spanning; + for (std::size_t i = 0; i < groups.size(); ++i) { + const DeckRow row = groups[i].row; + if (row == DeckRow::Spanning) spanning.push_back(i); + else rows[row == DeckRow::Contour ? 1 : 0].push_back(i); } - out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap; + + // The spanning decks take the right edge; the row block is what is left of them. + int spanTotal = 0; + for (std::size_t i : spanning) spanTotal += deckGroupWidth(groups[i]); + if (!spanning.empty()) { + spanTotal += (static_cast(spanning.size()) - 1) * kDeckGroupGap; + } + const int blockW = availWidth - (spanning.empty() ? 0 : spanTotal + kDeckGroupGap); + + std::vector boxes(groups.size()); + int y = top; + for (const std::vector& row : rows) { + if (row.empty()) continue; // an absent category collapses; it leaves no empty band + ++out.rowCount; + int total = 0; + for (std::size_t i : row) total += deckGroupWidth(groups[i]); + const std::vector gutters = + justifyGutters(static_cast(row.size()), total, blockW); + int x = left; + for (std::size_t k = 0; k < row.size(); ++k) { + const int w = deckGroupWidth(groups[row[k]]); + boxes[row[k]] = Rect::ltrb(x, y, x + w, y + kDeckGroupH); + x += w; + if (k < gutters.size()) x += gutters[k]; + } + y += kDeckGroupH + kDeckRowGap; + } + + int sx = left + availWidth - spanTotal; + for (std::size_t i : spanning) { + const int w = deckGroupWidth(groups[i]); + boxes[i] = Rect::ltrb(sx, top, sx + w, top + kDeckSpanningH); + sx += w + kDeckGroupGap; + } + + out.groups.reserve(groups.size()); + for (std::size_t i = 0; i < groups.size(); ++i) { + out.groups.push_back(layoutGroup(groups[i], boxes[i])); + } + out.height = deckHeight(groups); return out; } DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { for (const DeckGroupLayout& g : layout.groups) { if (!contains(g.box, x, y)) continue; - if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) { + if (g.captionRadio.id >= 0 && !g.captionRadio.passive && + contains(g.captionRadio.box, x, y)) { return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false}; } for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) { @@ -185,7 +271,13 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)}; } } - return {}; // inside the box but on fence/padding — a miss (groups never overlap) + if (g.column.id >= 0 && contains(g.column.box, x, y)) { + return {DeckHitKind::Column, g.column.id, -1, false}; + } + // Inside the box but on fence/padding — a miss. First-match is exact while the boxes + // are disjoint, which they are at every width the row block fits; under the sub-floor + // overrun an overrunning row can reach the spanning deck and the row group answers. + return {}; } return {}; } diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index c5c6787..9e02e68 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -3,16 +3,10 @@ // action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is // param_slider's — a knob cell here is just a rect the shell composes it into. // -// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a -// caption row (caption left, the group's compact mode toggle right-anchored) over a knob -// row of equal-width cells (knob centered, label band beneath). A group may also place one -// two-segment toggle in the knob row after its cells. Groups that must keep stable -// geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows -// neighbouring groups. -// -// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group -// that does not fit the remaining width starts a new row (whole groups only, never -// split); the first group of a row always places even if wider than the row. +// A group is a fenced box: caption row (caption left, toggles and a corner radio +// right-anchored) over a knob row of equal-width cells, optionally followed by one row +// toggle. Row membership is a PROPERTY OF THE GROUP (DeckRow), never a wrap outcome — see +// the justification law at layoutDeck. #pragma once @@ -39,6 +33,7 @@ inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells inline constexpr int kDeckGroupGap = 12; // gap between groups on a row inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square +inline constexpr int kDeckColumnGap = 8; // the spanning deck's cell column -> its readout column // The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while // the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is // deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob. @@ -46,6 +41,15 @@ inline constexpr int kDeckInnerDialSize = 20; // One group box: padding + caption + gap + cell row + padding. inline constexpr int kDeckGroupH = kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY; +// The spanning deck's box: it stands across both categorical rows AND the seam between them, +// which is what lets its two cell slots land on the two rows' own knob baselines. +inline constexpr int kDeckSpanningH = 2 * kDeckGroupH + kDeckRowGap; + +// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice +// IS, Contour is how it moves over time, Spanning is what happens after the mixer. Declared +// here rather than with the group inventory because the LAYOUT is what reads it; which group +// sits in which row is deck_groups' deckRowFor. +enum class DeckRow { Sound, Contour, Spanning }; // --- The deck's width budget at the editor's floor ------------------------------------ // DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a @@ -67,15 +71,29 @@ struct DeckToggleDesc { }; // A single-square corner radio (an exclusive selector across groups, so the group itself -// carries no state). id -1 = absent. +// carries no state). id -1 = absent. `passive` reuses the same slot for a READOUT lamp: the +// hit-test skips it entirely, so the shell cannot accidentally grow a gesture on it. struct DeckRadioDesc { int id = -1; + bool passive = false; +}; + +// A full-height readout column beside a spanning group's cell slots (the output meter). Only +// a Spanning group may carry one — a categorical row's groups have no height to span. +// id -1 = absent. +struct DeckColumnDesc { + int id = -1; + int width = 0; }; // One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 // reserves one cell's WIDTH without a cell, and the cells present divide the whole run — // see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell // reserves for the caption text (this module does not measure text). +// +// A SPANNING group reads `cellIds` down instead of across: one FIXED kDeckCellW slot per +// declared id, at successive row baselines, reserves included. The run-division law above is +// horizontal only — applied vertically it would stretch a lone knob over the whole box. struct DeckGroupDesc { int id = 0; // shell group id (opaque here) int captionWidth = 60; @@ -87,6 +105,8 @@ struct DeckGroupDesc { DeckToggleDesc captionToggle2; std::vector cellIds; // knob cells; -1 reserves width only, no cell (see above) DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none + DeckRow row = DeckRow::Sound; + DeckColumnDesc column; // Spanning groups only; id -1 = none }; // --- Laid-out geometry --------------------------------------------------------------- @@ -100,6 +120,12 @@ struct DeckToggleLayout { struct DeckRadioLayout { int id = -1; Rect box; + bool passive = false; // a readout lamp, not a selector — see DeckRadioDesc +}; + +struct DeckColumnLayout { + int id = -1; + Rect box; }; struct DeckCellLayout { @@ -119,34 +145,45 @@ struct DeckGroupLayout { DeckToggleLayout captionToggle2; std::vector cells; DeckToggleLayout rowToggle; // id -1 when absent + DeckColumnLayout column; // id -1 when absent (Spanning groups only) }; struct DeckLayout { std::vector groups; - int rowCount = 0; - int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups + int rowCount = 0; // POPULATED categorical rows (0..2). A spanning deck is in neither. + int height = 0; // the tallest thing laid out; 0 for no groups }; -// Width of one group box: the wider of its caption row (caption + gap + toggle) and its -// knob row (cells + gap + row toggle), plus horizontal padding. +// Width of one group box: the wider of its caption row (caption + gap + toggles + radio) and +// its knob row, plus horizontal padding. A Spanning group's knob row is one cell wide plus +// its readout column, because its cells stack. int deckGroupWidth(const DeckGroupDesc& g); -// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap. -// 0 for an empty list. -int deckRowCount(const std::vector& groups, int availWidth); +// How many of the two categorical rows carry at least one group (0..2). Independent of width: +// row membership is the group's own property. +int deckRowCount(const std::vector& groups); -// Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell +// Total deck height: the categorical rows, or the spanning deck when it is taller. The shell // bottom-anchors a band of exactly this height. -int deckHeight(const std::vector& groups, int availWidth); +int deckHeight(const std::vector& groups); -// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's -// rule. Every rect is absolute. +// Lays the groups out from (left, top) within `availWidth`. Every rect is absolute, and +// `groups` comes back in DECK order — the same position as the descriptor it was built from, +// whichever row that descriptor landed in. +// +// Spanning groups are right-anchored at `left + availWidth` and take no part in either row's +// justification; the ROW BLOCK is what remains to their left. Inside the block each row is +// justified SPACE-BETWEEN: groups keep their natural widths and the slack becomes gutters, +// divided equally with the integer residue going to the leftmost ones. Decks are never +// stretched. Below the width the block needs, every gutter sits at kDeckGroupGap and the row +// overflows right rather than wrapping — the shell clamps the window to a floor that fits +// (sample_bands' kEditorMinWidth), so that degrade is unreachable in the editor. DeckLayout layoutDeck(const std::vector& groups, int left, int top, int availWidth); // --- Hit-test -------------------------------------------------------------------------- -enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio }; +enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Column }; struct DeckHit { DeckHitKind kind = DeckHitKind::None; @@ -157,8 +194,9 @@ struct DeckHit { // The deck element a point lands on: a knob cell (the whole cell, not just the knob // circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking -// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or -// the caption-row corner radio. Everything else — fence, padding, outside — misses. +// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, an +// interactive caption-row corner radio, or a spanning group's readout column. Everything +// else — fence, padding, a PASSIVE radio, outside — misses. DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); // The knob FACE a point lands on. id -1 is a miss. diff --git a/src/core/instrument/ui/master_meter.cpp b/src/core/instrument/ui/master_meter.cpp new file mode 100644 index 0000000..8b625e4 --- /dev/null +++ b/src/core/instrument/ui/master_meter.cpp @@ -0,0 +1,70 @@ +// master_meter.cpp — see master_meter.h. + +#include "core/instrument/ui/master_meter.h" + +namespace reasampler::instrument::ui { + +MeterRects meterRects(const Rect& column, LaneSplit split) { + MeterRects r; + if (column.width <= 0 || column.height <= 0) return r; + r.labels = Rect::ltrb(column.x, column.y, column.x + kMeterLabelW, column.bottom()); + const int fieldLeft = column.x + kMeterLabelW + kMeterLabelGap; + r.field = Rect::ltrb(fieldLeft, column.y, fieldLeft + kMeterFieldW, column.bottom()); + if (split == LaneSplit::Single) { + r.barA = r.field; + return r; + } + const int barW = (kMeterFieldW - kMeterBarGap) / 2; + r.barA = Rect::ltrb(fieldLeft, column.y, fieldLeft + barW, column.bottom()); + const int bLeft = r.barA.right() + kMeterBarGap; + r.barB = Rect::ltrb(bLeft, column.y, bLeft + barW, column.bottom()); + return r; +} + +int meterDbToY(const Rect& field, double db) { + const double norm = engine::meterNormFromDb(db); + const int y = field.bottom() - static_cast(norm * field.height + 0.5); + if (y < field.y) return field.y; + if (y > field.bottom()) return field.bottom(); + return y; +} + +MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block, + double elapsedSeconds) { + MasterMeterUi next; + next.left = engine::advanceMeter(prev.left, block.peakL, elapsedSeconds); + next.right = engine::advanceMeter(prev.right, block.peakR, elapsedSeconds); + // The audio thread's latch is the authoritative one: it sees every block, where this only + // ever samples the last block before the UI woke. A clip that came and went between two + // frames is invisible to the peaks above and would otherwise be lost. + if (block.clip) { + next.left.clip = true; + next.right.clip = true; + } + + const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0; + const double reduction = -engine::meterDbFromLinear(block.minGain); + const double fallen = prev.reductionDb - engine::kMeterFallDbPerSecond * dt; + next.reductionDb = reduction > fallen ? reduction : fallen; + if (next.reductionDb < 0.0) next.reductionDb = 0.0; + return next; +} + +bool meterClipped(const MasterMeterUi& m) { return m.left.clip || m.right.clip; } + +MasterMeterUi clearMasterMeterClip(MasterMeterUi prev) { + MasterMeterUi next = prev; + next.left = engine::clearMeterClip(prev.left); + next.right = engine::clearMeterClip(prev.right); + return next; +} + +bool grLampLit(const MasterMeterUi& m) { return m.reductionDb >= kGrLampFloorDb; } + +bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b) { + return a.left.levelDb == b.left.levelDb && a.right.levelDb == b.right.levelDb && + a.left.holdDb == b.left.holdDb && a.right.holdDb == b.right.holdDb && + meterClipped(a) == meterClipped(b) && grLampLit(a) == grLampLit(b); +} + +} // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/master_meter.h b/src/core/instrument/ui/master_meter.h new file mode 100644 index 0000000..b67523c --- /dev/null +++ b/src/core/instrument/ui/master_meter.h @@ -0,0 +1,71 @@ +// master_meter.h — the interior of the spanning deck's readout column: the label gutter and +// bar field it divides into, the dB->y map its scale draws against, and the per-instance UI +// state the bars are drawn from. knob_deck hands over the column rect; this lays out inside +// it. Every timed, logged or latched quantity lives here, on the UI thread — the audio thread +// publishes raw block magnitudes and converts nothing. + +#pragma once + +#include "core/instrument/engine/meter_ballistics.h" +#include "core/instrument/ui/editor_geometry.h" +#include "core/instrument/ui/sample_bands.h" // LaneSplit + +namespace reasampler::instrument::ui { + +inline constexpr int kMeterLabelW = 22; // the numeral gutter, left of the bars +inline constexpr int kMeterLabelGap = 4; +inline constexpr int kMeterFieldW = 36; // one 36px bar, or two 17px bars kMeterBarGap apart +inline constexpr int kMeterBarGap = 2; + +// A tick every 6 dB up the scale; every other one carries a numeral, and 0 dB draws heavier. +inline constexpr double kMeterTickStepDb = 6.0; + +struct MeterRects { + Rect labels; // the numeral gutter + Rect field; // the whole bar field + Rect barA; // Single: the one wide bar. Stereo: L. + Rect barB; // empty() unless Stereo +}; + +// `split` is the RESOLVED lane decision waveformSurface already folds (channel mode AND the +// source's channel count), not "is the instrument in stereo mode": a mono source under stereo +// mode is dual-mono, and two identical bars would be a lie. One source, two views, one rule. +MeterRects meterRects(const Rect& column, LaneSplit split); + +// y of `db` inside the bar field — kMeterTopDb at the top edge, kMeterFloorDb at the bottom, +// linear in dB between, clamped outside. +int meterDbToY(const Rect& field, double db); + +// The per-instance UI state behind the column. One clip latch per channel (the cap is drawn +// once, over whichever of them tripped). +struct MasterMeterUi { + engine::MeterState left; + engine::MeterState right; + double reductionDb = 0.0; // how far the limiter is pulling gain down; 0 = not working +}; + +// What the audio thread published about the last block, in this module's own vocabulary. +struct MasterMeterBlock { + double peakL = 0.0; + double peakR = 0.0; + double minGain = 1.0; // the limiter's smallest gain over the block; 1 = no reduction + bool clip = false; // the AUDIO thread's own latch +}; + +MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block, + double elapsedSeconds); + +bool meterClipped(const MasterMeterUi& m); +MasterMeterUi clearMasterMeterClip(MasterMeterUi prev); + +// Whether two states would DRAW the same, so the UI tick can repaint only on a change and an +// idle editor costs nothing. Compares what the column shows — the bar, the held tick, the cap +// and the lamp — not every stored double. +bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b); + +// The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs +// a floor rather than a bare non-zero test. +inline constexpr double kGrLampFloorDb = 0.5; +bool grLampLit(const MasterMeterUi& m); + +} // namespace reasampler::instrument::ui diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index b958a55..72c9d40 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -12,7 +12,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`, `param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, `deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, -`limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and +`limiter`, `meter_ballistics`, `master_meter`, `reasampler_uid.h`) lives in `core/instrument/*` and `core/wire` and is documented there — this directory consumes it but does not own it. ## Invariants @@ -124,6 +124,13 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h the very instance whose frame is on the stack. Deferring by one tick is same-thread and in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into one. +- **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run + BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but + the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the + timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed. + At that cadence the bar falls 10 dB per redraw and the 1.5 s hold spans three frames: + correct against `meter_ballistics`' contract, coarse to the eye. A meter-rate timer is a + separate change and is not in yet. - The bake's availability probe runs on the SAME tick that paints the button, so the control can never be enabled on one tick and refuse on the next. The bake Hold control's applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index d03f76d..cb025e4 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -89,7 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") waveform_view loop_marks bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage - limiter meter_ballistics bake_hold + limiter meter_ballistics master_meter bake_hold file_bytes curve_law stroke_aa curve_tessellate bake_plan bake_render bake_reset bake_wire wav_codec) diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index cd8042e..63c8a91 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -75,10 +75,10 @@ double curveExponentFor(int id, const PlaySeconds& play) { ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const { // The ONE resolve every paint and hit-test path goes through, so the band stack, the // chrome interior, and the deck descriptors can never be derived three different ways. - // The deck's own wrapped height is the only interior measurement the allocator needs. + // The deck's own height is the only interior measurement the allocator needs. FaceLayout fl; fl.deckDescs = sampleDeckGroups(params_.play.playMode); - fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad)); + fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs)); fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize); return fl; } diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index cbdc930..16d89d0 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -64,6 +64,18 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { applyParamControl(hit.id, 0.0, hit.segment); commitAndReload(); break; + case ParamControl::kLimiterEnable: { + const bool on = (hit.segment == 1); + if (on != params_.limiterEnabled) { + params_.limiterEnabled = on; + // The processor's own funnel mirrors the audio-thread flag and requests the + // host's latency restart; a reload would re-decode a WAV the toggle cannot + // change. The local snapshot moves with it so a later commit agrees. + processor_->setLimiterEnabled(on); + } + invalidate(); + break; + } default: { // Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable, // and the three env-mode toggles). @@ -80,6 +92,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { } return true; } + if (hit.kind == DeckHitKind::Column) { + // The meter's ONLY gesture: clear the latched clip cap. Both latches go — the audio + // thread's is what the next tick would otherwise re-latch the UI's from. + masterMeter_ = clearMasterMeterClip(masterMeter_); + processor_->clearMasterBusClip(); + invalidate(); + return true; + } if (hit.kind == DeckHitKind::Knob) { // Knobs of a disabled group are drawn but inert. if (deckKnobDisabled(hit.id)) return true; @@ -179,6 +199,9 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width); const DeckHit dh = hitTestDeck(dl, x, y); if (dh.kind == DeckHitKind::None) return {}; + // The meter reports its own state continuously; a hover on it would only mean "the clip + // cap is clearable", which the cap's presence already says. + if (dh.kind == DeckHitKind::Column) return {}; if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id}; if (dh.kind == DeckHitKind::Knob && dh.inner && curveParamFor(static_cast(dh.id)) != ParamControl::kCount) { diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index 96af2d5..ec6388a 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -12,6 +12,8 @@ #include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state) #include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize +#include "core/instrument/ui/master_meter.h" // the bus meter's column interior + ballistics +#include "core/instrument/ui/waveform_view.h" // waveformSurface (THE lane-split fold) #include "shell/instrument/editor_internal.h" // kit adapters + knob face #include "shell/instrument/reasampler_processor.h" @@ -24,6 +26,75 @@ using namespace reasampler::instrument::ui; // deck geometry // are chrome you read once — this is the readout you read while turning something. constexpr Font kCellLabelFont = Font::Label; +namespace { + +// One tick's numeral, in whole dB ("0", "-12"). No unit suffix — the column is 22px wide and +// the scale's unit is stated once, by the caption. +std::string tickLabel(int db) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "%d", db); + return std::string(buf); +} + +// The MASTER column: dB scale in the label gutter, one or two bars, the held peak tick, and +// the latched clip cap. `split` is waveformSurface's own lane decision — see master_meter.h. +void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi& state, + LaneSplit split) { + if (column.width <= 0 || column.height <= 0) return; + const MeterRects m = meterRects(column, split); + fillSurface(bmp, toKitBox(m.field), Role::BgCell, InteractionState::Rest); + + // Scale: a rule every 6 dB, numeralled every 12 with 0 dB heavier — the reference the + // limiter-off case is read against. + const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); + for (int db = static_cast(instrument::engine::kMeterTopDb); + db >= static_cast(instrument::engine::kMeterFloorDb); + db -= static_cast(kMeterTickStepDb)) { + const int y = meterDbToY(m.field, db); + const bool zero = (db == 0); + const bool numeralled = zero || (db % 12 == 0); + LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1, + zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0); + if (numeralled) { + kitText(bmp, Rect::ltrb(m.labels.x, y - 5, m.labels.right(), y + 5), + tickLabel(db).c_str(), Font::Micro, Role::TextDim, Align::Right); + } + } + + // The bars. A single-lane surface shows ONE bar off the louder channel: the two are the + // same signal there (dual-mono), so two bars would be a duplicate rather than a reading. + const LICE_pixel barInk = toLice(roleColor(Role::AccentPrimary)); + const LICE_pixel holdInk = toLice(roleColor(Role::TextPrimary)); + const auto drawBar = [&](const Rect& bar, const instrument::engine::MeterState& ch) { + if (bar.empty()) return; + const int top = meterDbToY(bar, ch.levelDb); + if (top < bar.bottom()) { + LICE_FillRect(bmp, bar.x, top, bar.width, bar.bottom() - top, barInk, 1.0f, 0); + } + if (ch.holdDb > instrument::engine::kMeterFloorDb) { + // Clamped so the 2px tick cannot hang past the bar when the hold sits on the floor. + const int hold = (std::min)(meterDbToY(bar, ch.holdDb), bar.bottom() - 2); + LICE_FillRect(bmp, bar.x, hold, bar.width, 2, holdInk, 1.0f, 0); + } + }; + if (split == LaneSplit::Single) { + const instrument::engine::MeterState& loudest = + state.left.levelDb >= state.right.levelDb ? state.left : state.right; + drawBar(m.barA, loudest); + } else { + drawBar(m.barA, state.left); + drawBar(m.barB, state.right); + } + + // The clip cap: latched over the whole field, click to clear. + if (meterClipped(state)) { + LICE_FillRect(bmp, m.field.x, m.field.y, m.field.width, 3, + toLice(roleColor(Role::Warn)), 1.0f, 0); + } +} + +} // namespace + void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { const Rect& deckArea = fl.bands.decks; if (deckArea.width <= 0 || deckArea.height <= 0) return; @@ -31,6 +102,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { const PlaySeconds& play = params_.play; const bool isMono = (voiceMode_ == VoiceMode::Mono); const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); + // The meter's bar count is the SAME resolved decision the waveform's lane split is — read + // off waveformSurface rather than re-derived, so it can never become a second rule. + const LaneSplit meterSplit = + waveformSurface(fl.bands.waveform, channelMode_ == ChannelMode::Stereo, + channelPcmFor(selectedId_).channelCount) + .laneCount == 2 + ? LaneSplit::Stereo + : LaneSplit::Single; // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. @@ -122,9 +201,19 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { } kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); + // The gain-reduction lamp. ROUND, where the overlay radios in this same slot are + // square, so it reads as a lamp rather than a control. + if (g.captionRadio.id >= 0 && g.captionRadio.passive) { + const Rect& rb = g.captionRadio.box; + const float r = rb.width / 2.0f - 0.5f; + LICE_FillCircle(bmp, rb.x + rb.width / 2.0f, rb.y + rb.height / 2.0f, r, + toLice(roleColor(grLampLit(masterMeter_) ? Role::Warn + : Role::LineHairline)), + 1.0f, 0, true); + } // The overlay-select radio: filled in the tertiary accent (the colour the overlay // traces in) when this group's envelope is the one on the waveform, hollow otherwise. - if (g.captionRadio.id >= 0) { + if (g.captionRadio.id >= 0 && !g.captionRadio.passive) { // overlayEnvForRadio returns kNone for BOTH "not a radio id" and "no selection" — // a non-radio id must never read as lit just because nothing is selected, so the // picked env has to be checked against kNone itself, not just matched by equality. @@ -164,6 +253,15 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { case ParamControl::kFilterEnable: drawToggle(t, "Off", "On", play.filter.enabled, false); break; + case ParamControl::kFilterLaw: + drawToggle(t, "Band", "Notch", + play.filter.settings.morphLaw == + instrument::engine::filter::MorphLaw::HighNotchLow, + !play.filter.enabled); + break; + case ParamControl::kLimiterEnable: + drawToggle(t, "Off", "On", params_.limiterEnabled, false); + break; case ParamControl::kAmpEnvMode: drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false); break; @@ -176,19 +274,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { default: break; } } - // Row toggles: VOICE's Retrig|Legato (live only in Mono) and FILTER's morph law. + // The one row toggle left: VOICE's Retrig|Legato, live only in Mono. if (g.rowToggle.id >= 0) { - if (static_cast(g.rowToggle.id) == ParamControl::kFilterLaw) { - drawToggle(g.rowToggle, "Band", "Notch", - play.filter.settings.morphLaw == - instrument::engine::filter::MorphLaw::HighNotchLow, - !play.filter.enabled); - } else { - drawToggle(g.rowToggle, "Retrig", "Legato", - monoTrigger_ == MonoTrigger::Legato, !isMono); - } + drawToggle(g.rowToggle, "Retrig", "Legato", + monoTrigger_ == MonoTrigger::Legato, !isMono); } + if (g.column.id >= 0) paintMeterColumn(bmp, g.column.box, masterMeter_, meterSplit); + // The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry. // The predicate is the input side's, so the drawn state and the inert grab agree. for (const DeckCellLayout& c : g.cells) { diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index bcff8e1..4da27de 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -107,6 +107,26 @@ void ReaSamplerEditor::onSyncTimer() { // rebuild the instrument and repaint under the cursor, yanking the edit — the next // tick picks up the change after release. if (!processor_) return; + + // The meter runs on EVERY tick, the in-flight-drag case included: a drag suppresses the + // reload poll below, but the bus keeps sounding and a frozen bar would misreport it. + // Elapsed time is measured rather than assumed — WM_TIMER's period is a request. + { + const unsigned long long now = GetTickCount64(); + const double elapsed = meterTickMs_ == 0 + ? 0.0 + : static_cast(now - meterTickMs_) / 1000.0; + meterTickMs_ = now; + const MasterBusMeter bus = processor_->masterBusMeter(); + const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter( + masterMeter_, + {bus.peakL, bus.peakR, bus.minGain, bus.clip}, + elapsed); + const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_); + masterMeter_ = advanced; + if (changed) invalidate(); + } + if (drag_ != DragKind::kNone) return; // defer past the in-flight edit // Resolve the bake affordance's availability on the SAME tick that paints it, so it diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 2cea870..397d7e4 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -20,6 +20,7 @@ #include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam) #include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band) #include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine) +#include "core/instrument/ui/master_meter.h" // MasterMeterUi (the bus meter's UI-side state) #include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator) #include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks) #include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar @@ -498,6 +499,11 @@ private: std::string searchQuery_; // type-to-filter narrow; "" = no search bool searchFocused_ = false; // whether the search box has keyboard focus + // The MASTER deck's meter, advanced from the published block magnitudes on the sync tick + // (see onSyncTimer for why it runs mid-drag too). meterTickMs_ 0 = never advanced. + instrument::ui::MasterMeterUi masterMeter_; + unsigned long long meterTickMs_ = 0; + // Hover state (transient, never persisted). HoverTarget hover_; // the interactive element under the pointer #ifdef _WIN32 diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 6fa95b9..15535b4 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -125,10 +125,11 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() { cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel), cell(DeckParam::kFilterKeyTrack)}; CHECK(f.cellIds == expected); - // Off by default is a state question, but reachability is a layout one: the enable - // toggle is in the caption row and the morph law in the knob row. + // Off by default is a state question, but reachability is a layout one: BOTH toggles now + // ride the caption row, which is what takes the group from 524 to 432. CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable)); - CHECK(f.rowToggle.id == cell(DeckParam::kFilterLaw)); + CHECK(f.captionToggle2.id == cell(DeckParam::kFilterLaw)); + CHECK(f.rowToggle.id == -1); const DeckGroupDesc& fe = g[static_cast(indexOfGroup(g, kGroupFilterEnv))]; const std::vector env = { @@ -141,14 +142,24 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() { CHECK(fe.rowToggle.id == -1); } -// Exactly the three envelope decks carry an overlay-select radio, each its own, and no other -// group has one — the exclusivity the shell enforces is only meaningful if the id space is. -static void testOnlyTheThreeEnvelopeDecksCarryARadio() { +// Exactly the three envelope decks carry a SELECTABLE overlay radio, each its own, and no +// other group has one — the exclusivity the shell enforces is only meaningful if the id space +// is. MASTER occupies the same corner slot with a PASSIVE lamp, which is a different thing: +// it must never be counted as, or reachable as, a selector. +static void testOnlyTheThreeEnvelopeDecksCarryASelectableRadio() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); int radios = 0; for (const DeckGroupDesc& d : g) { if (d.captionRadio.id < 0) continue; + if (d.captionRadio.passive) { + CHECK(d.id == kGroupMaster); + CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr)); + // A passive slot names no overlay, so no click on it could select one even if + // the hit-test ever handed it through. + CHECK(overlayEnvForRadio(d.captionRadio.id) == OverlayEnv::kNone); + continue; + } ++radios; const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect) : d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect) @@ -234,27 +245,36 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() { CHECK(a.cellIds.size() == b.cellIds.size()); CHECK(b.cellIds[4] == -1); // the Trigger face's one reserved blank // Every other group is mode-independent, so the whole deck's height is too. - CHECK(deckHeight(gate, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth)); + CHECK(deckHeight(gate) == deckHeight(trig)); } -static void testWrappedDeckHeightAtTheEditorFloorWidth() { - const std::vector g = sampleDeckGroups(PlayMode::Gate); - // An UPPER BOUND, not an equality. The greedy whole-group wrap is still what decides row - // membership until the reflow replaces it with the categorical partition, and at this width - // it happens to pack two ragged rows with the wrong composition. Bounding it is a real - // regression canary — a third row would cost the waveform 112 px again — without turning a - // wrap outcome into a claim. - const int rows = deckRowCount(g, kAvailAtMinWidth); - CHECK(rows <= 2); - CHECK(deckHeight(g, kAvailAtMinWidth) == rows * kDeckGroupH + (rows - 1) * kDeckRowGap); +// TWO rows plus the spanning deck, BY CONSTRUCTION: the row count is read off the group +// inventory's own row assignment, not observed as a pack outcome, so it holds at every width. +static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + CHECK(deckRowCount(g) == 2); + CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap); + CHECK(deckHeight(g) == 216); - // Whole groups only, never split: every group's box lies inside the available width or is - // the first of its row. - const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); - CHECK(dl.groups.size() == g.size()); - for (const DeckGroupLayout& gl : dl.groups) { - CHECK(gl.box.x >= kPad); - CHECK(gl.box.height == kDeckGroupH); + for (int avail : {kAvailAtMinWidth, kAvailAtMinWidth + 200, 4000}) { + const DeckLayout dl = layoutDeck(g, kPad, 0, avail); + CHECK(dl.rowCount == 2); + CHECK(dl.height == 216); + CHECK(dl.groups.size() == g.size()); + int rowTops[2] = {0, kDeckGroupH + kDeckRowGap}; + for (const DeckGroupLayout& gl : dl.groups) { + const DeckRow row = deckRowFor(static_cast(gl.id)); + if (row == DeckRow::Spanning) { + CHECK(gl.box.y == 0); + CHECK(gl.box.height == kDeckSpanningH); + CHECK(gl.box.right() == kPad + avail); // right-anchored at every width + } else { + CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]); + CHECK(gl.box.height == kDeckGroupH); + } + } + } } } @@ -265,15 +285,15 @@ static void testWrappedDeckHeightAtTheEditorFloorWidth() { static void testDeckFitsInsideTheEnforcedMinimumWindow() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); - const int h = deckHeight(g, kAvailAtMinWidth); + const int h = deckHeight(g); const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); - CHECK(deckRowCount(g, kAvailAtMinWidth) <= 2); // either face; see the bound above + CHECK(deckRowCount(g) == 2); // either face CHECK(b.decks.height == h); - // The raised floor hands the waveform the reflow's 112 px two waves early: at two rows - // the deck band is 216 and the waveform 358, against 328/246 before. Bounded rather - // than pinned for the same reason the row count is. - CHECK(b.decks.height <= 2 * kDeckGroupH + kDeckRowGap); - CHECK(b.waveform.height >= 358); + // The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the + // waveform 358, against 328/246 before. Pinned now that both are reached by + // construction rather than by a pack outcome. + CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap); + CHECK(b.waveform.height == 358); // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the // deck down until the waveform hits its floor, so any deck too tall to fit stops // landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the @@ -293,6 +313,14 @@ static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() { CHECK(kEditorCeilingWidth - kEditorMinWidth == 90); // The reflow's 112 px goes entirely to the waveform, so the height does not move. CHECK(kEditorMinHeight == 680); + // The floor did not move to make the reflow fit — the reflow was fitted to the floor. This + // wave spends the budget it was handed; it does not widen it. + CHECK(kEditorMinWidth == 1190); + CHECK(kEditorMinWidth <= kEditorCeilingWidth); + CHECK(kEditorMinHeight <= 720); + // And the row block really is what the two rows justify inside — derived from the floor + // and the spanning reserve, not restated. + CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW); } static void testEveryDeckGroupBelongsToExactlyOneRow() { @@ -320,38 +348,180 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() { } } -// What the budget can already be measured against. The contour row fits today and MASTER has -// not touched its reserve; the SOUND row does not fit yet and must not be forced to — it is -// 1030 against the 1020 block, and the 50 px deficit is exactly what two later descriptor -// changes buy: PITCH becoming PITCH/RATE (+42) and FILTER's Band|Notch moving from the knob -// row to the caption corner (−92), netting 980. The fit is asserted when they land, not here. -static void testTheContourRowAndTheSpanningDeckFitTheBudget() { +// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes: +// it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move +// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold +// them at 312 in Trigger too — asserted here rather than assumed. +static void testBothRowsAndTheSpanningDeckFitTheBudget() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); - int contourWidth = 0, contourGroups = 0, spanningWidth = 0; + int width[3] = {0, 0, 0}; + int count[3] = {0, 0, 0}; for (const DeckGroupDesc& d : g) { - const DeckRow row = deckRowFor(static_cast(d.id)); - if (row == DeckRow::Contour) { - contourWidth += deckGroupWidth(d); - ++contourGroups; - } else if (row == DeckRow::Spanning) { - spanningWidth += deckGroupWidth(d); - } + const int r = static_cast(deckRowFor(static_cast(d.id))); + width[r] += deckGroupWidth(d); + ++count[r]; + } + const int sound = static_cast(DeckRow::Sound); + const int contour = static_cast(DeckRow::Contour); + const int spanning = static_cast(DeckRow::Spanning); + + CHECK(count[sound] == 4); + CHECK(width[sound] == 980); // 192 + 432 + 192 + 164 + CHECK(count[contour] == 3); + CHECK(width[contour] == 876); // 252 + 312 + 312 + CHECK(count[spanning] == 1); + CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent + + for (int r : {sound, contour}) { + CHECK(width[r] <= kDeckRowBlockW); + // Slack enough that no gutter in the row falls under the minimum. + CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap); } - // 252 + 312 + 312. Mode-stable because FILTER ENV's and AMP's reserve slots hold them - // at 312 in Trigger as well as Gate. - CHECK(contourGroups == 3); - CHECK(contourWidth == 876); - CHECK(contourWidth <= kDeckRowBlockW); - // Slack enough that neither of the row's two gutters falls under the minimum. - CHECK(kDeckRowBlockW - contourWidth >= (contourGroups - 1) * kDeckGroupGap); - // MASTER is 72 today against a 142 reserve: the double-height interior it grows into is - // budgeted for, not yet spent. - CHECK(spanningWidth == 72); - CHECK(spanningWidth <= kDeckSpanningW); } } +// The gutters the justification law produces at the floor, and the alignment they buy. The +// SPEC (instrument-control-surface.md §1.2/§1.3) states row 1 as 12/14/14 with both filter +// edges at x = 636; equal division of 40 px over three gutters cannot produce that, so what is +// pinned here is what the LAW produces — 14/13/13, filter edge 638 — with row 2 exact at +// 72/72 and 636. The 2 px is flagged for review; a row block of 1028 (floor 1198, still under +// the 1280 ceiling) is the width at which the law puts both edges on 640. +static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + + const auto box = [&](int id) { + return dl.groups[static_cast(indexOfGroup(g, id))].box; + }; + // Row 1: flush left, flush right on the block, gutters 14/13/13. + CHECK(box(kGroupPitch).x == kPad); + CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 14); + CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 13); + CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 13); + CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW); + + // Row 2: flush left, flush right, and its two gutters exactly equal — the property the + // 1020 block was chosen for, and the one it does deliver. + CHECK(box(kGroupPitchEnv).x == kPad); + CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 72); + CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 72); + CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW); + + // The filter tie-line, block-relative. Row 2 lands on the specified 636; row 1 lands 2 px + // past it. See this test's header. + CHECK(box(kGroupFilterEnv).right() - kPad == 636); + CHECK(box(kGroupFilter).right() - kPad == 638); + + // MASTER is right-anchored outside the block, one kDeckGroupGap clear of it. + CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap); + CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth); +} + +// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay +// flush at every width — the property the exact-at-the-floor numbers above are one point of. +// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides +// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past +// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure. +static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + int lastDrift = 1 << 20; // sentinel above any real drift + for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) { + const DeckLayout dl = layoutDeck(g, kPad, 0, avail); + const DeckGroupLayout* prev = nullptr; + DeckRow prevRow = DeckRow::Spanning; + for (const DeckGroupLayout& gl : dl.groups) { + const DeckRow row = deckRowFor(static_cast(gl.id)); + if (row != DeckRow::Spanning && prev && row == prevRow) { + CHECK(gl.box.x - prev->box.right() >= kDeckGroupGap); + } + prev = ≷ + prevRow = row; + } + const auto right = [&](int id) { + return dl.groups[static_cast(indexOfGroup(g, id))].box.right(); + }; + // Flush right on the block at every width, both rows. + CHECK(right(kGroupVoice) == right(kGroupAmpEnv)); + // Monotone in width rather than oscillating: row 2's two gutters absorb slack + // faster than row 1's three, so the gap only ever opens. + const int drift = right(kGroupFilter) - right(kGroupFilterEnv); + CHECK(drift <= lastDrift); + lastDrift = drift; + } + // It really does open up, and by far more than the 2 px it starts at — separation + // above the floor is the accepted outcome, not a near-miss to be pinned back. + CHECK(lastDrift < -50); + } +} + +// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own +// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect +// across both, never a readout per row. +static void testTheMasterDeckInteriorLandsOnBothRowBaselines() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + const DeckGroupLayout& m = + dl.groups[static_cast(indexOfGroup(g, kGroupMaster))]; + + CHECK(m.box.width == 142); + CHECK(m.box.height == 216); + // 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total. + CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62 + kDeckGroupPadX == 142); + + // One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed + // position and draws nothing. + CHECK(m.cells.size() == 1); + CHECK(m.cells[0].id == cell(DeckParam::kMasterGain)); + CHECK(m.cells[0].cell.y - m.box.y == 26); + const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap; + CHECK(reserveTop - m.box.y == 138); + + // The two baselines are row 1's and row 2's own. + const DeckGroupLayout& filter = + dl.groups[static_cast(indexOfGroup(g, kGroupFilter))]; + const DeckGroupLayout& amp = + dl.groups[static_cast(indexOfGroup(g, kGroupAmpEnv))]; + CHECK(m.cells[0].cell.y == filter.cells[0].cell.y); + CHECK(reserveTop == amp.cells[0].cell.y); + + // The meter: one rect spanning both baselines, 62 x 186. + CHECK(m.column.id == cell(DeckParam::kMasterMeter)); + CHECK(m.column.box.width == 62); + CHECK(m.column.box.height == 186); + CHECK(m.column.box.y == m.cells[0].cell.y); + CHECK(m.column.box.bottom() - m.box.y == 212); +} + +// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left +// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law +// applied vertically — which would stretch the one gain knob over the whole 186 px. +static void testTheMasterColumnDoesNotDivideItsRunVertically() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + const DeckGroupLayout& m = + dl.groups[static_cast(indexOfGroup(g, kGroupMaster))]; + CHECK(m.cells[0].cell.height == kDeckCellH); + CHECK(m.cells[0].cell.width == kDeckCellW); + // Under the run-division law the lone present cell would take the whole two-slot run; + // here it takes exactly one slot and leaves the rest empty. + CHECK(m.cells[0].cell.height < m.column.box.height); + CHECK(m.cells[0].cell.bottom() < m.column.box.bottom()); + CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize); + // And dropping the reserve does not move the gain knob or the meter — the slot below it is + // reserved height, so nothing above it depends on whether it is there. + std::vector noReserve = g; + for (DeckGroupDesc& d : noReserve) { + if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)}; + } + const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth); + const DeckGroupLayout& m2 = + dl2.groups[static_cast(indexOfGroup(noReserve, kGroupMaster))]; + CHECK(m2.cells[0].cell == m.cells[0].cell); + CHECK(m2.column.box == m.column.box); +} + static void testHitTestResolvesTheNewFilterControls() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); @@ -377,10 +547,15 @@ static void testHitTestResolvesTheNewFilterControls() { f.captionToggle.seg1.y + 2); CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1); - const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2); - CHECK(band.kind == DeckHitKind::RowToggle); + // The morph law answers from its NEW home in the caption row, and as a CaptionToggle — + // the shell's toggle branch handles both kinds, so the move must not change the id or the + // segment either. + const DeckHit band = hitTestDeck(dl, f.captionToggle2.seg0.x + 2, + f.captionToggle2.seg0.y + 2); + CHECK(band.kind == DeckHitKind::CaptionToggle); CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0); - const DeckHit notch = hitTestDeck(dl, f.rowToggle.seg1.x + 2, f.rowToggle.seg1.y + 2); + const DeckHit notch = hitTestDeck(dl, f.captionToggle2.seg1.x + 2, + f.captionToggle2.seg1.y + 2); CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1); // The filter-envelope knobs resolve too, and are distinct ids from the amp's. @@ -455,7 +630,8 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode, DeckParam::kVoiceCount, DeckParam::kVoiceMode, - DeckParam::kMonoTrigger, DeckParam::kMasterGain, + DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable, + DeckParam::kMasterMeter, DeckParam::kMasterGr, }; for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload); @@ -640,6 +816,9 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { const DeckLayout dl = layoutDeck(g, kPad, 0, avail); CHECK(dl.groups.size() == g.size()); for (std::size_t i = 0; i < dl.groups.size(); ++i) { + // The spanning deck's slots STACK — the run-division law this pins is the + // horizontal one, and its vertical guard is its own test. + if (g[i].row == DeckRow::Spanning) continue; const DeckGroupLayout& lay = dl.groups[i]; const int reserved = static_cast(g[i].cellIds.size()) * kDeckCellW; const std::size_t present = lay.cells.size(); @@ -687,30 +866,37 @@ static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() { CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over } -// Gate is the common face and its group widths are what the width budget is spent against: -// pin them at the floor so a later edit anywhere in the deck cannot move one silently. -// (Measured from the shipped descriptors, not copied out of a failing run.) The WRAP row a -// group lands on is deliberately NOT pinned — that is the interim greedy pack the reflow -// replaces, and deckRowFor is where row membership is asserted. -static void testGateModeGroupWidthsAreUnchanged() { - const std::vector g = sampleDeckGroups(PlayMode::Gate); +// Every group's width, in BOTH play modes, against the measured layout table +// (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the +// reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the +// contour row's 876 a constant rather than a Gate-only fact. +static void testEveryGroupWidthMatchesTheMeasuredLayout() { const struct { int id; int width; } want[] = { - {kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 524}, + {kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432}, {kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192}, - {kGroupVoice, 164}, {kGroupMaster, 72}, + {kGroupVoice, 164}, {kGroupMaster, 142}, }; - CHECK(g.size() == sizeof(want) / sizeof(want[0])); - const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); - for (std::size_t i = 0; i < dl.groups.size(); ++i) { - CHECK(dl.groups[i].id == want[i].id); - CHECK(deckGroupWidth(g[i]) == want[i].width); - CHECK(dl.groups[i].box.width == want[i].width); - // Every box lands on a row line, and no lower than the second — the same two-row - // bound the deck height carries. - CHECK(dl.groups[i].box.y % (kDeckGroupH + kDeckRowGap) == 0); - CHECK(dl.groups[i].box.y <= kDeckGroupH + kDeckRowGap); - // Gate carries no reserves, so its cells are the deck's base size. - for (const DeckCellLayout& c : dl.groups[i].cells) CHECK(c.cell.width == kDeckCellW); + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + CHECK(g.size() == sizeof(want) / sizeof(want[0])); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + for (const auto& w : want) { + const int i = indexOfGroup(g, w.id); + CHECK(i >= 0); + if (i < 0) continue; + CHECK(deckGroupWidth(g[static_cast(i)]) == w.width); + const DeckGroupLayout& lay = + dl.groups[static_cast(indexOfGroup(g, w.id))]; + CHECK(lay.box.width == w.width); + } + // Gate carries no reserves, so its cells are the deck's base size; Trigger's two + // reduced faces divide the same reserved run between fewer cells and get wider ones. + for (const DeckGroupLayout& lay : dl.groups) { + for (const DeckCellLayout& c : lay.cells) { + CHECK(c.cell.width >= kDeckCellW); + if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW); + } + } } } @@ -725,8 +911,10 @@ static bool sameLayout(const DeckLayout& a, const DeckLayout& b) { const DeckGroupLayout& x = a.groups[i]; const DeckGroupLayout& y = b.groups[i]; if (x.id != y.id || !(x.box == y.box) || !(x.caption == y.caption)) return false; - if (x.captionRadio.id != y.captionRadio.id || !(x.captionRadio.box == y.captionRadio.box)) - return false; + if (x.captionRadio.id != y.captionRadio.id || + !(x.captionRadio.box == y.captionRadio.box) || + x.captionRadio.passive != y.captionRadio.passive) return false; + if (x.column.id != y.column.id || !(x.column.box == y.column.box)) return false; if (!sameToggle(x.captionToggle, y.captionToggle) || !sameToggle(x.captionToggle2, y.captionToggle2) || !sameToggle(x.rowToggle, y.rowToggle)) return false; @@ -786,19 +974,23 @@ int main() { testCurveTargetNamesEachCellsOwnDestination(); testVelocityCellsHitTestWithinTheirGroup(); testFilterGroupCarriesItsToneControlsPlusModulation(); - testOnlyTheThreeEnvelopeDecksCarryARadio(); + testOnlyTheThreeEnvelopeDecksCarryASelectableRadio(); testGateAndTriggerFacesCarryTheirOwnShapes(); testOnlySlopedStageKnobsCarryAnInnerCurveDial(); testAmpGroupWidthSurvivesAGateTriggerFlip(); - testWrappedDeckHeightAtTheEditorFloorWidth(); + testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction(); testDeckFitsInsideTheEnforcedMinimumWindow(); testNoFaceLeavesSlackWhereItsDroppedControlsWere(); testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo(); - testGateModeGroupWidthsAreUnchanged(); + testEveryGroupWidthMatchesTheMeasuredLayout(); testGateSplineGateRoundTripsToTheSameLayout(); testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); testEveryDeckGroupBelongsToExactlyOneRow(); - testTheContourRowAndTheSpanningDeckFitTheBudget(); + testBothRowsAndTheSpanningDeckFitTheBudget(); + testGutterArithmeticAndTheFilterTieLineAtTheFloor(); + testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor(); + testTheMasterDeckInteriorLandsOnBothRowBaselines(); + testTheMasterColumnDoesNotDivideItsRunVertically(); testHitTestResolvesTheNewFilterControls(); testBipolarKnobLawRoundTripsAndIsExactAtCentre(); if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); diff --git a/tests/test_knob_deck.cpp b/tests/test_knob_deck.cpp index a43c3ec..2cf5dbd 100644 --- a/tests/test_knob_deck.cpp +++ b/tests/test_knob_deck.cpp @@ -5,8 +5,8 @@ // * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right // inside the box; knob square centered; label band beneath; row toggle after the cells. // * reserves — a -1 id holds the group's width and hands its pixels to the cells present. -// * wrap — deterministic whole-group wrap at a narrowing width; the first group of a row -// always places; deckHeight consistency with deckRowCount. +// * rows — membership comes from the group's own DeckRow, never from a wrap outcome; +// space-between justification inside the row block; the right-anchored spanning deck. // * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding // misses, outside-deck misses. // * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring, @@ -52,42 +52,183 @@ static void testGroupWidth() { // No toggles: max(caption, cells) + padding. DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}}; CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX); + // A spanning group's cells STACK, so extra slots cost it no width — only its readout + // column does. Two slots measure the same as one. + DeckGroupDesc bus{5, 46, {}, {}, {}, {11}, {}, DeckRow::Spanning, {300, 62}}; + CHECK(deckGroupWidth(bus) == kDeckCellW + kDeckColumnGap + 62 + 2 * kDeckGroupPadX); + bus.cellIds = {11, -1, -1}; + CHECK(deckGroupWidth(bus) == kDeckCellW + kDeckColumnGap + 62 + 2 * kDeckGroupPadX); } -static void testWrapAtNarrowWidthIsDeterministic() { - // A width that forces this synthetic deck to wrap: TWO rows, whole trailing groups only. - // Deliberately narrower than the shipped editor floor — this pins the wrap MECHANISM, not - // the shipped deck's row count (that is deck_groups' own test). - const auto deck = shellLikeDeck(); - CHECK(deckRowCount(deck, 544) == 2); - CHECK(deckHeight(deck, 544) == 2 * kDeckGroupH + kDeckRowGap); - const DeckLayout dl = layoutDeck(deck, 8, 100, 544); - CHECK(dl.rowCount == 2); - CHECK(dl.height == deckHeight(deck, 544)); - CHECK(dl.groups.size() == 5); - // Row membership: groups on row 1 share the first top; the wrapped groups sit one row - // pitch lower and restart at the left margin. - const int row0Top = dl.groups[0].box.y; - const int row1Top = row0Top + kDeckGroupH + kDeckRowGap; - CHECK(dl.groups[0].box.y == row0Top); - CHECK(dl.groups[1].box.y == row0Top); - bool sawWrap = false; - for (std::size_t i = 1; i < dl.groups.size(); ++i) { - if (dl.groups[i].box.y == row1Top && dl.groups[i - 1].box.y == row0Top) { - CHECK(dl.groups[i].box.x == 8); // wrapped row restarts at the left edge - sawWrap = true; - } +// A two-row deck with a spanning bus deck, shaped like the shipped one but with synthetic +// widths: two Sound groups, two Contour groups, one Spanning group carrying a column. +static std::vector tworowDeck() { + std::vector g; + g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}}); + g.push_back({1, 38, {}, {101, 48}, {}, {6, 7}, {}, DeckRow::Sound, {}}); + g.push_back({2, 58, {}, {102, 32}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}}); + g.push_back({3, 38, {}, {103, 40}, {}, {11}, {}, DeckRow::Contour, {}}); + g.push_back({4, 46, {200, true}, {104, 32}, {}, {12, -1}, {}, + DeckRow::Spanning, {300, 62}}); + return g; +} + +// Row membership is the GROUP's, and nothing about the width can change it: the same list at +// three very different widths lays out as the same two rows plus the same spanning deck. +static void testRowMembershipComesFromTheGroupNotTheWidth() { + const auto deck = tworowDeck(); + CHECK(deckRowCount(deck) == 2); + CHECK(deckHeight(deck) == 2 * kDeckGroupH + kDeckRowGap); + CHECK(deckHeight(deck) == kDeckSpanningH); + + for (int avail : {600, 1174, 2000}) { + const DeckLayout dl = layoutDeck(deck, 8, 100, avail); + CHECK(dl.rowCount == 2); + CHECK(dl.height == deckHeight(deck)); + CHECK(dl.groups.size() == 5); + // The output is in DECK order, not row order — a layout pairs with the descriptor at + // the same index whichever row it landed in. + CHECK(dl.groups[0].id == 0 && dl.groups[1].id == 1); + CHECK(dl.groups[2].id == 2 && dl.groups[3].id == 3); + CHECK(dl.groups[4].id == 4); + CHECK(dl.groups[0].box.y == 100 && dl.groups[1].box.y == 100); + const int row1Top = 100 + kDeckGroupH + kDeckRowGap; + CHECK(dl.groups[2].box.y == row1Top && dl.groups[3].box.y == row1Top); + // Both rows start flush left. + CHECK(dl.groups[0].box.x == 8 && dl.groups[2].box.x == 8); + // The spanning deck stands across both rows and is right-anchored. + CHECK(dl.groups[4].box.y == 100); + CHECK(dl.groups[4].box.height == kDeckSpanningH); + CHECK(dl.groups[4].box.right() == 8 + avail); } - CHECK(sawWrap); - // Every box stays within the available width (no group straddles the right edge). - for (const auto& g : dl.groups) CHECK(g.box.right() <= 8 + 544); } -static void testFirstGroupAlwaysPlaces() { - // A group wider than the row still places (degenerate width) — exactly one row per group. - const auto deck = shellLikeDeck(); - CHECK(deckRowCount(deck, 100) == 5); - CHECK(deckHeight(deck, 100) == 5 * kDeckGroupH + 4 * kDeckRowGap); +// Space-between: slack becomes gutters, divided equally with the integer residue on the +// LEFTMOST ones, and the row ends flush against the block. Decks are never stretched. +static void testJustificationSpreadsSlackIntoEqualGutters() { + const auto deck = tworowDeck(); + const int soundW = deckGroupWidth(deck[0]) + deckGroupWidth(deck[1]); + const int contourW = deckGroupWidth(deck[2]) + deckGroupWidth(deck[3]); + const int spanW = deckGroupWidth(deck[4]); + + const int avail = 900; + const int block = avail - spanW - kDeckGroupGap; + const DeckLayout dl = layoutDeck(deck, 8, 0, avail); + // Natural widths, unstretched. + CHECK(dl.groups[0].box.width == deckGroupWidth(deck[0])); + CHECK(dl.groups[1].box.width == deckGroupWidth(deck[1])); + // One gutter per row here, so it takes the whole slack and both rows end on the block. + CHECK(dl.groups[1].box.x - dl.groups[0].box.right() == block - soundW); + CHECK(dl.groups[3].box.x - dl.groups[2].box.right() == block - contourW); + CHECK(dl.groups[1].box.right() == 8 + block); + CHECK(dl.groups[3].box.right() == 8 + block); + + // Three gutters over an indivisible slack: base everywhere, +1 on the leftmost ones. + std::vector four = {deck[0], deck[1], deck[1], deck[1]}; + int total = 0; + for (const auto& g : four) total += deckGroupWidth(g); + const int block4 = 940; + const DeckLayout d4 = layoutDeck(four, 0, 0, block4); + const int slack = block4 - total; + CHECK(slack % 3 != 0); // the case the residue rule exists for + const int base = slack / 3; + const int residue = slack % 3; + for (int i = 0; i < 3; ++i) { + const int gut = d4.groups[static_cast(i + 1)].box.x - + d4.groups[static_cast(i)].box.right(); + CHECK(gut == base + (i < residue ? 1 : 0)); + CHECK(gut >= kDeckGroupGap); + } + CHECK(d4.groups.back().box.right() == block4); // flush right +} + +// Below the width the block needs, gutters floor at kDeckGroupGap and the row overruns to the +// right. It never wraps — the editor clamps its window above this, so the degrade only has to +// be defined, not pretty. +static void testTooNarrowFloorsTheGuttersRatherThanWrapping() { + const auto deck = tworowDeck(); + CHECK(deckRowCount(deck) == 2); // unchanged: a row count is not a width outcome + const DeckLayout dl = layoutDeck(deck, 0, 0, 200); + CHECK(dl.rowCount == 2); + CHECK(dl.height == 2 * kDeckGroupH + kDeckRowGap); + CHECK(dl.groups[1].box.x - dl.groups[0].box.right() == kDeckGroupGap); + CHECK(dl.groups[3].box.x - dl.groups[2].box.right() == kDeckGroupGap); + CHECK(dl.groups[1].box.right() > 200); // overruns rather than wrapping +} + +// The spanning deck's left column uses FIXED slots at the row baselines. Applying the +// horizontal run-division law vertically would stretch its one knob over the whole box — this +// is the regression guard against exactly that. +static void testSpanningColumnStacksFixedSlotsAndCarriesItsReadout() { + const auto deck = tworowDeck(); + const DeckLayout dl = layoutDeck(deck, 8, 100, 900); + const DeckGroupLayout& bus = dl.groups[4]; + CHECK(bus.cells.size() == 1); // the -1 slot reserves height without drawing a cell + + const DeckCellLayout& gain = bus.cells[0]; + CHECK(gain.cell.width == kDeckCellW); // fixed, NOT the box's inner width + CHECK(gain.cell.height == kDeckCellH); // fixed, NOT half the double-height box + CHECK(gain.cell.x == bus.box.x + kDeckGroupPadX); + // Slot 0 shares row 0's knob baseline; the reserve below it shares row 1's. + CHECK(gain.cell.y == dl.groups[0].cells[0].cell.y); + const int reserveTop = gain.cell.y + kDeckGroupH + kDeckRowGap; + CHECK(reserveTop == dl.groups[2].cells[0].cell.y); + + // ONE readout rect spanning both slots, right of the cell column, flush to the padding. + CHECK(bus.column.id == 300); + CHECK(bus.column.box.width == 62); + CHECK(bus.column.box.x == gain.cell.right() + kDeckColumnGap); + CHECK(bus.column.box.right() == bus.box.right() - kDeckGroupPadX); + CHECK(bus.column.box.y == gain.cell.y); + CHECK(bus.column.box.bottom() == bus.box.bottom() - kDeckGroupPadY); + CHECK(bus.column.box.height == kDeckSpanningH - kDeckGroupPadY - kDeckCaptionH - + kDeckCaptionGap - kDeckGroupPadY); + + // The group is exactly as wide as its two columns plus padding. + CHECK(deckGroupWidth(deck[4]) == + 2 * kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62); + + // The column answers its own hit kind; the cell above it still answers as a knob. + const DeckHit col = hitTestDeck(dl, bus.column.box.x + 4, bus.column.box.y + 40); + CHECK(col.kind == DeckHitKind::Column && col.id == 300); + const DeckHit knob = hitTestDeck(dl, gain.cell.x + 4, gain.cell.y + 4); + CHECK(knob.kind == DeckHitKind::Knob && knob.id == 12); + // The reserved slot draws nothing and answers nothing — it is height, not a control. + CHECK(hitTestDeck(dl, gain.cell.x + 4, reserveTop + 4).kind == DeckHitKind::None); +} + +// A passive corner radio keeps its rect (the shell draws a lamp there) but is unreachable by +// the hit-test, so no gesture can grow on it by accident. +static void testPassiveRadioIsLaidOutButNeverHit() { + const auto deck = tworowDeck(); + const DeckLayout dl = layoutDeck(deck, 8, 100, 900); + const DeckGroupLayout& bus = dl.groups[4]; + CHECK(bus.captionRadio.id == 200); + CHECK(bus.captionRadio.passive); + CHECK(bus.captionRadio.box.width == kDeckRadioSize); + CHECK(bus.captionRadio.box.right() == bus.box.right() - kDeckGroupPadX); + const DeckHit h = hitTestDeck(dl, bus.captionRadio.box.x + 2, bus.captionRadio.box.y + 2); + CHECK(h.kind == DeckHitKind::None); + + // An INTERACTIVE radio in the same slot still answers — the flag is what changed, not the + // geometry. + std::vector active{deck[4]}; + active[0].captionRadio.passive = false; + const DeckLayout dl2 = layoutDeck(active, 0, 0, 400); + const DeckHit h2 = hitTestDeck(dl2, dl2.groups[0].captionRadio.box.x + 2, + dl2.groups[0].captionRadio.box.y + 2); + CHECK(h2.kind == DeckHitKind::CaptionRadio && h2.id == 200); +} + +// A deck with only a spanning group is as tall as that group, not as tall as zero rows. +static void testSpanningOnlyDeckKeepsItsHeight() { + std::vector only{tworowDeck()[4]}; + CHECK(deckRowCount(only) == 0); + CHECK(deckHeight(only) == kDeckSpanningH); + const DeckLayout dl = layoutDeck(only, 0, 0, 400); + CHECK(dl.rowCount == 0); + CHECK(dl.height == kDeckSpanningH); + CHECK(dl.groups.size() == 1); } static void testGroupInnerGeometry() { @@ -401,16 +542,20 @@ static void testInKnobFaceUsesTheSmallerDimensionOnANonSquareRect() { static void testEmptyDeck() { const std::vector none; - CHECK(deckRowCount(none, 800) == 0); - CHECK(deckHeight(none, 800) == 0); + CHECK(deckRowCount(none) == 0); + CHECK(deckHeight(none) == 0); const DeckLayout dl = layoutDeck(none, 0, 0, 800); CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0); } int main() { testGroupWidth(); - testWrapAtNarrowWidthIsDeterministic(); - testFirstGroupAlwaysPlaces(); + testRowMembershipComesFromTheGroupNotTheWidth(); + testJustificationSpreadsSlackIntoEqualGutters(); + testTooNarrowFloorsTheGuttersRatherThanWrapping(); + testSpanningColumnStacksFixedSlotsAndCarriesItsReadout(); + testPassiveRadioIsLaidOutButNeverHit(); + testSpanningOnlyDeckKeepsItsHeight(); testGroupInnerGeometry(); testHitTest(); testReservedCellWidthGoesToTheCellsPresent(); diff --git a/tests/test_master_meter.cpp b/tests/test_master_meter.cpp new file mode 100644 index 0000000..7b9d48c --- /dev/null +++ b/tests/test_master_meter.cpp @@ -0,0 +1,215 @@ +// Standalone tests for reasampler::instrument::ui::master_meter — no VST3, no REAPER, no +// framework. Assert: +// +// * column interior — the 22/4/36 decomposition, the mono bar taking the whole field, the +// two stereo bars at 17 px and kMeterBarGap apart, all inside the column. +// * bar count — the SAME LaneSplit waveformSurface folds, over channel mode x source +// channel count, so it can never become a second rule. +// * the dB axis — top/floor land on the field's edges, it is monotone, and it clamps. +// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release; the +// audio thread's clip latch surviving a UI frame that never sampled the loud block. +// * the GR lamp — lit only while the limiter actually reduces, and decaying afterwards. + +#include "../src/core/instrument/ui/master_meter.h" +#include "../src/core/instrument/ui/waveform_view.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::ui; +using reasampler::instrument::engine::kMeterFallDbPerSecond; +using reasampler::instrument::engine::kMeterFloorDb; +using reasampler::instrument::engine::kMeterPeakHoldSeconds; +using reasampler::instrument::engine::kMeterTopDb; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// The shipped column: 62 px wide, 186 tall (knob_deck's spanning geometry). +static const Rect kColumn = Rect::ltrb(1114, 40, 1176, 226); + +static void testColumnDividesIntoGutterAndBarField() { + const MeterRects m = meterRects(kColumn, LaneSplit::Single); + CHECK(m.labels.x == kColumn.x); + CHECK(m.labels.width == kMeterLabelW); + CHECK(m.field.x == m.labels.right() + kMeterLabelGap); + CHECK(m.field.width == kMeterFieldW); + // The three parts account for the column exactly — a residue would leave dead pixels the + // scale's numerals would then be centred against. + CHECK(kMeterLabelW + kMeterLabelGap + kMeterFieldW == kColumn.width); + CHECK(m.field.right() == kColumn.right()); + // Full height in both rects: the column spans both row baselines as ONE readout. + CHECK(m.labels.y == kColumn.y && m.labels.bottom() == kColumn.bottom()); + CHECK(m.field.y == kColumn.y && m.field.bottom() == kColumn.bottom()); +} + +static void testMonoDrawsOneWideBarAndStereoDrawsTwo() { + const MeterRects mono = meterRects(kColumn, LaneSplit::Single); + CHECK(mono.barA == mono.field); // the one bar IS the field + CHECK(mono.barB.empty()); + + const MeterRects st = meterRects(kColumn, LaneSplit::Stereo); + CHECK(!st.barB.empty()); + CHECK(st.barA.width == st.barB.width); + CHECK(st.barA.width == (kMeterFieldW - kMeterBarGap) / 2); + CHECK(st.barA.width == 17); + CHECK(st.barB.x - st.barA.right() == kMeterBarGap); + // Both bars inside the field, and the pair fills it to the pixel. + CHECK(st.barA.x == st.field.x); + CHECK(st.barB.right() == st.field.right()); + CHECK(st.barA.y == st.field.y && st.barB.bottom() == st.field.bottom()); +} + +// The bar count is NOT a second rule: it is whatever waveformSurface resolved for the same +// (mode, source) pair. A mono source under stereo mode is dual-mono — one source, two views. +static void testBarCountFollowsTheWaveformsOwnLaneSplit() { + const Rect band = Rect::ltrb(8, 100, 1182, 458); + for (bool stereoMode : {false, true}) { + for (int sourceChannels : {1, 2}) { + const WaveformSurface s = waveformSurface(band, stereoMode, sourceChannels); + const LaneSplit split = + s.laneCount == 2 ? LaneSplit::Stereo : LaneSplit::Single; + const MeterRects m = meterRects(kColumn, split); + const int bars = m.barB.empty() ? 1 : 2; + CHECK(bars == s.laneCount); + // Spelled out per combination so a regression names which one broke. + const bool expectTwo = stereoMode && sourceChannels >= 2; + CHECK(bars == (expectTwo ? 2 : 1)); + } + } +} + +static void testDbAxisSpansTheFieldAndClamps() { + const MeterRects m = meterRects(kColumn, LaneSplit::Single); + CHECK(meterDbToY(m.field, kMeterTopDb) == m.field.y); + CHECK(meterDbToY(m.field, kMeterFloorDb) == m.field.bottom()); + // Monotone downward as the level falls. + int prev = m.field.y; + for (double db = kMeterTopDb; db >= kMeterFloorDb; db -= 6.0) { + const int y = meterDbToY(m.field, db); + CHECK(y >= prev); + prev = y; + } + // Clamped outside the scale rather than drawn off the field. + CHECK(meterDbToY(m.field, kMeterTopDb + 40.0) == m.field.y); + CHECK(meterDbToY(m.field, kMeterFloorDb - 40.0) == m.field.bottom()); +} + +static void testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond() { + MasterMeterUi s; + // Unity on the left, silence on the right: the two channels are independent. + s = advanceMasterMeter(s, {1.0, 0.0, 1.0, false}, 0.1); + CHECK(std::fabs(s.left.levelDb - 0.0) < 1e-9); // rise is instantaneous, this very frame + CHECK(s.right.levelDb == kMeterFloorDb); + + // One second of silence: exactly kMeterFallDbPerSecond of fall, not a smoothed decay. + s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 1.0); + CHECK(std::fabs(s.left.levelDb - -kMeterFallDbPerSecond) < 1e-9); +} + +static void testPeakHoldSitsForItsFullWindowThenReleases() { + MasterMeterUi s; + s = advanceMasterMeter(s, {1.0, 1.0, 1.0, false}, 0.1); + const double held = s.left.holdDb; + CHECK(std::fabs(held - 0.0) < 1e-9); + + // Just under the hold window: the bar has fallen a long way, the tick has not moved. + s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, kMeterPeakHoldSeconds - 0.01); + CHECK(s.left.levelDb < held - 20.0); + CHECK(std::fabs(s.left.holdDb - held) < 1e-9); + + // Past it, the tick releases at the same 20 dB/s the bar uses. + s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 0.5); + CHECK(s.left.holdDb < held); + CHECK(s.left.holdDb >= s.left.levelDb); +} + +// The published latch is the authoritative one: a clip between two UI frames never appears in +// the block peak this frame samples, so dropping it would silently lose the report. +static void testClipLatchesFromThePublishedFlagAndClearsOnDemand() { + MasterMeterUi s; + CHECK(!meterClipped(s)); + s = advanceMasterMeter(s, {0.25, 0.25, 1.0, /*clip=*/true}, 0.1); + CHECK(meterClipped(s)); + // Latched: quiet frames do not lower it. + s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 5.0); + CHECK(meterClipped(s)); + s = clearMasterMeterClip(s); + CHECK(!meterClipped(s)); + // And the UI's own sample latches it too, when the loud block IS the one sampled. + s = advanceMasterMeter(s, {1.0, 0.0, 1.0, false}, 0.1); + CHECK(meterClipped(s)); +} + +static void testGrLampLitOnlyWhileTheLimiterReduces() { + MasterMeterUi s; + CHECK(!grLampLit(s)); + // A gain of 1 is no reduction, however long it is held. + s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1); + CHECK(s.reductionDb == 0.0); + CHECK(!grLampLit(s)); + + // ~6 dB of reduction lights it. + s = advanceMasterMeter(s, {0.5, 0.5, 0.5, false}, 0.1); + CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3); + CHECK(grLampLit(s)); + + // It decays at the meter's own rate rather than snapping dark, so a transient catch is + // visible for more than the single frame it happened on. + s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1); + CHECK(grLampLit(s)); + CHECK(s.reductionDb < 6.0206); + s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 1.0); + CHECK(!grLampLit(s)); + CHECK(s.reductionDb == 0.0); +} + +// The tick repaints only on a change, so what counts as a change has to cover every drawn +// quantity — and only those. +static void testDrawEqualityCoversTheDrawnQuantities() { + MasterMeterUi a; + CHECK(meterDrawEqual(a, a)); + + MasterMeterUi loud = advanceMasterMeter(a, {1.0, 0.0, 1.0, false}, 0.1); + CHECK(!meterDrawEqual(a, loud)); // bar + hold tick moved + + MasterMeterUi clipped = a; + clipped.left.clip = true; + CHECK(!meterDrawEqual(a, clipped)); // the cap appeared + + MasterMeterUi lamp = a; + lamp.reductionDb = kGrLampFloorDb; + CHECK(!meterDrawEqual(a, lamp)); // the lamp lit + + // Reduction that does not cross the lamp's floor draws identically — the state differs, + // the picture does not, and a repaint there would be pure cost. + MasterMeterUi graze = a; + graze.reductionDb = kGrLampFloorDb / 2.0; + CHECK(meterDrawEqual(a, graze)); +} + +static void testDegenerateColumnYieldsNothing() { + const MeterRects m = meterRects(Rect::ltrb(0, 0, 0, 0), LaneSplit::Stereo); + CHECK(m.field.empty() && m.barA.empty() && m.barB.empty()); +} + +int main() { + testColumnDividesIntoGutterAndBarField(); + testMonoDrawsOneWideBarAndStereoDrawsTwo(); + testBarCountFollowsTheWaveformsOwnLaneSplit(); + testDbAxisSpansTheFieldAndClamps(); + testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond(); + testPeakHoldSitsForItsFullWindowThenReleases(); + testClipLatchesFromThePublishedFlagAndClearsOnDemand(); + testGrLampLitOnlyWhileTheLimiterReduces(); + testDrawEqualityCoversTheDrawnQuantities(); + testDegenerateColumnYieldsNothing(); + if (g_fail) { + std::printf("%d FAILURE(S)\n", g_fail); + return 1; + } + std::printf("master_meter tests passed\n"); + return 0; +} diff --git a/tests/test_sample_bands.cpp b/tests/test_sample_bands.cpp index 4015d84..ef673cc 100644 --- a/tests/test_sample_bands.cpp +++ b/tests/test_sample_bands.cpp @@ -131,10 +131,10 @@ static void testDeckBandIsBottomAnchoredAtTheEditorFloor() { CHECK(b.decks.bottom() == kEditorMinHeight - kPad); } -// At a representative two-row deck height (216px — the ceiling test_deck_groups.cpp bounds -// the wrapped deck to), the waveform gets exactly what the floor's own height leaves it: an -// equality, not a bound, so a floor-height change that quietly ate into the waveform's slack -// would fail here rather than only widen/narrow a `>=`. +// At the shipped two-row deck height (216px — what test_deck_groups.cpp pins the deck to by +// construction, at and above the floor width), the waveform gets exactly what the floor's own +// height leaves it: an equality, not a bound, so a floor-height change that quietly ate into +// the waveform's slack would fail here rather than only widen/narrow a `>=`. static void testWaveformGetsExactlyTheFloorsRemainingHeightAtATwoRowDeck() { constexpr int twoRowDeckH = 216; const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, twoRowDeckH); From df10ddacc242378e451f62f98b25eb4c330e1ac4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 08:27:05 -0400 Subject: [PATCH 39/56] Widen the deck row block to 1028 so the filter tie-line is exact, and accumulate the meter's block peaks instead of sampling one in 47 --- docs/COMPLETED.md | 5 +- docs/PLAN.md | 43 ++++-- docs/product/instrument-control-surface.md | 100 +++++++----- docs/product/parameter-automation.md | 2 +- src/core/instrument/CLAUDE.md | 4 +- src/core/instrument/ui/CMakeLists.txt | 19 +-- src/core/instrument/ui/deck_groups.cpp | 10 +- src/core/instrument/ui/knob_deck.h | 8 +- src/core/instrument/ui/master_meter.cpp | 55 ++++++- src/core/instrument/ui/master_meter.h | 36 ++++- src/core/instrument/ui/sample_bands.h | 4 +- src/core/instrument/ui/waveform_view.cpp | 8 +- src/core/instrument/ui/waveform_view.h | 14 +- src/shell/instrument/CLAUDE.md | 23 ++- src/shell/instrument/editor_input_browse.cpp | 4 +- src/shell/instrument/editor_input_chrome.cpp | 4 +- src/shell/instrument/editor_input_curve.cpp | 4 +- src/shell/instrument/editor_input_deck.cpp | 14 +- .../instrument/editor_input_waveform.cpp | 4 +- src/shell/instrument/editor_interaction.h | 55 +++++++ src/shell/instrument/editor_paint_deck.cpp | 27 ++-- src/shell/instrument/editor_session.cpp | 14 +- src/shell/instrument/processor_state.cpp | 13 +- src/shell/instrument/reasampler_editor.h | 50 +----- src/shell/instrument/reasampler_processor.cpp | 14 +- src/shell/instrument/reasampler_processor.h | 54 +++++-- tests/test_deck_groups.cpp | 60 ++++--- tests/test_keyboard_strip.cpp | 8 +- tests/test_master_meter.cpp | 146 +++++++++++++++--- 29 files changed, 552 insertions(+), 250 deletions(-) create mode 100644 src/shell/instrument/editor_interaction.h diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index 12aafa2..4bd0834 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -970,7 +970,10 @@ from `knob_deck.h` into `sample_bands.h` alongside the min-width/min-height pair is a window fact rather than a deck one; the deck's own width-budget constants — the row block (1020) and MASTER's reserved width (142) — stay in `knob_deck.h`. The floor is derived rather than asserted as a literal: `1020 + 12 (gap) + 142 + 2×8 (pad) = 1190`, -leaving 90 px of headroom against the 1280 px ceiling. Row membership becomes a property of +leaving 90 px of headroom against the 1280 px ceiling. (**Both numbers moved afterwards:** +Γ-W3-T1 widened the block to 1028 and the floor to 1198 — 82 px of headroom — so the +justification law makes the filter tie-line exact. This paragraph is what W1-T4 landed.) +Row membership becomes a property of the group id — `DeckRow { Sound, Contour, Spanning }` plus `deckRowFor(DeckGroupId)`, an exhaustive switch (Sound = PITCH/RATE, FILTER, VELOCITY, VOICE; Contour = PITCH ENV, FILTER ENV, AMP ENVELOPE; Spanning = MASTER) so a future group left unclassified is a compile diff --git a/docs/PLAN.md b/docs/PLAN.md index bf8a770..11d1469 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -625,7 +625,7 @@ folded into the tracks below: table freezes. The editor's visual rows after the reflow were the rejected alternative. **The reason, because a future reader will ask why the id order does not match the screen:** the editor's layout has already moved twice (Θ-W6-T1 grew the floor 840 → 980; Γ-W3-T1 takes - it to 1190 and re-rows every group) and within-row order is settled by width fitting, not by + it to 1198 and re-rows every group) and within-row order is settled by width fitting, not by meaning — so **binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two drift apart**, after which the order is neither logical nor matching. Signal flow is the axis that does not move. Full argument and the accepted residual cost: @@ -693,7 +693,7 @@ see "The wave shape after Ruling 1" below. 1. **The reflow is split, canvas from arrangement.** The window floor and the width budget it is derived from land **early** (Γ-W1-T4), so every other UI track in the phase is drawn, - tested and judged at the final 1190 × 680 window instead of at a size a later wave changes + tested and judged at the final 1198 × 680 window instead of at a size a later wave changes under it. The two-row *arrangement* stays late (Γ-W3-T1), because it can only be measured once the final PITCH/RATE and MASTER descriptors exist. The seam is stated at Γ-W1-T4. 2. **`preserve-time-stretch` moved W4 → W1-T5.** It is the longest pole in the phase and has @@ -825,10 +825,13 @@ exact interim layout; do not "fix" it in a track that does not own it. - **The exposed parameter set is DERIVED, never hand-maintained.** A control is a parameter if and only if its commit class is `Live` or `NoteOnLatched`. There is no second table beside `deckParamCommit` / `liveCommitFor`, and no list that can drift from it. -- **The window floor is 1190 × 680 and must not exceed 1280 × 720.** **Γ-W1-T4 sets it, in - wave 1; no other track in the phase may move it**, and from that point every track is +- **The window floor is 1198 × 680 and must not exceed 1280 × 720.** **Γ-W1-T4 set it at + 1190, in wave 1; no other track in the phase may move it** — with ONE ruled exception, + Daniel 2026-08-02: Γ-W3-T1 widened the row block 1020 → 1028 and the floor 1190 → 1198, so + the justification law puts both rows' filter edges on one pixel (spec §1.3). That is the + only reopening, and only these two constants moved. From that point every track is authored and judged at it. A track that pushes the floor past 1280 has failed, not overrun. - **`kEditorMinHeight` stays 680** (Γ-F1). The remaining **90 px of width headroom is the + **`kEditorMinHeight` stays 680** (Γ-F1). The remaining **82 px of width headroom is the budget for the life of this layout** — one deck cell is 60 px, so there is room for exactly one more, once. Spec §1.6 states the ledger; read it before adding any control. Chrome-row additions are a **separate purse** (they are paid for out of the title slot, not the floor) @@ -900,7 +903,9 @@ into `sample_bands.h`) — leaving 90 px of headroom. Row membership becomes a p group id via an exhaustive `deckRowFor(DeckGroupId)` switch (Sound / Contour / Spanning), consumed by no one yet — **that consumption, and the fit inside the 1020 block, is Γ-W3-T1's** to assert. No drawing code, descriptor, parameter, or audio changed in this -track. +track. **Superseded in part:** Γ-W3-T1 asserted the fit and found 1020 could not deliver the +tie-line it was chosen for, so the block is now 1028 and the floor 1198 — see that track's +block below. #### Γ-W1-T5 — `preserve-time-stretch` @@ -1063,14 +1068,18 @@ waveform band. divided equally among the row's (n−1) gutters, integer residue to the leftmost; **no gutter narrower than `kDeckGroupGap` (12)**. **Decks are never stretched.** MASTER is not part of either row's justification. -- **Row block = 1020 px at the floor**, giving row 1 gutters 12/14/14 and row 2 gutters 72/72, - at which width **FILTER's right edge and FILTER ENV's right edge both land on x = 636**. - That tie-line, row 2's equal gutters, and row 1's minimum gutter being exactly - `kDeckGroupGap` all hold at 1020 and only at 1020 — **this is why the floor is 1190 and not - 1186.** Above the floor the tie-line drifts and that is accepted (spec §1.3). -- **The floor is already 1190 × 680 and the bands are already 216 / 358** — Γ-W1-T4 landed all +- **Row block = 1028 px at the floor** (widened from the originally specified 1020 — Daniel, + 2026-08-02), giving row 1 gutters 16/16/16 and row 2 gutters 76/76, at which width + **FILTER's right edge and FILTER ENV's right edge both land on x = 640**. The tie-line and + both rows' equal gutters hold at 1028 because each row's slack divides by its gutter count + with no residue. **This is why the floor is 1198 and not 1190.** The originally specified + 1020 delivered NEITHER the tie-line (638 vs 636) nor the claimed exactly-`kDeckGroupGap` + smallest gutter (13); the three properties were never simultaneously satisfiable, and 12 is + a floor rather than a target — spec §1.3 records all three deviations. Above the floor the + tie-line drifts and that is accepted (spec §1.3). +- **The bands are already 216 / 358 and the floor was already 1190 × 680** — Γ-W1-T4 landed all four in wave 1, and the greedy wrap happened to reach two rows at that width. **This track - changes none of those numbers; it makes them true by construction instead of by coincidence.** + changes only the row block and the floor (see above); the rest it makes true by construction rather than by coincidence.** Row 1's natural width fits the block **only after this track's `Band|Notch` move**: 1030 today, +42 from W2-T1's PITCH/RATE, −92 here, = **980**. That is this track's fit assertion and W1-T4 deliberately left it open. @@ -1109,10 +1118,10 @@ waveform band. natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at 312 in both modes — assert it). - Row 1 and row 2 are **flush left and flush right**; at the floor width the filter tie-line - is exact (both edges at x = 636) and row 2's two gutters are equal. -- **Row 1's natural width is 980 and fits the 1020 block** — the fit Γ-W1-T4 could not yet + is exact (both edges at x = 640) and BOTH rows' gutters are equal. +- **Row 1's natural width is 980 and fits the 1028 block** — the fit Γ-W1-T4 could not yet assert, closed here by the `Band|Notch` move. -- **`kEditorMinWidth` is still 1190 and the floor is still ≤ 1280 × 720** — unchanged by this +- **`kEditorMinWidth` is 1198 and the floor is still ≤ 1280 × 720** — moved 1190 → 1198 by this track, verified against Γ-W1-T4's derived test rather than a second copy of it. - The waveform band is **358 px at the floor**, and the deck band is 216 — **unchanged from the interim, now reached by construction**: `deckRowCount` at and above the floor is 2 because the @@ -3633,7 +3642,7 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends + 10 s ceiling + AHDSR schematic axis [Ruling 2] T2 master-bus-audio ........... limiter + meter ballistics + dynamic PDC [rung 1] T3 contour-trace-curves ....... staged traces draw curved, knot on its trace - T4 editor-floor-and-row-law ... floor 1190x680 + budget constants + row predicate + T4 editor-floor-and-row-law ... floor 1190x680 (W3-T1: 1198) + budget constants + row predicate T5 preserve-time-stretch ...... real stretcher [measure-and-report gate] T6 exhaustive-switch gate on pure libraries ... /we4062, -Werror=switch on pure libraries [no PLAN entry — see COMPLETED.md] diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index dab82c6..8f94b05 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -31,14 +31,15 @@ own width formula, not carried over from a prior measurement. The stale geometry **PITCH/RATE | FILTER | VELOCITY | VOICE** (sound). Row 2 is **PITCH ENV | FILTER ENV | AMP ENVELOPE** (contour). **MASTER spans both rows on the far right.** - **The arithmetic closes, with room.** Minimum/default window goes **980 × 680 → - 1190 × 680**, inside the settled 1280 × 720 ceiling with **90 px of headroom**. The deck + 1198 × 680**, inside the settled 1280 × 720 ceiling with **82 px of headroom**. The deck band drops **328 → 216 px**, returning **112 px to the waveform** (246 → 358 px at the - floor). **That 90 px is the governing budget for every future control addition** — one + floor). **That 82 px is the governing budget for every future control addition** — one deck cell is 60 px, so the layout has room for exactly one more, once. §1.6. -- **The two rows align exactly, not nearly.** At the floor width the row block is 1020 px, - and at that width row 2's two gutters are equal (72 px each) *and* FILTER's right edge - lands exactly on FILTER ENV's right edge (both at x = 636). That is the aesthetic tie - between the rows and it falls out of the arithmetic — §1.3. +- **The two rows align exactly, not nearly.** At the floor width the row block is 1028 px, + and at that width BOTH rows' gutters are equal (16/16/16 and 76/76) *and* FILTER's right + edge lands exactly on FILTER ENV's right edge (both at x = 640). That is the aesthetic tie + between the rows and it falls out of the arithmetic — §1.3, which also records the three + properties the originally-specified 1020 block was claimed to deliver and did not. - **PITCH becomes PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the existing Varisp|Presrv toggle. Rate 50–200 % exponential, Pitch ±24 st. - **MASTER becomes the post-voice-mixer deck it was always reserved to be**: limiter @@ -117,30 +118,31 @@ and `knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle)`. | | Natural content | Gutters at floor | **Row width** | |---|---|---|---| -| Row 1 | 192 + 432 + 192 + 164 = **980** | 12 + 14 + 14 = 40 | **1020** | -| Row 2 | 252 + 312 + 312 = **876** | 72 + 72 = 144 | **1020** | +| Row 1 | 192 + 432 + 192 + 164 = **980** | 16 + 16 + 16 = 48 | **1028** | +| Row 2 | 252 + 312 + 312 = **876** | 76 + 76 = 152 | **1028** | **Window floor.** ``` -deck band width = 1020 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1174 -kEditorMinWidth = 1174 + 2·kPad(8) = 1190 +deck band width = 1028 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1182 +kEditorMinWidth = 1182 + 2·kPad(8) = 1198 kEditorMinHeight = 680 (unchanged) deck band height = 2·kDeckGroupH(104) + kDeckRowGap(8) = 216 (was 328) waveform band at the floor = 680 − 90 (chrome) − 4 − 4 − 8 − 216 = 358 (was 246) ``` -**1190 × 680, against a 1280 × 720 ceiling — 90 px of width headroom, 40 px of height.** +**1198 × 680, against a 1280 × 720 ceiling — 82 px of width headroom, 40 px of height.** > **Who lands which half.** The floor, the three budget constants it is derived from -> (row block 1020 · MASTER 142 · ceiling 1280) and each group's row membership land in +> (row block · MASTER 142 · ceiling 1280) and each group's row membership land in > **Γ-W1-T4**, in wave 1, so the rest of the phase is authored at the final window. The > arrangement *inside* that budget — the justification law, the gutters, the tie-line, > MASTER's interior — is **Γ-W3-T1**, because every one of those measures a descriptor that > does not exist until Γ-W2-T1 and Γ-W3-T1 create it. **Row 1's natural width does not fit -> the 1020 block until Γ-W3-T1**: it is 1030 today, +42 from PITCH/RATE, −92 from FILTER's -> `Band|Notch` caption move, = 980. Row 2's 876 already fits. `docs/PLAN.md` at Γ-W1-T4 -> states the seam and the interim layout in full. +> the block until Γ-W3-T1**: it is 1030 today, +42 from PITCH/RATE, −92 from FILTER's +> `Band|Notch` caption move, = 980. Row 2's 876 already fits. Γ-W1-T4 set the block at 1020 +> and the floor at 1190; the widen recorded below moved both, and it is the ONLY number of +> W1-T4's that this phase reopened. `docs/PLAN.md` at Γ-W1-T4 states the seam in full. Three corrections to the arithmetic in the brief, all small and all in our favour: @@ -148,9 +150,13 @@ Three corrections to the arithmetic in the brief, all small and all in our favou ceiling with zero slack. 142 is what the deck's own content actually needs (§1.4) and it banks 94 px. MASTER may grow to **236** before the ceiling binds; that is the meter's growth room, not a target. -2. **The row block is 1020, not 1016.** The extra 4 px is deliberate and is what makes the - two rows align exactly rather than 2 px apart — §1.3. It is the single cheapest - aesthetic purchase in the phase. +2. **The row block is 1028, not 1016 and not the 1020 originally specified.** 1020 was + chosen to make the two rows align exactly; it does not — 1020 leaves row 1 a 40 px slack + that three gutters cannot divide evenly, so the justification law produces 14/13/13 and + leaves FILTER's right edge 2 px past FILTER ENV's. **1028 is the width at which the law + itself makes the tie-line exact**, with no residue in either row (§1.3). The 8 px is the + single cheapest aesthetic purchase in the phase, and it is spent from the headroom + ledger in §1.6. 3. **VOICE keeps its row toggle** — confirmed. Moving `Retrig|Legato` to the caption gives `38 + 4 + 80 + 4 + 88 = 214` → **226 px**, wider than 164, because VOICE's caption row is the binding side and its knob row is nearly empty. Leave it. @@ -182,11 +188,24 @@ approximate: share a right edge. 2. **The filter tie-line.** At the floor width the two rows' filter groups end on the same pixel: - `row 1: 192 + 12 + 432 = 636` · `row 2: 252 + 72 + 312 = 636`. - That is not a coincidence to be preserved by a special rule — it is what row-block - width **1020** buys, and at 1020 row 2's two gutters are *also* exactly equal (72/72) - and row 1's smallest gutter is *exactly* `kDeckGroupGap`. Three good properties at one - width. **This is why the floor is 1190 and not 1186.** + `row 1: 192 + 16 + 432 = 640` · `row 2: 252 + 76 + 312 = 640`. + That is not a coincidence preserved by a special rule — it is what row-block width + **1028** buys, and at 1028 both rows' gutters are *also* exactly equal (16/16/16 and + 76/76), because 1028 leaves each row a slack its gutter count divides with no residue. + **This is why the floor is 1198 and not 1190.** + + > **Corrected 2026-08-02 — this paragraph previously claimed THREE properties at 1020, + > and none of the three held there.** It said the tie-line landed at 636, that row 2's + > gutters were equal, and that row 1's *smallest gutter was exactly* `kDeckGroupGap` (12). + > What 1020 actually produced: row 1's slack is 40 over three gutters, so the law's + > equal-division-plus-leftmost-residue rule gives **14/13/13** — not 12/14/14 as §1.2's + > table stated, and not a smallest gutter of 12 — and FILTER's right edge lands on **638** + > against row 2's 636. Only row 2's equal gutters held. The three were never + > simultaneously satisfiable: the tie-line needs 1028, an exactly-12 smallest gutter needs + > 1016, and 1020 delivered neither. **`kDeckGroupGap` is a FLOOR — "no gutter narrower + > than 12" — never a target**, so the 16 px gutters at 1028 satisfy the real rule and the + > third property is withdrawn rather than traded away. Two properties hold at 1028, both + > exactly, and the law is what makes them hold. 3. **Shared horizontal baselines.** Every group is `kDeckGroupH` with identical interior offsets, so across both rows the caption text, the knob centrelines and the label bands sit on the same four lines. The reflow must not break this — it is free today and @@ -249,32 +268,37 @@ Horizontally the group is `6 + 60 + 8 + 62 + 6 = 142`. | Deck rows at the floor width | 3 (by greedy wrap) | **2 (by construction)** | | Deck band height | 328 | **216** | | Waveform band at the floor | 246 | **358** | -| Minimum / default window | 980 × 680 | **1190 × 680** | -| Ceiling headroom | — | **90 px wide, 40 px tall** | +| Minimum / default window | 980 × 680 | **1198 × 680** | +| Ceiling headroom | — | **82 px wide, 40 px tall** | -**Costs, named.** The floor width grows by 210 px — an existing saved instance's window +**Costs, named.** The floor width grows by 218 px — an existing saved instance's window grows on open (the same one-time effect Θ-W6-T1 already shipped at 840 → 980, so the behaviour is precedented, not new). The deck's wrap mechanism stops being the thing that decides row membership at the floor width (§7.3). And the phase spends its ceiling headroom budget — §1.6. -### 1.6 The 90 px headroom is the budget, and it governs every future control +### 1.6 The 82 px headroom is the budget, and it governs every future control -**Read this before proposing any new knob.** The floor is **1190** against Daniel's hard -**1280** ceiling. That is **90 px of width headroom for the life of this layout**, and it is +**Read this before proposing any new knob.** The floor is **1198** against Daniel's hard +**1280** ceiling. That is **82 px of width headroom for the life of this layout**, and it is the single constraint every later addition spends from: | Purchase | Cost | Headroom after | |---|---|---| -| One more 60 px deck cell on row 1 | 60 | 30 | -| One more caption toggle on a group whose caption row is the binding side | 0–48 | 42–90 | -| Widening MASTER to a two-cell left column | 60 | 30 | +| One more 60 px deck cell on row 1 | 60 | 22 | +| One more caption toggle on a group whose caption row is the binding side | 0–48 | 34–82 | +| Widening MASTER to a two-cell left column | 60 | 22 | | A second cell *and* a wider MASTER | 120 | **over ceiling** | +**The ledger was 90 until the row block widened 1020 → 1028** (§1.2 correction 2, §1.3). Its +*purchasing power* is unchanged: one more 60 px deck cell remains affordable (82 − 60 = 22), +which is the only purchase this ledger has ever promised, and the second one was already over +the ceiling at 90. The 8 px came out of the spare change, not out of the budget's one slot. + **This is why MASTER's reserved lower-left slot is ONE cell and not two** (Γ-F5, ruled by -Daniel 2026-08-01). A two-cell reserve would spend 60 of the 90 up front, on a control +Daniel 2026-08-01). A two-cell reserve would spend 60 of the 82 up front, on a control nobody has named yet, and would effectively freeze row 1 forever: any later row-1 addition -would then need the remaining 30 px and would not have it. One cell keeps the spare. If the +would then need the remaining 22 px and would not have it. One cell keeps the spare. If the future master-bus control turns out to be two knobs, widening MASTER **then** costs the same 60 px it would cost now, and by then the trade is being made against a real control instead of a guess. **Reserving capacity you have not designed a use for is not free here — it is @@ -1167,8 +1191,8 @@ Three reasons for that exact slot: 2. **Browse stays rightmost.** It is navigation, not a mode — moving it would break the established right-edge reading. 3. **It costs zero window width.** The run is right-anchored and the title slot absorbs it, - so `kEditorMinWidth` does not move and **none of §1.6's 90 px headroom is spent.** - *Constraint:* the title slot must still hold its text at the 1190 floor. If it will not, + so `kEditorMinWidth` does not move and **none of §1.6's 82 px headroom is spent.** + *Constraint:* the title slot must still hold its text at the 1198 floor. If it will not, the enable's segments narrow — the floor does not move. That is a hard rule, because the floor is a phase-wide acceptance criterion. @@ -1402,7 +1426,7 @@ ceiling. | **Γ-F2** | Limiter lookahead, or zero-latency? | **Lookahead with DYNAMIC reported latency** — zero when off, the lookahead when on, reported to the host's PDC. *Overrides this doc's zero-lookahead recommendation.* | **§3.1.1** (new), §7.10 | | **Γ-F3** | Does the log taper raise the 2 s stage-time ceiling? | **REVERSED, same day. Ruled first "not in this phase — stays 2.0 s"; then Daniel: _"extend the stage lengths to 10s."_ The ceiling moves 2.0 → 10.0 in Γ-W1-T1.** The reversal's cause is Ruling 1: parameters now ship in-phase, so the ceiling is a one-way door that has to be walked through *before* them. | **§4.3.1** (new), §4.3; `docs/TODO.md` entry discharged | | **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 | -| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 90 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 | +| **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 82 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 | | **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11, `docs/TODO.md` | | **Γ-F7** | VST3 parameter ORDER: signal flow, or the editor's visual rows? | **Signal flow** — *"signal flow order."* The frozen id numbering and the presentation index both follow the deck's own rule; the visual layout is too mobile to freeze against. | **§8.3**; `parameter-automation.md` §6.4 (argument) and §6.2 (the 44-id table) | @@ -1534,7 +1558,7 @@ ceiling into W1 (§4.3.1) and turned the Ξ ordering constraint into an owned co 1. **Item B splits: canvas early, arrangement late.** The window floor, the width budget it derives from, and each group's row membership land in W1-T4 so every other UI track is - drawn, tested and judged at the final 1190 × 680 window. The two-row layout itself stays in + drawn, tested and judged at the final 1198 × 680 window. The two-row layout itself stays in W3-T1, because it can only be measured once the final PITCH/RATE and MASTER descriptors exist. The exact seam — what W1-T4 can assert, what it cannot, and what the editor looks like in between — is in `docs/PLAN.md` at Γ-W1-T4. diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index 8718885..2f056b2 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -499,7 +499,7 @@ will eventually propose "fixing" that. The answer is that the two *cannot* both forever, and only one of the two axes holds still: > **The editor's visual layout has already moved twice** — Θ-W6-T1 grew the window floor -> 840 → 980, and Γ-W3-T1 takes it to 1190 and re-rows every group into two categorical rows +> 840 → 980, and Γ-W3-T1 takes it to 1198 and re-rows every group into two categorical rows > with a double-height MASTER. Within-row order is decided by *width fitting*, not by meaning. > **Binding a permanently-frozen id order to a demonstrably mobile layout guarantees the two > drift apart** — and after the first drift the order is neither logical *nor* matching, which diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index bd3aa17..223e000 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -330,7 +330,7 @@ anything for a trigger shape. - `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset. - `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means. - `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins. -- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other). +- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other). - **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one. - **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes. - `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition. @@ -357,7 +357,7 @@ anything for a trigger shape. drag the bank model and the WAV codec in behind it. The shell keeps only the controls the parameter set does not carry (key-track, voice count, master gain, preview velocity) and the labels for them. -- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveformSurface` folds** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Composes `engine/meter_ballistics` per channel and adds the gain-reduction lamp's own decay; the audio thread's clip flag is ORed in because it is the only latch that sees the blocks between two UI frames. `meterDrawEqual` is what lets the UI tick repaint on change alone. +- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. `kMeterColumnW` is the SUM of those three, exported so `deck_groups`' MASTER descriptor reserves exactly what the interior consumes — the column is banked to grow, and a reserve that did not track it would underfill or overrun silently. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveform_view`'s `resolveLaneSplit` answers** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Also owns `meterTickNumeralled` (the spec-pinned 0/−12/−24/−36/−48/−60 numeral set, beside the tick step it derives from), `meterNumeralRect` (bottom-clamped, so the floor tick's numeral cannot hang out of the gutter), and `meterSingleLaneState` — the one bar folds both channels PER FIELD, never picking a whole channel by level. Composes `engine/meter_ballistics` per channel and gives the gain-reduction lamp the peak tick's own hold-then-release, without which a catch smaller than 20 dB × the UI period is dark again before it has been drawn twice; the audio thread's clip flag is ORed in because it is the only latch that sees every block. `meterDrawEqual` is what lets the UI tick repaint on change alone. - `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the −92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164. - `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 8a9cde9..1ab5169 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -20,13 +20,11 @@ reasampler_test(capture_browser LINK capture_browser) reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry) reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome) -# sample_bands is PRIVATE: the lane split is used internally and nothing in the public -# header needs it. +# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit — the meter's bar +# count consumes that answer, so the type is part of this module's surface, not an internal. reasampler_pure_library(waveform_view SOURCES waveform_view.cpp - LINK PUBLIC editor_geometry peaks PRIVATE sample_bands) -# sample_bands is linked directly here because the test exercises the lane metrics that -# waveform_view does not re-export. + LINK PUBLIC editor_geometry peaks sample_bands) reasampler_test(waveform_view LINK waveform_view sample_bands) # The loop enable's state machine. Links loop_span for the park bounds — the span the user is @@ -72,12 +70,15 @@ reasampler_test(master_meter LINK master_meter waveform_view) # PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's # AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings, # MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked. +# master_meter is PRIVATE: MASTER's descriptor reserves the meter column's own kMeterColumnW, +# but nothing in deck_groups.h names a meter type, so the edge stops at this TU. reasampler_pure_library(deck_groups SOURCES deck_groups.cpp - LINK PUBLIC knob_deck velocity_curve peaks curve_law) -# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion -# needs the band allocator deck_groups itself has no reason to depend on. -reasampler_test(deck_groups LINK deck_groups sample_bands) + LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter) +# sample_bands and master_meter are linked directly for the test: the deck-fits-the-floor-window +# assertion needs the band allocator, and the MASTER-reserve identity needs the column width the +# PRIVATE edge above does not re-export. +reasampler_test(deck_groups LINK deck_groups sample_bands master_meter) # The point-editing grammar both spline consumers share, so it links the curve itself (unlike # envelope_overlay/envelope_edit, which stay engine-free — the staged envelopes touch no curve). diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 0d4ed3d..3dfb7f7 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -4,6 +4,8 @@ #include +#include "core/instrument/ui/master_meter.h" // kMeterColumnW (what the column's interior needs) + namespace reasampler::instrument::ui { namespace { @@ -15,10 +17,6 @@ double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi // row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen // the CONTOUR row, which has 144px of slack, not the SOUND row. constexpr int kEnvModeSegW = 23; - -// The output meter's column, right of MASTER's cell slots. 62 + the 60px cell + the gap + the -// group's own padding is exactly kDeckSpanningW. -constexpr int kMasterMeterW = 62; } // namespace double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; } @@ -142,7 +140,9 @@ std::vector sampleDeckGroups(PlayMode playMode) { master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true}; master.captionToggle = {id(DeckParam::kLimiterEnable), 32}; master.cellIds = {id(DeckParam::kMasterGain), -1}; - master.column = {id(DeckParam::kMasterMeter), kMasterMeterW}; + // The reserve IS what the interior consumes — read from master_meter rather than + // restated, so the two cannot drift when the column grows into MASTER's banked room. + master.column = {id(DeckParam::kMasterMeter), kMeterColumnW}; out.push_back(std::move(master)); } for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast(d.id)); diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index 9e02e68..194737d 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -57,10 +57,14 @@ enum class DeckRow { Sound, Contour, Spanning }; // from the first two — kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2*kPad — and the // identity is asserted in test_deck_groups.cpp rather than coded, so the allocator keeps no // include edge to this header. -inline constexpr int kDeckRowBlockW = 1020; // the block both categorical rows justify inside +// 1028 is the width at which the justification law puts BOTH rows' filter groups on the same +// right edge (x = 640 block-relative) AND divides row 1's slack into three equal gutters. The +// narrower 1020 delivered neither: 40 px over three gutters is 13⅓, so the law produced +// 14/13/13 and left row 1's filter edge 2 px past row 2's. +inline constexpr int kDeckRowBlockW = 1028; // the block both categorical rows justify inside inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck, outside the block // The hard ceiling the floor may not exceed lives beside the floor itself, in sample_bands.h's -// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 90px, +// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 82px, // the whole width budget for the life of this layout (asserted in test_deck_groups.cpp) — see // instrument-control-surface.md §1.6 before spending any of it. diff --git a/src/core/instrument/ui/master_meter.cpp b/src/core/instrument/ui/master_meter.cpp index 8b625e4..06318e4 100644 --- a/src/core/instrument/ui/master_meter.cpp +++ b/src/core/instrument/ui/master_meter.cpp @@ -4,9 +4,14 @@ namespace reasampler::instrument::ui { +bool meterTickNumeralled(int db) { + // Every OTHER 6 dB tick, which is the 0/−12/−24/−36/−48/−60 set the scale is specified as. + return db % (2 * static_cast(kMeterTickStepDb)) == 0; +} + MeterRects meterRects(const Rect& column, LaneSplit split) { MeterRects r; - if (column.width <= 0 || column.height <= 0) return r; + if (column.width < kMeterColumnW || column.height <= 0) return r; r.labels = Rect::ltrb(column.x, column.y, column.x + kMeterLabelW, column.bottom()); const int fieldLeft = column.x + kMeterLabelW + kMeterLabelGap; r.field = Rect::ltrb(fieldLeft, column.y, fieldLeft + kMeterFieldW, column.bottom()); @@ -21,6 +26,18 @@ MeterRects meterRects(const Rect& column, LaneSplit split) { return r; } +Rect meterNumeralRect(const Rect& labels, int y) { + if (labels.empty()) return {}; + int top = y - 5; + if (top < labels.y) top = labels.y; + int bottom = top + 10; + if (bottom > labels.bottom()) { + bottom = labels.bottom(); + top = bottom - 10 < labels.y ? labels.y : bottom - 10; + } + return Rect::ltrb(labels.x, top, labels.right(), bottom); +} + int meterDbToY(const Rect& field, double db) { const double norm = engine::meterNormFromDb(db); const int y = field.bottom() - static_cast(norm * field.height + 0.5); @@ -34,9 +51,9 @@ MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& blo MasterMeterUi next; next.left = engine::advanceMeter(prev.left, block.peakL, elapsedSeconds); next.right = engine::advanceMeter(prev.right, block.peakR, elapsedSeconds); - // The audio thread's latch is the authoritative one: it sees every block, where this only - // ever samples the last block before the UI woke. A clip that came and went between two - // frames is invisible to the peaks above and would otherwise be lost. + // ORed in unconditionally. Now that the peaks accumulate, advanceMeter's own >= 0 dBFS + // check sees the same window and would latch too — but the published flag stays the + // definitive one, and it is the half clearMasterBusClip resets. if (block.clip) { next.left.clip = true; next.right.clip = true; @@ -44,8 +61,23 @@ MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& blo const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0; const double reduction = -engine::meterDbFromLinear(block.minGain); - const double fallen = prev.reductionDb - engine::kMeterFallDbPerSecond * dt; - next.reductionDb = reduction > fallen ? reduction : fallen; + // Same hold-then-release shape as the peak tick, for the same reason: at the UI period the + // lamp actually runs at, a bare decay retires a catch before it has been drawn twice. + if (reduction >= prev.reductionDb) { + next.reductionDb = reduction; + next.reductionHoldSeconds = engine::kMeterPeakHoldSeconds; + } else { + next.reductionDb = prev.reductionDb; + next.reductionHoldSeconds = prev.reductionHoldSeconds - dt; + if (next.reductionHoldSeconds < 0.0) { + // Spend the overshoot as fall time so the release does not quantize to whichever + // UI frame the hold happened to expire on. + const double fallen = + next.reductionDb - engine::kMeterFallDbPerSecond * -next.reductionHoldSeconds; + next.reductionDb = fallen > reduction ? fallen : reduction; + next.reductionHoldSeconds = 0.0; + } + } if (next.reductionDb < 0.0) next.reductionDb = 0.0; return next; } @@ -59,6 +91,17 @@ MasterMeterUi clearMasterMeterClip(MasterMeterUi prev) { return next; } +engine::MeterState meterSingleLaneState(const MasterMeterUi& m) { + engine::MeterState s; + s.levelDb = m.left.levelDb > m.right.levelDb ? m.left.levelDb : m.right.levelDb; + s.holdDb = m.left.holdDb > m.right.holdDb ? m.left.holdDb : m.right.holdDb; + s.holdRemainingSeconds = m.left.holdRemainingSeconds > m.right.holdRemainingSeconds + ? m.left.holdRemainingSeconds + : m.right.holdRemainingSeconds; + s.clip = m.left.clip || m.right.clip; + return s; +} + bool grLampLit(const MasterMeterUi& m) { return m.reductionDb >= kGrLampFloorDb; } bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b) { diff --git a/src/core/instrument/ui/master_meter.h b/src/core/instrument/ui/master_meter.h index b67523c..5b1378e 100644 --- a/src/core/instrument/ui/master_meter.h +++ b/src/core/instrument/ui/master_meter.h @@ -17,9 +17,18 @@ inline constexpr int kMeterLabelGap = 4; inline constexpr int kMeterFieldW = 36; // one 36px bar, or two 17px bars kMeterBarGap apart inline constexpr int kMeterBarGap = 2; +// What the interior consumes, and therefore the width the deck must RESERVE for the column. +// knob_deck's MASTER descriptor reads this rather than restating 62 — §1.2 banks MASTER's +// growth to 236 as the meter's growth room, so this constant is expected to move. +inline constexpr int kMeterColumnW = kMeterLabelW + kMeterLabelGap + kMeterFieldW; + // A tick every 6 dB up the scale; every other one carries a numeral, and 0 dB draws heavier. inline constexpr double kMeterTickStepDb = 6.0; +// Whether the tick at `db` carries a numeral. The numeral SET is spec-pinned (0, −12, −24, +// −36, −48, −60), so it lives beside the step it is derived from rather than in the painter. +bool meterTickNumeralled(int db); + struct MeterRects { Rect labels; // the numeral gutter Rect field; // the whole bar field @@ -27,28 +36,39 @@ struct MeterRects { Rect barB; // empty() unless Stereo }; -// `split` is the RESOLVED lane decision waveformSurface already folds (channel mode AND the +// `split` is the RESOLVED lane decision resolveLaneSplit already folds (channel mode AND the // source's channel count), not "is the instrument in stereo mode": a mono source under stereo // mode is dual-mono, and two identical bars would be a lie. One source, two views, one rule. +// A column narrower than kMeterColumnW yields nothing rather than an overrunning field. MeterRects meterRects(const Rect& column, LaneSplit split); // y of `db` inside the bar field — kMeterTopDb at the top edge, kMeterFloorDb at the bottom, // linear in dB between, clamped outside. int meterDbToY(const Rect& field, double db); +// The numeral's label rect for the tick at `y`, kept inside the gutter: the floor tick sits ON +// the field's bottom edge, and an unclamped y±5 box would hang below the column. +Rect meterNumeralRect(const Rect& labels, int y); + // The per-instance UI state behind the column. One clip latch per channel (the cap is drawn // once, over whichever of them tripped). struct MasterMeterUi { engine::MeterState left; engine::MeterState right; double reductionDb = 0.0; // how far the limiter is pulling gain down; 0 = not working + // The lamp's hold, on the SAME principle (and the same window) as the peak tick's: without + // it a catch smaller than kMeterFallDbPerSecond x the UI period is fully decayed by the + // next frame and the lamp never draws lit at all. + double reductionHoldSeconds = 0.0; }; -// What the audio thread published about the last block, in this module's own vocabulary. +// What the audio thread published SINCE THE LAST READ, in this module's own vocabulary — the +// peaks are a max over every block in that window and minGain a min, so no block is discarded +// unseen between two UI frames. struct MasterMeterBlock { double peakL = 0.0; double peakR = 0.0; - double minGain = 1.0; // the limiter's smallest gain over the block; 1 = no reduction + double minGain = 1.0; // the limiter's smallest gain over the window; 1 = no reduction bool clip = false; // the AUDIO thread's own latch }; @@ -58,13 +78,21 @@ MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& blo bool meterClipped(const MasterMeterUi& m); MasterMeterUi clearMasterMeterClip(MasterMeterUi prev); +// What the ONE bar shows on a single-lane column: the two channels folded per FIELD, not the +// louder channel's whole state. Picking a channel by level would draw a hold tick and a clip +// belonging to whichever won on level — inert while L ≡ R on every path that reaches Single, +// and wrong the moment they diverge. +engine::MeterState meterSingleLaneState(const MasterMeterUi& m); + // Whether two states would DRAW the same, so the UI tick can repaint only on a change and an // idle editor costs nothing. Compares what the column shows — the bar, the held tick, the cap // and the lamp — not every stored double. bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b); // The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs -// a floor rather than a bare non-zero test. +// a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the +// spec asks only for "a small floor". Lowering it makes the lamp flicker on limiting too slight +// to hear; raising it hides genuine catches, since the limiter's ceiling is only −0.3 dBTP. inline constexpr double kGrLampFloorDb = 0.5; bool grLampLit(const MasterMeterUi& m); diff --git a/src/core/instrument/ui/sample_bands.h b/src/core/instrument/ui/sample_bands.h index 410e981..eb8cb7d 100644 --- a/src/core/instrument/ui/sample_bands.h +++ b/src/core/instrument/ui/sample_bands.h @@ -21,12 +21,12 @@ inline constexpr int kPad = 8; // this allocator is deliberately independent of the deck (it takes deckHeight as a parameter // for exactly that reason), so the derivation is asserted in test_deck_groups.cpp — the one // place that already includes both headers — rather than coded as an include edge. -inline constexpr int kEditorMinWidth = 1190; +inline constexpr int kEditorMinWidth = 1198; inline constexpr int kEditorMinHeight = 680; // The hard ceiling the floor above may not exceed; the window itself still grows freely above // it. A window fact, sibling of kEditorMinWidth/kEditorMinHeight, not a deck one — moved here -// from knob_deck.h for that reason. The gap to the floor (today: 90px) is the deck's whole +// from knob_deck.h for that reason. The gap to the floor (today: 82px) is the deck's whole // width budget, spent once; the identity is asserted in test_deck_groups.cpp, the one place // that already includes both this header and knob_deck.h. inline constexpr int kEditorCeilingWidth = 1280; diff --git a/src/core/instrument/ui/waveform_view.cpp b/src/core/instrument/ui/waveform_view.cpp index 209eda2..1b52ef2 100644 --- a/src/core/instrument/ui/waveform_view.cpp +++ b/src/core/instrument/ui/waveform_view.cpp @@ -24,13 +24,15 @@ OverlayArea waveformOverlayArea(const Rect& band) { return OverlayArea{band.empty() ? Rect{} : band}; } +LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels) { + return (stereoMode && sourceChannels >= 2) ? LaneSplit::Stereo : LaneSplit::Single; +} + WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels) { WaveformSurface s; if (band.empty()) return s; s.overlay = waveformOverlayArea(band); - const bool twoLanes = stereoMode && sourceChannels >= 2; - const WaveformLanes lanes = - waveformLanes(band, twoLanes ? LaneSplit::Stereo : LaneSplit::Single); + const WaveformLanes lanes = waveformLanes(band, resolveLaneSplit(stereoMode, sourceChannels)); s.upper = lanes.upper; s.lower = lanes.lower; // Derived from the resolved lanes, not `twoLanes` — a stereo split's integer division diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index 2d2f310..1fac446 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -11,6 +11,7 @@ #include #include "core/instrument/ui/editor_geometry.h" // Rect, OverlayArea, contains +#include "core/instrument/ui/sample_bands.h" // LaneSplit (resolveLaneSplit's answer) #include "core/audio/peaks.h" // AudioSample (float), Envelope namespace reasampler::instrument::ui { @@ -35,9 +36,16 @@ struct WaveformSurface { // the band-stack allocator's kWaveformMinHeight floor. }; -// Resolves the surface for a waveform band. Two lanes need BOTH stereo mode and a source -// that has a second channel to show: a mono source under stereo mode is dual-mono, so a -// second lane would be the redundant duplicate single-lane mode exists to avoid. +// THE lane-split decision, free of any pixel geometry: two lanes need BOTH stereo mode and a +// source that has a second channel to show, since a mono source under stereo mode is dual-mono +// and a second lane would be the redundant duplicate single-lane mode exists to avoid. The one +// home of that rule — the meter's bar count is the SAME question and reads it here, rather than +// inferring it from a band rect it has no business knowing about. +LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels); + +// Resolves the surface for a waveform band, folding the split above and then measuring it +// against the band: WaveformSurface::laneCount can still report 1 for a Stereo split on a band +// too thin to divide, which is a geometry fact and not a second rule. WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels); // THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 72c9d40..ac2ae49 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -107,12 +107,13 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h ## Modules - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. -- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published per block as relaxed atomics (per-channel peak, latched clip, the block's smallest limiter gain). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. - `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. +- `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)* @@ -124,13 +125,27 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h the very instance whose frame is on the stack. Deferring by one tick is same-thread and in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into one. +- **The limiter toggle arms on the same principle, and for the same reason.** Its commit + requests the host's `restartComponent(kLatencyChanged)`; a host that services that + synchronously runs `setActive(false)`/`setActive(true)`, and OUR `setActive(true)` calls + `reloadInstrument()` — a WAV re-decode plus disk I/O. Inline from `WM_LBUTTONDOWN` that + whole cycle runs with `SetCapture` held. The click writes the editor's own snapshot and + paints at once; the sync tick calls `setLimiterEnabled`, so **the audio and the reported + latency follow the click by up to one tick.** It sits AFTER the drag guard with the bake: + the restart rebuilds the instance, which mid-drag would yank the edit surface exactly as a + reload would. Every other writer of the parameter set (`setState`, the bake's adopt) still + commits and restarts immediately — none of them is inside a mouse handler. - **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed. - At that cadence the bar falls 10 dB per redraw and the 1.5 s hold spans three frames: - correct against `meter_ballistics`' contract, coarse to the eye. A meter-rate timer is a - separate change and is not in yet. + **The published block state is therefore ACCUMULATED, not sampled**: at 48 kHz / 512 frames + ~47 blocks elapse per tick, so the processor folds a per-channel max and a min limiter gain + across them and `masterBusMeter()` clears the accumulators as it reads. A plain overwriting + store displayed one block in ~47 and lost the rest — the specified "a peak displays on the + first UI frame after it occurs" is what the fold restores. `masterBusMeter()` is CONSUMING, + so exactly one caller may hold it; the embed strip reads its own non-consuming + `embedActivityLevel()`. A meter-rate timer remains a separate change and is not in. - The bake's availability probe runs on the SAME tick that paints the button, so the control can never be enabled on one tick and refuse on the next. The bake Hold control's applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and diff --git a/src/shell/instrument/editor_input_browse.cpp b/src/shell/instrument/editor_input_browse.cpp index 50932ce..a379200 100644 --- a/src/shell/instrument/editor_input_browse.cpp +++ b/src/shell/instrument/editor_input_browse.cpp @@ -98,8 +98,8 @@ void ReaSamplerEditor::dragBrowse(int x, int y) { invalidate(); } -ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x, - int y) const { +HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x, + int y) const { const BrowseModal bm = computeBrowseModal(w, h); if (contains(bm.back, x, y)) return {HoverKind::kBack, -1}; if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1}; diff --git a/src/shell/instrument/editor_input_chrome.cpp b/src/shell/instrument/editor_input_chrome.cpp index 8da7c46..e60cfad 100644 --- a/src/shell/instrument/editor_input_chrome.cpp +++ b/src/shell/instrument/editor_input_chrome.cpp @@ -152,8 +152,8 @@ void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) { invalidate(); // live feedback; the commit lands on WM_LBUTTONUP } -ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x, - int y) const { +HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x, + int y) const { const ChromeRects& cr = fl.chrome; if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1}; if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav diff --git a/src/shell/instrument/editor_input_curve.cpp b/src/shell/instrument/editor_input_curve.cpp index 31ee47c..a03dd0b 100644 --- a/src/shell/instrument/editor_input_curve.cpp +++ b/src/shell/instrument/editor_input_curve.cpp @@ -114,8 +114,8 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) { /*addOnEmptySpace=*/false); } -ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x, - int y) const { +HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x, + int y) const { const CurvePopupLayout pl = computeCurvePopup(w, h); if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1}; if (!contains(pl.curveBox, x, y)) return {}; diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index 16d89d0..57f6993 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -68,10 +68,12 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { const bool on = (hit.segment == 1); if (on != params_.limiterEnabled) { params_.limiterEnabled = on; - // The processor's own funnel mirrors the audio-thread flag and requests the - // host's latency restart; a reload would re-decode a WAV the toggle cannot - // change. The local snapshot moves with it so a later commit agrees. - processor_->setLimiterEnabled(on); + // ARMED here, run on the sync tick — the same treatment the bake gets, and + // for the same reason: the processor's funnel requests the host's latency + // restart, whose deactivate/reactivate calls setActive(true) and re-decodes + // the WAV. Inline, that whole cycle would run nested inside this mouse + // handler with SetCapture held. + limiterPending_ = on; } invalidate(); break; @@ -192,8 +194,8 @@ void ReaSamplerEditor::dragDeck(int x, int y) { invalidate(); } -ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x, - int y) const { +HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x, + int y) const { const Rect& band = fl.bands.decks; if (!contains(band, x, y)) return {}; const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width); diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 2cb5130..64cfcee 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -188,8 +188,8 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in return true; } -ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x, - int y) { +HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x, + int y) { // Caps only: the cap is the grip, so it is the one thing on the overlay a resting pointer // can be "on". A hovered mark promotes its own label past the suppression rule. // diff --git a/src/shell/instrument/editor_interaction.h b/src/shell/instrument/editor_interaction.h new file mode 100644 index 0000000..d06675b --- /dev/null +++ b/src/shell/instrument/editor_interaction.h @@ -0,0 +1,55 @@ +// editor_interaction.h — the Sample editor's INTERACTION VOCABULARY: what a drag can be +// editing, and what the pointer can be over. Two catalogues of the editor's interactive +// surface, produced by the input TUs and read by the paint TUs; neither is behaviour, which is +// why they are named here rather than buried inside the editor class between its paint and +// layout declarations. Internal to the reasampler_editor TU family, like editor_internal.h. + +#pragma once + +namespace reasampler::vst { + +// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their grabbed +// item in the editor's waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored knob +// drag (control in dragParamId_, grab value in dragKnobStartValue_). kSplineNode is the +// overlay's peer of kCurveNode: the same VelocityCurve point drag, over the waveform overlay's +// box and the overlay-active envelope's contour rather than the popup's box and curve. +enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode, + kCurveNode, kSplineNode, kDeckKnob }; + +// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. +enum class HoverKind { + kNone, + kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal) + kBack, // the Browse "back" affordance (returns to Sample) + kSearchBox, // the browser search box + kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All) + kCard, // a capture card (index = visible_ index) + kBrowseConfirm, // the Browse modal "Load" confirm button + kBrowseCancel, // the Browse modal "Cancel" button + kChanMono, // the mono channel-mode segment + kChanStereo, // the stereo channel-mode segment + kLoopOff, // the loop enable's Off segment + kLoopOn, // the loop enable's On segment + kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label + kPreview, // the preview-trigger button + kBake, // the resample-bake trigger + kControl, // a knob-deck element (index = control id) + kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id) + kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id) + kCurveNode, // a velocity-curve control point (index = point index) + kVelKnob, // the chrome preview-velocity radial knob + kHoldKnob, // the chrome bake-Hold radial knob + kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip + kPopupClose, // the curve popup's Close (x) button +}; + +// `index` disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 +// when not applicable. +struct HoverTarget { + HoverKind kind = HoverKind::kNone; + int index = -1; + bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; } + bool operator!=(const HoverTarget& o) const { return !(*this == o); } +}; + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index ec6388a..c849c4c 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -13,7 +13,7 @@ #include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state) #include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize #include "core/instrument/ui/master_meter.h" // the bus meter's column interior + ballistics -#include "core/instrument/ui/waveform_view.h" // waveformSurface (THE lane-split fold) +#include "core/instrument/ui/waveform_view.h" // resolveLaneSplit (THE lane-split fold) #include "shell/instrument/editor_internal.h" // kit adapters + knob face #include "shell/instrument/reasampler_processor.h" @@ -52,12 +52,11 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi db -= static_cast(kMeterTickStepDb)) { const int y = meterDbToY(m.field, db); const bool zero = (db == 0); - const bool numeralled = zero || (db % 12 == 0); LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1, zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0); - if (numeralled) { - kitText(bmp, Rect::ltrb(m.labels.x, y - 5, m.labels.right(), y + 5), - tickLabel(db).c_str(), Font::Micro, Role::TextDim, Align::Right); + if (meterTickNumeralled(db)) { + kitText(bmp, meterNumeralRect(m.labels, y), tickLabel(db).c_str(), Font::Micro, + Role::TextDim, Align::Right); } } @@ -78,9 +77,7 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi } }; if (split == LaneSplit::Single) { - const instrument::engine::MeterState& loudest = - state.left.levelDb >= state.right.levelDb ? state.left : state.right; - drawBar(m.barA, loudest); + drawBar(m.barA, meterSingleLaneState(state)); } else { drawBar(m.barA, state.left); drawBar(m.barB, state.right); @@ -102,14 +99,12 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { const PlaySeconds& play = params_.play; const bool isMono = (voiceMode_ == VoiceMode::Mono); const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); - // The meter's bar count is the SAME resolved decision the waveform's lane split is — read - // off waveformSurface rather than re-derived, so it can never become a second rule. - const LaneSplit meterSplit = - waveformSurface(fl.bands.waveform, channelMode_ == ChannelMode::Stereo, - channelPcmFor(selectedId_).channelCount) - .laneCount == 2 - ? LaneSplit::Stereo - : LaneSplit::Single; + // The meter's bar count is the SAME resolved decision the waveform's lane split is — + // resolveLaneSplit is the one home of it. Asked directly rather than read back off + // waveformSurface, whose laneCount additionally folds in the waveform BAND's pixel height, + // which decides nothing about how many channels the bus is carrying. + const LaneSplit meterSplit = resolveLaneSplit(channelMode_ == ChannelMode::Stereo, + channelPcmFor(selectedId_).channelCount); // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 4da27de..9ec7c7d 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -108,9 +108,8 @@ void ReaSamplerEditor::onSyncTimer() { // tick picks up the change after release. if (!processor_) return; - // The meter runs on EVERY tick, the in-flight-drag case included: a drag suppresses the - // reload poll below, but the bus keeps sounding and a frozen bar would misreport it. - // Elapsed time is measured rather than assumed — WM_TIMER's period is a request. + // Ahead of the drag guard on purpose: a drag suppresses the reload poll below, but the bus + // keeps sounding and a frozen bar would misreport it. { const unsigned long long now = GetTickCount64(); const double elapsed = meterTickMs_ == 0 @@ -129,6 +128,15 @@ void ReaSamplerEditor::onSyncTimer() { if (drag_ != DragKind::kNone) return; // defer past the in-flight edit + // The limiter click armed it; this is where it runs. Past the drag guard with the bake, + // because the restart it requests makes the host rebuild this instance — mid-drag that + // would yank the edit surface exactly as a reload would. + if (limiterPending_) { + const bool on = *limiterPending_; + limiterPending_.reset(); + processor_->setLimiterEnabled(on); + } + // Resolve the bake affordance's availability on the SAME tick that paints it, so it // can never be enabled on one tick and refuse on the next. const bool available = bakeAvailable(processor_->bridge()); diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 2df4c34..33ea8ab 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -183,11 +183,16 @@ void ReaSamplerProcessor::setLimiterEnabled(bool on) { setInstrumentParams(params); } -MasterBusMeter ReaSamplerProcessor::masterBusMeter() const { +MasterBusMeter ReaSamplerProcessor::masterBusMeter() { MasterBusMeter m; - m.peakL = meterPeakL_.load(std::memory_order_relaxed); - m.peakR = meterPeakR_.load(std::memory_order_relaxed); - m.minGain = meterMinGain_.load(std::memory_order_relaxed); + // Exchange, not load: the accumulators hold the window since this was last called, and + // clearing them here is what starts the next window. The audio thread's own fold is a + // load-max-store, so a store landing between this exchange and that store can retain one + // window's peak for one extra frame — it can never LOSE one, which is the property that + // matters for a peak meter. + m.peakL = meterPeakL_.exchange(0.f, std::memory_order_relaxed); + m.peakR = meterPeakR_.exchange(0.f, std::memory_order_relaxed); + m.minGain = meterMinGain_.exchange(1.f, std::memory_order_relaxed); m.clip = meterClip_.load(std::memory_order_relaxed); return m; } diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 397d7e4..b6ca274 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -28,6 +28,7 @@ #include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) #include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams #include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state) +#include "shell/instrument/editor_interaction.h" // DragKind / HoverKind / HoverTarget #ifdef _WIN32 #include @@ -79,15 +80,6 @@ private: // picker overlaid on it. enum class View { kSample, kBrowse }; - // What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their - // grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored - // knob drag (control in dragParamId_, grab value in dragKnobStartValue_). - // kSplineNode is the overlay's peer of kCurveNode: the same VelocityCurve point drag, over - // the waveform overlay's box and the overlay-active envelope's contour rather than the - // popup's box and curve. - enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode, - kCurveNode, kSplineNode, kDeckKnob }; - // Which envelope the waveform overlay is drawing and editing. The selection type and its // whole state machine are the pure deck_groups module's; this alias keeps the shell's // spelling. @@ -115,41 +107,6 @@ private: // What those four marks are showing — see pickedMarkers. using SetupMarkers = instrument::ui::LoopMarks; - // The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index` - // disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when - // not applicable. - enum class HoverKind { - kNone, - kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal) - kBack, // the Browse "back" affordance (returns to Sample) - kSearchBox, // the browser search box - kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All) - kCard, // a capture card (index = visible_ index) - kBrowseConfirm, // the Browse modal "Load" confirm button - kBrowseCancel, // the Browse modal "Cancel" button - kChanMono, // the mono channel-mode segment - kChanStereo, // the stereo channel-mode segment - kLoopOff, // the loop enable's Off segment - kLoopOn, // the loop enable's On segment - kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label - kPreview, // the preview-trigger button - kBake, // the resample-bake trigger - kControl, // a knob-deck element (index = control id) - kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id) - kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id) - kCurveNode, // a velocity-curve control point (index = point index) - kVelKnob, // the chrome preview-velocity radial knob - kHoldKnob, // the chrome bake-Hold radial knob - kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip - kPopupClose, // the curve popup's Close (x) button - }; - struct HoverTarget { - HoverKind kind = HoverKind::kNone; - int index = -1; - bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; } - bool operator!=(const HoverTarget& o) const { return !(*this == o); } - }; - // The three-band stack for the current client size, plus the chrome interior. Every // paint/hit-test path derives both through this one call so draw and hit-test can never // disagree about where a band is. @@ -489,6 +446,11 @@ private: std::string bakeMessage_; // last outcome, shown in the title band int bakeMessageTicks_ = 0; // sync ticks the message survives + // The limiter toggle, armed by the click and run on the sync tick — the commit requests a + // host latency restart, which is the same nested-inside-a-mouse-handler hazard the bake + // defers for. Empty = nothing armed. + std::optional limiterPending_; + // The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only): // an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts // down via the sync tick). Never ingests, never inserts a timeline item. diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index 89c65c4..2d4721b 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -309,7 +309,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } // The chain's last stage before the bus, after the gain above. const float minGain = limiter_.process(ch0, ch1, frames); - meterMinGain_.store(minGain, std::memory_order_relaxed); + foldMinGain(meterMinGain_, minGain); // Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). for (int32 ch = 2; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { @@ -324,8 +324,9 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { if (a0 > peakL) peakL = a0; if (a1 > peakR) peakR = a1; } - meterPeakL_.store(peakL, std::memory_order_relaxed); - meterPeakR_.store(peakR, std::memory_order_relaxed); + foldPeak(meterPeakL_, peakL); + foldPeak(meterPeakR_, peakR); + advisoryPeak_.store(peakL > peakR ? peakL : peakR, std::memory_order_relaxed); if (peakL >= 1.f || peakR >= 1.f) meterClip_.store(true, std::memory_order_relaxed); } else if (ch0) { // Mono: render into channel 0, replicate to any extra channels (defensive). @@ -354,14 +355,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } const float minGain = limiter_.process(ch0, nullptr, frames); - meterMinGain_.store(minGain, std::memory_order_relaxed); + foldMinGain(meterMinGain_, minGain); float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; if (a > peak) peak = a; } - meterPeakL_.store(peak, std::memory_order_relaxed); - meterPeakR_.store(peak, std::memory_order_relaxed); + foldPeak(meterPeakL_, peak); + foldPeak(meterPeakR_, peak); + advisoryPeak_.store(peak, std::memory_order_relaxed); if (peak >= 1.f) meterClip_.store(true, std::memory_order_relaxed); for (int32 ch = 1; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 1154e8e..4e1f65b 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -128,16 +128,17 @@ public: Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void** obj) override; - // The embedded-strip activity level (0..1) for the embed shell, UI thread. The loudest of - // the two published bus peaks — one publication serves the strip and the meter. + // The embedded-strip activity level (0..1) for the embed shell, UI thread. The last block's + // loudest channel — a non-consuming read, so it stays correct however often the editor's + // meter drains its own accumulators (or never does, with no editor open). double embedActivityLevel() const { - const float l = meterPeakL_.load(std::memory_order_relaxed); - const float r = meterPeakR_.load(std::memory_order_relaxed); - return static_cast(l > r ? l : r); + return static_cast(advisoryPeak_.load(std::memory_order_relaxed)); } - // What the audio thread published about the output bus last block. UI thread. - MasterBusMeter masterBusMeter() const; + // What the audio thread published since the LAST call: peaks maxed and minGain minimised + // across every block in that window. CONSUMING — it resets the accumulators as it reads, so + // exactly one reader may call it, and that reader is the editor's meter tick. UI thread. + MasterBusMeter masterBusMeter(); void clearMasterBusClip(); // Resolves the selection against the instance-owned SampleRefs, decodes its WAV @@ -284,13 +285,28 @@ private: // Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine. void publishBuiltLocked(std::unique_ptr built); - // Publishes a silent block to the meter. EVERY process() path that emits no audio calls - // this, or the bar freezes at the last peak it saw. The clip latch is deliberately not - // touched — it survives silence until the user clears it. + // Folds one block's reading into the accumulator it belongs to — a running max for a peak, + // a running min for the limiter's gain. Read-modify-write rather than a bare store, so the + // ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512 all reach the meter + // instead of the one it happened to sample. Relaxed throughout: the accumulators are + // advisory, and no other state is ordered against them. Block rate, never per frame. + static void foldPeak(std::atomic& acc, float blockPeak) { + if (blockPeak > acc.load(std::memory_order_relaxed)) { + acc.store(blockPeak, std::memory_order_relaxed); + } + } + static void foldMinGain(std::atomic& acc, float blockMinGain) { + if (blockMinGain < acc.load(std::memory_order_relaxed)) { + acc.store(blockMinGain, std::memory_order_relaxed); + } + } + + // Publishes a silent block. EVERY process() path that emits no audio calls this. It clears + // only the ADVISORY level, which is a last-block reading: the meter accumulators need + // nothing here, because a block that emitted no audio contributes no peak and no gain + // reduction, and the UI's own read is what resets them. void publishSilentMeterBlock() { - meterPeakL_.store(0.f, std::memory_order_relaxed); - meterPeakR_.store(0.f, std::memory_order_relaxed); - meterMinGain_.store(1.f, std::memory_order_relaxed); + advisoryPeak_.store(0.f, std::memory_order_relaxed); } // Mirrors the persisted limiter enable onto the audio thread and the latency reader. Called @@ -456,13 +472,21 @@ private: instrument::engine::Limiter limiter_; std::atomic limiterEnabled_{false}; - // What the audio thread publishes about the output bus each block, relaxed — peaks, the - // latched clip, and the limiter's smallest gain. No dB, no ballistics, no hold timer here; + // What the audio thread publishes about the output bus each block, relaxed. The peaks and + // minGain ACCUMULATE (max / min) across every block since the UI last read, and + // masterBusMeter() resets them as it reads — the fix for a bar that displayed roughly one + // block in fifty. No dB, no ballistics, no hold timer here; // the UI runs those off these values and its own elapsed time. std::atomic meterPeakL_{0.f}; std::atomic meterPeakR_{0.f}; std::atomic meterMinGain_{1.f}; std::atomic meterClip_{false}; + + // The embed strip's activity level: the LAST block's loudest channel, plainly overwritten. + // Separate from the accumulators above because it answers a different question ("how loud + // is it now") and has a different reader — sharing one would make each reader's reset + // silently truncate the other's window. + std::atomic advisoryPeak_{0.f}; }; } // namespace reasampler::vst diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 15535b4..9574406 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -12,6 +12,7 @@ // and which selections are inert). #include "../src/core/instrument/ui/deck_groups.h" +#include "../src/core/instrument/ui/master_meter.h" // kMeterColumnW: MASTER's reserve IS this #include "../src/core/instrument/ui/sample_bands.h" #include @@ -310,12 +311,15 @@ static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() { CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth); // The budget: what is left between the derived floor and the hard ceiling, and it is spent // once. A cell costs 60 of it. - CHECK(kEditorCeilingWidth - kEditorMinWidth == 90); + CHECK(kEditorCeilingWidth - kEditorMinWidth == 82); + // 82 still buys one more deck cell (60), which is the only purchase the ledger promises — + // the widen below spent 8 px of slack, not the layout's purchasing power. + CHECK(kEditorCeilingWidth - kEditorMinWidth >= kDeckCellW); // The reflow's 112 px goes entirely to the waveform, so the height does not move. CHECK(kEditorMinHeight == 680); - // The floor did not move to make the reflow fit — the reflow was fitted to the floor. This - // wave spends the budget it was handed; it does not widen it. - CHECK(kEditorMinWidth == 1190); + // 1190 + 8: the row block was widened 1020 -> 1028 to put the two rows' filter edges on + // one pixel, which is the only reason the floor moved off Γ-W1-T4's number. + CHECK(kEditorMinWidth == 1198); CHECK(kEditorMinWidth <= kEditorCeilingWidth); CHECK(kEditorMinHeight <= 720); // And the row block really is what the two rows justify inside — derived from the floor @@ -382,11 +386,11 @@ static void testBothRowsAndTheSpanningDeckFitTheBudget() { } // The gutters the justification law produces at the floor, and the alignment they buy. The -// SPEC (instrument-control-surface.md §1.2/§1.3) states row 1 as 12/14/14 with both filter -// edges at x = 636; equal division of 40 px over three gutters cannot produce that, so what is -// pinned here is what the LAW produces — 14/13/13, filter edge 638 — with row 2 exact at -// 72/72 and 636. The 2 px is flagged for review; a row block of 1028 (floor 1198, still under -// the 1280 ceiling) is the width at which the law puts both edges on 640. +// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than +// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's +// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three +// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap +// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule. static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); @@ -394,24 +398,25 @@ static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() { const auto box = [&](int id) { return dl.groups[static_cast(indexOfGroup(g, id))].box; }; - // Row 1: flush left, flush right on the block, gutters 14/13/13. + // Row 1: flush left, flush right on the block, and three EQUAL gutters — 48 divides by 3 + // with no residue, so no gutter carries a leftover pixel. CHECK(box(kGroupPitch).x == kPad); - CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 14); - CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 13); - CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 13); + CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 16); + CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16); + CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16); CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW); - // Row 2: flush left, flush right, and its two gutters exactly equal — the property the - // 1020 block was chosen for, and the one it does deliver. + // Row 2: flush left, flush right, two gutters exactly equal. CHECK(box(kGroupPitchEnv).x == kPad); - CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 72); - CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 72); + CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76); + CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76); CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW); - // The filter tie-line, block-relative. Row 2 lands on the specified 636; row 1 lands 2 px - // past it. See this test's header. - CHECK(box(kGroupFilterEnv).right() - kPad == 636); - CHECK(box(kGroupFilter).right() - kPad == 638); + // The tie-line, block-relative: both filter edges on ONE pixel, which is what the widen + // bought. Pinned as an identity too, so a group-width change cannot pass by moving both. + CHECK(box(kGroupFilterEnv).right() - kPad == 640); + CHECK(box(kGroupFilter).right() - kPad == 640); + CHECK(box(kGroupFilter).right() == box(kGroupFilterEnv).right()); // MASTER is right-anchored outside the block, one kDeckGroupGap clear of it. CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap); @@ -450,8 +455,8 @@ static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() { CHECK(drift <= lastDrift); lastDrift = drift; } - // It really does open up, and by far more than the 2 px it starts at — separation - // above the floor is the accepted outcome, not a near-miss to be pinned back. + // It really does open up: the tie-line is exact AT the floor and separates above it, + // which is the accepted outcome rather than a near-miss to be pinned back. CHECK(lastDrift < -50); } } @@ -467,8 +472,13 @@ static void testTheMasterDeckInteriorLandsOnBothRowBaselines() { CHECK(m.box.width == 142); CHECK(m.box.height == 216); - // 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total. - CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62 + kDeckGroupPadX == 142); + // 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total, and the 62 is the meter + // module's own kMeterColumnW rather than a copy of it. That link is the whole point: the + // column is banked to GROW (§1.2), and a reserve that did not track it would leave the + // interior underfilling or overrunning with every test still green. + CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + kMeterColumnW + kDeckGroupPadX == 142); + CHECK(m.column.id == cell(DeckParam::kMasterMeter)); + CHECK(m.column.box.width == kMeterColumnW); // One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed // position and draws nothing. diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp index 7fa2e36..add1147 100644 --- a/tests/test_keyboard_strip.cpp +++ b/tests/test_keyboard_strip.cpp @@ -306,7 +306,7 @@ static void testGutterAtTheSawtoothMaximumResidueWidth() { // nor the floor is covered by another test firing if either ever changes. Derived from // kEditorMinWidth/kEditorMinHeight (editor_session.cpp's ViewRect default IS the floor) rather // than a hardcoded window size, so a floor change fails HERE instead of silently moving the -// shipped gutter out from under it. The numbers below are today's floor (1190x680); re-derive +// shipped gutter out from under it. The numbers below are today's floor (1198x680); re-derive // them by hand if the floor ever moves. static void testGutterAtTheShippedDefaultWindowSize() { // Derive rootStrip's width the same way the shell does, through the real allocator + @@ -315,14 +315,14 @@ static void testGutterAtTheShippedDefaultWindowSize() { const ChromeRects chrome = chromeRects(bands.chrome, /*knobSize=*/24); const int stripW = chrome.rootStrip.width; CHECK(stripW == kEditorMinWidth - 2 * kPad); - CHECK(stripW == 1174); + CHECK(stripW == 1182); const StripLayout L = layoutStrip(stripW, 30); CHECK(L.whiteWidth == 15); const int margins = L.band.width - L.keys.width; - CHECK(margins == 49); + CHECK(margins == 57); const int leftMargin = L.keys.x - L.band.x; - CHECK(leftMargin == 24); + CHECK(leftMargin == 28); } int main() { diff --git a/tests/test_master_meter.cpp b/tests/test_master_meter.cpp index 7b9d48c..f97c2fb 100644 --- a/tests/test_master_meter.cpp +++ b/tests/test_master_meter.cpp @@ -1,14 +1,15 @@ // Standalone tests for reasampler::instrument::ui::master_meter — no VST3, no REAPER, no // framework. Assert: // -// * column interior — the 22/4/36 decomposition, the mono bar taking the whole field, the -// two stereo bars at 17 px and kMeterBarGap apart, all inside the column. -// * bar count — the SAME LaneSplit waveformSurface folds, over channel mode x source +// * column interior — the 22/4/36 decomposition, the exported column width the deck reserves, +// the mono bar taking the whole field, the two stereo bars, all inside the column. +// * bar count — the SAME LaneSplit resolveLaneSplit folds, over channel mode x source // channel count, so it can never become a second rule. -// * the dB axis — top/floor land on the field's edges, it is monotone, and it clamps. -// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release; the -// audio thread's clip latch surviving a UI frame that never sampled the loud block. -// * the GR lamp — lit only while the limiter actually reduces, and decaying afterwards. +// * the dB axis — top/floor on the field's edges, an interior value, and the clamps. +// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release AT RATE; +// the audio thread's clip latch surviving a UI frame; the per-field single-lane fold. +// * the GR lamp — lit only while the limiter reduces, held, and surviving the 500 ms tick the +// editor actually runs it at. #include "../src/core/instrument/ui/master_meter.h" #include "../src/core/instrument/ui/waveform_view.h" @@ -37,8 +38,11 @@ static void testColumnDividesIntoGutterAndBarField() { CHECK(m.field.x == m.labels.right() + kMeterLabelGap); CHECK(m.field.width == kMeterFieldW); // The three parts account for the column exactly — a residue would leave dead pixels the - // scale's numerals would then be centred against. - CHECK(kMeterLabelW + kMeterLabelGap + kMeterFieldW == kColumn.width); + // scale's numerals would then be centred against. Asserted against the EXPORTED width the + // deck reserves, not against this fixture's literal rect: the deck reading the same + // constant is what keeps the reserve and the interior from drifting apart. + CHECK(kMeterLabelW + kMeterLabelGap + kMeterFieldW == kMeterColumnW); + CHECK(kMeterColumnW == kColumn.width); CHECK(m.field.right() == kColumn.right()); // Full height in both rects: the column spans both row baselines as ONE readout. CHECK(m.labels.y == kColumn.y && m.labels.bottom() == kColumn.bottom()); @@ -62,18 +66,19 @@ static void testMonoDrawsOneWideBarAndStereoDrawsTwo() { CHECK(st.barA.y == st.field.y && st.barB.bottom() == st.field.bottom()); } -// The bar count is NOT a second rule: it is whatever waveformSurface resolved for the same -// (mode, source) pair. A mono source under stereo mode is dual-mono — one source, two views. +// The bar count is NOT a second rule: it is resolveLaneSplit's answer for the same (mode, +// source) pair the waveform asks about. A mono source under stereo mode is dual-mono — one +// source, two views. static void testBarCountFollowsTheWaveformsOwnLaneSplit() { const Rect band = Rect::ltrb(8, 100, 1182, 458); for (bool stereoMode : {false, true}) { for (int sourceChannels : {1, 2}) { - const WaveformSurface s = waveformSurface(band, stereoMode, sourceChannels); - const LaneSplit split = - s.laneCount == 2 ? LaneSplit::Stereo : LaneSplit::Single; + const LaneSplit split = resolveLaneSplit(stereoMode, sourceChannels); const MeterRects m = meterRects(kColumn, split); const int bars = m.barB.empty() ? 1 : 2; - CHECK(bars == s.laneCount); + // The waveform's own surface folds the SAME call, so on a band tall enough to + // divide the two answers agree by construction rather than by coincidence. + CHECK(bars == waveformSurface(band, stereoMode, sourceChannels).laneCount); // Spelled out per combination so a regression names which one broke. const bool expectTwo = stereoMode && sourceChannels >= 2; CHECK(bars == (expectTwo ? 2 : 1)); @@ -95,6 +100,49 @@ static void testDbAxisSpansTheFieldAndClamps() { // Clamped outside the scale rather than drawn off the field. CHECK(meterDbToY(m.field, kMeterTopDb + 40.0) == m.field.y); CHECK(meterDbToY(m.field, kMeterFloorDb - 40.0) == m.field.bottom()); + + // One INTERIOR point, because endpoints plus monotonicity are satisfied by any log or + // piecewise map through them, and the scale is specified LINEAR in dB. −27 is the + // midpoint of −60…+6, so it must land on the field's own midpoint: 186 x 0.5 = 93. + CHECK(meterDbToY(m.field, -27.0) == m.field.bottom() - 93); + // And a quarter of the way up, which fixes the slope rather than just the centre. + CHECK(meterDbToY(m.field, -43.5) == m.field.bottom() - 47); // round(0.25 x 186) = 47 +} + +// The numeral SET is spec-pinned (0, −12, −24, −36, −48, −60) as a property of the scale, so it +// is asserted here rather than left as a modulo inside the painter. +static void testEveryOtherTickCarriesANumeral() { + const int expected[] = {6, -6, -18, -30, -42, -54}; + for (int db : expected) CHECK(!meterTickNumeralled(db)); + const int numeralled[] = {0, -12, -24, -36, -48, -60}; + for (int db : numeralled) CHECK(meterTickNumeralled(db)); +} + +// The floor tick sits ON the field's bottom edge, so an unclamped y±5 numeral box hangs below +// the column and into the deck's bottom padding. +static void testTheFloorNumeralStaysInsideTheGutter() { + const MeterRects m = meterRects(kColumn, LaneSplit::Single); + const Rect floorLabel = meterNumeralRect(m.labels, meterDbToY(m.field, kMeterFloorDb)); + CHECK(floorLabel.bottom() <= m.labels.bottom()); + CHECK(floorLabel.y >= m.labels.y); + CHECK(floorLabel.height == 10); // clamped, not squashed — the numeral still has its band + const Rect topLabel = meterNumeralRect(m.labels, meterDbToY(m.field, kMeterTopDb)); + CHECK(topLabel.y >= m.labels.y); + CHECK(topLabel.height == 10); + // An interior tick is centred on its rule, which is the case the clamp must not disturb. + const int midY = meterDbToY(m.field, -24.0); + CHECK(meterNumeralRect(m.labels, midY).y == midY - 5); +} + +// A column narrower than the interior needs yields NOTHING rather than a field overrunning it. +// Reachable only if the deck's reserve and this module's interior ever disagree — which is +// exactly what kMeterColumnW exists to prevent. +static void testAColumnTooNarrowForTheInteriorDrawsNothing() { + const Rect narrow = Rect::ltrb(0, 0, kMeterColumnW - 1, 186); + const MeterRects m = meterRects(narrow, LaneSplit::Stereo); + CHECK(m.field.empty() && m.barA.empty() && m.barB.empty()); + // Exactly the needed width still lays out. + CHECK(!meterRects(Rect::ltrb(0, 0, kMeterColumnW, 186), LaneSplit::Stereo).field.empty()); } static void testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond() { @@ -120,9 +168,12 @@ static void testPeakHoldSitsForItsFullWindowThenReleases() { CHECK(s.left.levelDb < held - 20.0); CHECK(std::fabs(s.left.holdDb - held) < 1e-9); - // Past it, the tick releases at the same 20 dB/s the bar uses. + // Past it, the tick releases at the SAME 20 dB/s the bar uses — pinned by value, not as an + // inequality: a slower release would satisfy "it fell" and still be the wrong meter. The + // frame spends the 0.01 s of hold it had left and releases for the remaining 0.49 s, which + // is also what proves the release does not quantize to whole UI frames. s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 0.5); - CHECK(s.left.holdDb < held); + CHECK(std::fabs(s.left.holdDb - (held - kMeterFallDbPerSecond * 0.49)) < 1e-9); CHECK(s.left.holdDb >= s.left.levelDb); } @@ -151,21 +202,67 @@ static void testGrLampLitOnlyWhileTheLimiterReduces() { CHECK(s.reductionDb == 0.0); CHECK(!grLampLit(s)); - // ~6 dB of reduction lights it. + // ~6 dB of reduction lights it, and arms the hold. s = advanceMasterMeter(s, {0.5, 0.5, 0.5, false}, 0.1); CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3); CHECK(grLampLit(s)); + CHECK(s.reductionHoldSeconds == kMeterPeakHoldSeconds); - // It decays at the meter's own rate rather than snapping dark, so a transient catch is - // visible for more than the single frame it happened on. - s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1); + // Held flat, not decaying, for its whole window — the peak tick's own contract. + s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, kMeterPeakHoldSeconds - 0.01); + CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3); CHECK(grLampLit(s)); - CHECK(s.reductionDb < 6.0206); + + // Past the window it releases at the meter's 20 dB/s, and 6 dB of catch is gone inside a + // third of a second of release. s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 1.0); CHECK(!grLampLit(s)); CHECK(s.reductionDb == 0.0); } +// The cadence the lamp ACTUALLY runs at is editor_platform's 500 ms sync tick, and the whole +// point of the hold is that the lamp survives it. Without one, a 6 dB catch decays 20 x 0.5 = +// 10 dB on the very next frame and clamps to 0 — lit for exactly one repaint. Pinned in frames, +// because "how many times does this draw lit" is arithmetic, not a look. +static void testGrLampSurvivesTheFiveHundredMillisecondTick() { + constexpr double kTick = 0.5; // editor_platform.cpp's kSyncTimerIntervalMs + MasterMeterUi s = advanceMasterMeter(MasterMeterUi{}, {0.5, 0.5, 0.5, false}, kTick); + CHECK(grLampLit(s)); + + int litFrames = 1; + for (int i = 0; i < 20 && grLampLit(s); ++i) { + s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, kTick); + if (grLampLit(s)) ++litFrames; + } + // 1.5 s of hold spans the tick that armed it plus three more, and the release then takes + // 6.02 dB below the 0.5 dB floor within one further 10 dB step. + CHECK(litFrames == 4); + CHECK(!grLampLit(s)); + + // A catch the previous frame does not shorten: a SECOND catch re-arms the full window. + MasterMeterUi t = advanceMasterMeter(MasterMeterUi{}, {0.5, 0.5, 0.5, false}, kTick); + t = advanceMasterMeter(t, {0.5, 0.5, 1.0, false}, kTick); + t = advanceMasterMeter(t, {0.5, 0.5, 0.5, false}, kTick); + CHECK(t.reductionHoldSeconds == kMeterPeakHoldSeconds); +} + +// The one bar a single-lane column draws folds the two channels per FIELD. Picking whichever +// channel won on level would draw the OTHER channel's hold tick and clip nowhere. +static void testSingleLaneStateFoldsBothChannelsPerField() { + MasterMeterUi m; + m.left.levelDb = -30.0; + m.left.holdDb = -2.0; // left is quieter now but held the loudest peak + m.right.levelDb = -10.0; + m.right.holdDb = -8.0; + m.left.clip = true; // and only left ever clipped + m.right.clip = false; + + const instrument::engine::MeterState s = meterSingleLaneState(m); + CHECK(s.levelDb == -10.0); // the louder channel's bar + CHECK(s.holdDb == -2.0); // but the higher hold tick, which is the other channel's + CHECK(s.clip); // and the clip, which a level pick would have dropped +} + // The tick repaints only on a change, so what counts as a change has to cover every drawn // quantity — and only those. static void testDrawEqualityCoversTheDrawnQuantities() { @@ -200,10 +297,15 @@ int main() { testMonoDrawsOneWideBarAndStereoDrawsTwo(); testBarCountFollowsTheWaveformsOwnLaneSplit(); testDbAxisSpansTheFieldAndClamps(); + testEveryOtherTickCarriesANumeral(); + testTheFloorNumeralStaysInsideTheGutter(); + testAColumnTooNarrowForTheInteriorDrawsNothing(); testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond(); testPeakHoldSitsForItsFullWindowThenReleases(); testClipLatchesFromThePublishedFlagAndClearsOnDemand(); + testSingleLaneStateFoldsBothChannelsPerField(); testGrLampLitOnlyWhileTheLimiterReduces(); + testGrLampSurvivesTheFiveHundredMillisecondTick(); testDrawEqualityCoversTheDrawnQuantities(); testDegenerateColumnYieldsNothing(); if (g_fail) { From 41876674e4550de42f486c53aac75d543af4cf30 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 08:45:35 -0400 Subject: [PATCH 40/56] Fix deck-UI review findings: right-anchor MASTER's meter column, correct stale/overclaiming comments, split test_deck_groups.cpp on its commit-tier/overlay seam, and pin two width-ceiling assertions. --- src/core/instrument/ui/CMakeLists.txt | 4 + src/core/instrument/ui/knob_deck.cpp | 12 +- src/core/instrument/ui/knob_deck.h | 14 +- tests/test_deck_groups.cpp | 274 ++++++-------------------- tests/test_deck_groups_state.cpp | 231 ++++++++++++++++++++++ 5 files changed, 304 insertions(+), 231 deletions(-) create mode 100644 tests/test_deck_groups_state.cpp diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 1ab5169..a005c02 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -79,6 +79,10 @@ reasampler_pure_library(deck_groups # assertion needs the band allocator, and the MASTER-reserve identity needs the column width the # PRIVATE edge above does not re-export. reasampler_test(deck_groups LINK deck_groups sample_bands master_meter) +# The commit-tier + overlay-selection state machine, split out of deck_groups_tests on the seam +# those fixtures already had: deckParamCommit/liveCommitFor and the overlay predicates are pure +# control-id/enum logic that touches no layout, so this target needs no sample_bands/master_meter. +reasampler_test(deck_groups_state LINK deck_groups) # The point-editing grammar both spline consumers share, so it links the curve itself (unlike # envelope_overlay/envelope_edit, which stay engine-free — the staged envelopes touch no curve). diff --git a/src/core/instrument/ui/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp index 680dbb5..caf5dcf 100644 --- a/src/core/instrument/ui/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -8,8 +8,8 @@ namespace reasampler::instrument::ui { namespace { -// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell -// already carries its own breathing room around the 28px knob), plus the optional row +// The knob-row width of a group: cells side by side (no inter-cell gap — the 60px cell +// already carries its own breathing room around the 40px knob), plus the optional row // toggle after a kDeckToggleGap. A spanning group's cells stack, so its knob row is one // cell wide plus whatever readout column sits beside it. int knobRowWidth(const DeckGroupDesc& g) { @@ -109,8 +109,10 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { slotTop += kDeckGroupH + kDeckRowGap; } if (g.column.id >= 0) { - // ONE rect spanning every slot, not a readout per row. - const int colX = innerLeft + (g.cellIds.empty() ? 0 : kDeckCellW + kDeckColumnGap); + // ONE rect spanning every slot, not a readout per row. Right-anchored off + // innerRight rather than measured past the cell slot, so a wider caption + // reserve on this group can never detach the column from the padding. + const int colX = innerRight - g.column.width; out.column = DeckColumnLayout{ g.column.id, Rect::ltrb(colX, cellTop, colX + g.column.width, box.bottom() - kDeckGroupPadY)}; @@ -156,7 +158,7 @@ std::vector justifyGutters(int count, int total, int blockW) { const int slack = blockW - total; if (slack < gutters * kDeckGroupGap) { // The block cannot hold the row: minimum gutters, and the row overruns to the right - // rather than wrapping. Unreachable in the editor — see layoutDeck's header note. + // rather than wrapping — see layoutDeck's header note for when this degrade applies. return std::vector(static_cast(gutters), kDeckGroupGap); } const int base = slack / gutters; diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index 194737d..3e7103e 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -1,12 +1,8 @@ // knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free // like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of // action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is -// param_slider's — a knob cell here is just a rect the shell composes it into. -// -// A group is a fenced box: caption row (caption left, toggles and a corner radio -// right-anchored) over a knob row of equal-width cells, optionally followed by one row -// toggle. Row membership is a PROPERTY OF THE GROUP (DeckRow), never a wrap outcome — see -// the justification law at layoutDeck. +// param_slider's — a knob cell here is just a rect the shell composes it into. Group/row +// composition and the justification law are this directory's own CLAUDE.md's to describe. #pragma once @@ -31,7 +27,8 @@ inline constexpr int kDeckGroupPadY = 4; // group box vertical inner paddin inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap inline constexpr int kDeckGroupGap = 12; // gap between groups on a row -inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows +inline constexpr int kDeckRowGap = 8; // gap between the deck's two categorical rows, + // and between the spanning deck's stacked slots inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square inline constexpr int kDeckColumnGap = 8; // the spanning deck's cell column -> its readout column // The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while @@ -181,7 +178,8 @@ int deckHeight(const std::vector& groups); // divided equally with the integer residue going to the leftmost ones. Decks are never // stretched. Below the width the block needs, every gutter sits at kDeckGroupGap and the row // overflows right rather than wrapping — the shell clamps the window to a floor that fits -// (sample_bands' kEditorMinWidth), so that degrade is unreachable in the editor. +// (sample_bands' kEditorMinWidth) via checkSizeConstraint, a host-honoured clamp rather than a +// guarantee, so this degrade is defined and tested rather than assumed impossible. DeckLayout layoutDeck(const std::vector& groups, int left, int top, int availWidth); diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 9574406..9b75b87 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -6,10 +6,9 @@ // floor width and its fit inside the floor window, the pinned Gate group widths, the editor // floor derived from the deck's width budget and each group's categorical row, // that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round -// trip restores the layout exactly, the hit-test reaching the new filter controls, the bipolar knob -// law's inverse pair, the commit-tier routing — which controls are live, and which drags take -// the live tier — and the overlay-selection state machine (exclusivity, the none resting state, -// and which selections are inert). +// trip restores the layout exactly, the hit-test reaching the new filter controls, and the +// bipolar knob law's inverse pair. The commit-tier routing and the overlay-selection state +// machine live in test_deck_groups_state.cpp — they touch no layout at all. #include "../src/core/instrument/ui/deck_groups.h" #include "../src/core/instrument/ui/master_meter.h" // kMeterColumnW: MASTER's reserve IS this @@ -428,21 +427,26 @@ static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() { // Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides // its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past // row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure. +// +// Checked per ROW (tracking the last-seen box in each of the two categorical rows while +// walking dl.groups in deck order), not just deck-order neighbours: two same-row groups can +// sit apart in deck order with a different-row group between them, and a deck-order-only +// check would silently skip that gutter. static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { const std::vector g = sampleDeckGroups(mode); int lastDrift = 1 << 20; // sentinel above any real drift for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) { const DeckLayout dl = layoutDeck(g, kPad, 0, avail); - const DeckGroupLayout* prev = nullptr; - DeckRow prevRow = DeckRow::Spanning; + const DeckGroupLayout* prevInRow[2] = {nullptr, nullptr}; for (const DeckGroupLayout& gl : dl.groups) { const DeckRow row = deckRowFor(static_cast(gl.id)); - if (row != DeckRow::Spanning && prev && row == prevRow) { - CHECK(gl.box.x - prev->box.right() >= kDeckGroupGap); + if (row == DeckRow::Spanning) continue; + const int r = row == DeckRow::Contour ? 1 : 0; + if (prevInRow[r]) { + CHECK(gl.box.x - prevInRow[r]->box.right() >= kDeckGroupGap); } - prev = ≷ - prevRow = row; + prevInRow[r] = ≷ } const auto right = [&](int id) { return dl.groups[static_cast(indexOfGroup(g, id))].box.right(); @@ -532,6 +536,22 @@ static void testTheMasterColumnDoesNotDivideItsRunVertically() { CHECK(m2.column.box == m.column.box); } +// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column +// derived from either edge lands in the same place — that balance is what let a left-derived +// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or +// a limiter-toggle change would) and the column must still land flush against the group's own +// right padding, derived from innerRight rather than measured past the cell slots. +static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + DeckGroupDesc probe = g[static_cast(indexOfGroup(g, kGroupMaster))]; + probe.captionWidth += 40; // unbalances it: the caption row now measures past the knob row + const std::vector one = {probe}; + const DeckLayout dl = layoutDeck(one, kPad, 0, kAvailAtMinWidth); + const DeckGroupLayout& m = dl.groups[0]; + CHECK(m.box.width > 142); // the widen is real, not absorbed elsewhere + CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX); +} + static void testHitTestResolvesTheNewFilterControls() { const std::vector g = sampleDeckGroups(PlayMode::Gate); const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); @@ -597,206 +617,6 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() { CHECK(deckNormFromBipolar(3.0) == 1.0); } -static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { - // The live set: the seven filter tone/modulation knobs, the baseline pitch offset, plus - // every stage time, stage level, hold fraction and curve exponent on all three envelopes — - // in BOTH mode shapes. - const DeckParam live[] = { - DeckParam::kPitch, - DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ, - DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterVel, - DeckParam::kFilterKeyTrack, - DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain, - DeckParam::kRelease, - DeckParam::kTrigAttack, DeckParam::kTrigHold, DeckParam::kTrigDecay, - DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay, - DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease, - DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigHold, DeckParam::kFilterTrigDecay, - DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvHold, DeckParam::kPitchEnvDecay, - DeckParam::kPitchEnvDepth, - DeckParam::kAttackCurve, DeckParam::kDecayCurve, DeckParam::kReleaseCurve, - DeckParam::kTrigAttackCurve, DeckParam::kTrigDecayCurve, - DeckParam::kPitchEnvAttackCurve, DeckParam::kPitchEnvDecayCurve, - DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve, - DeckParam::kFilterEnvReleaseCurve, - DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve, - }; - for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live); - - // The note-on-latched tier: published like a live control, read only at note-on. Asserted as - // its OWN state rather than as "not Reload" — the whole point of widening the predicate is - // that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification - // to decide what it exposes to the host. - const DeckParam latched[] = {DeckParam::kRate}; - for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched); - - // Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion - // is excluded. - const DeckParam reloads[] = { - DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable, - DeckParam::kFilterEnable, DeckParam::kFilterLaw, - DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve, - DeckParam::kKeyTrack, DeckParam::kTrigLength, - DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, - DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode, - DeckParam::kVoiceCount, DeckParam::kVoiceMode, - DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable, - DeckParam::kMasterMeter, DeckParam::kMasterGr, - }; - for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload); - - // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the three lists. A sum check - // would stay green if an edit duplicated one id and dropped another, leaving that one - // unclassified. - for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { - const DeckParam p = static_cast(i); - int seen = 0; - for (DeckParam q : live) if (q == p) ++seen; - for (DeckParam q : latched) if (q == p) ++seen; - for (DeckParam q : reloads) if (q == p) ++seen; - if (seen != 1) std::printf(" (deck id %d classified %d times)\n", i, seen); - CHECK(seen == 1); - } -} - -static void testOnlyALiveControlsDragTakesTheLiveTier() { - // deckParamCommit alone is not what a user experiences — liveCommitFor is, at the editor's - // commit site. Inverting it has to FAIL a test rather than merely read wrong. - const auto knob = [](DeckParam p) { - return liveCommitFor(LiveDragKind::kDeckKnob, static_cast(p)); - }; - CHECK(knob(DeckParam::kFilterCutoff) == LiveCommit::Live); - CHECK(knob(DeckParam::kAttack) == LiveCommit::Live); - CHECK(knob(DeckParam::kPitch) == LiveCommit::Live); - // The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural - // consequence of that consolidation. - CHECK(knob(DeckParam::kTrigAttack) == LiveCommit::Live); - CHECK(knob(DeckParam::kTrigDecayCurve) == LiveCommit::Live); - // Rate keeps its own tier through the drag site: it must not arrive as Live (which would let - // it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob). - CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched); - CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload); - CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload); - CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload); - // The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id - // are not parameter-set controls, so they must never reach the enum. - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -2) == LiveCommit::Reload); - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -1) == LiveCommit::Reload); - CHECK(knob(DeckParam::kCount) == LiveCommit::Reload); - // Every stage value an envelope node can reach is live, in either mode shape. - CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1) == LiveCommit::Live); - // Every other drag (markers, scrollbar, curve nodes) commits through a reload. - CHECK(liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff)) == - LiveCommit::Reload); -} - -// --- The overlay selection state machine --------------------------------------- - -static int radio(DeckParam p) { return static_cast(p); } - -// EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never -// be overlay-active at once, whatever the previous selection was. -static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() { - const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch, - OverlayEnv::kFilter}; - for (OverlayEnv from : states) { - if (from != OverlayEnv::kAmp) { - CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp); - } - if (from != OverlayEnv::kPitch) { - CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) == - OverlayEnv::kPitch); - } - if (from != OverlayEnv::kFilter) { - CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) == - OverlayEnv::kFilter); - } - } -} - -// kNone is a RESTING STATE the user can get back to: clicking the active radio clears it. -static void testClickingTheActiveOverlayRadioClearsToNone() { - CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) == - OverlayEnv::kNone); - CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) == - OverlayEnv::kNone); - CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) == - OverlayEnv::kNone); -} - -// A control that is not one of the three radios selects nothing and clears nothing. -static void testANonRadioIdLeavesTheOverlaySelectionAlone() { - CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone); - CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone); - CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) == - OverlayEnv::kFilter); - CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp); -} - -// The two group gates, spelled the way the predicates read them. Spline flags default off, so -// a case that says nothing about them is asserting the staged behaviour. -static DeckEnableState gates(bool pitchEnv, bool filter) { - DeckEnableState s; - s.pitchEnvEnabled = pitchEnv; - s.filterEnabled = filter; - return s; -} - -// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on -// the same params: a node drag must not reach a value the knob refuses. -static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() { - CHECK(overlayEnvInert(OverlayEnv::kPitch, gates(/*pitchEnv=*/false, /*filter=*/true))); - CHECK(!overlayEnvInert(OverlayEnv::kPitch, gates(true, true))); - CHECK(overlayEnvInert(OverlayEnv::kFilter, gates(true, /*filter=*/false))); - CHECK(!overlayEnvInert(OverlayEnv::kFilter, gates(true, true))); - // Amp has no enable toggle, so it is never inert; kNone draws nothing to grab. - CHECK(!overlayEnvInert(OverlayEnv::kAmp, gates(false, false))); - CHECK(!overlayEnvInert(OverlayEnv::kNone, gates(false, false))); - - // The enable gate alone, which the SPLINE overlay reads: it survives a mode switch, so a - // disabled group's contour is as dead as its knobs. - CHECK(!overlayEnvEnabled(OverlayEnv::kPitch, gates(false, true))); - CHECK(overlayEnvEnabled(OverlayEnv::kAmp, gates(false, false))); - // ...while the staged overlay additionally goes inert once the envelope is drawn: its - // nodes are no longer what the overlay is editing. - DeckEnableState drawn = gates(true, true); - drawn.ampSpline = true; - CHECK(overlayEnvInert(OverlayEnv::kAmp, drawn)); - CHECK(overlayEnvEnabled(OverlayEnv::kAmp, drawn)); -} - -// A deck knob goes inert exactly with its group's own enable toggle — including the filter's -// VELOCITY cell, which sits in the VELOCITY group visually but is a filter parameter and must -// go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck -// checks before ever routing a curve-cell click to the popup). -static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() { - CHECK(deckKnobInert(DeckParam::kFilterVelCurve, gates(/*pitchEnv=*/true, /*filter=*/false))); - CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, gates(true, true))); - CHECK(deckKnobInert(DeckParam::kFilterCutoff, gates(true, false))); - CHECK(!deckKnobInert(DeckParam::kFilterCutoff, gates(true, true))); - CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, gates(/*pitchEnv=*/false, true))); - CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates(true, true))); - // The amp's own velocity cell and every ordinary control are never inert here — inertness - // is a filter/pitch-env-group-only concept until an envelope is drawn. - CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, gates(false, false))); - CHECK(!deckKnobInert(DeckParam::kAttack, gates(false, false))); -} - -// A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs -// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in -// spline_egs_tests alongside the rest of the spline rules.) -static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() { - CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload); - CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload); - CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload); - CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp); - CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch); - CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter); - // A mode toggle must not be mistaken for the overlay-select radio beside it. - CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone); - CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone); -} - // The three mode toggles ride each env group's caption slack, so the deck's wrapped geometry // is unchanged by them: raising their segment width past the caption headroom would reflow the // first row and push the deck to a fourth one (see testDeckFitsInsideTheEnforcedMinimumWindow). @@ -876,6 +696,30 @@ static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() { CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over } +// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47, +// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate +// caption ceiling above is: a change to either group's caption width or its enable toggle +// would otherwise invalidate the recorded numbers with nothing failing. +static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckGroupDesc& penv = g[static_cast(indexOfGroup(g, kGroupPitchEnv))]; + const DeckGroupDesc& amp = g[static_cast(indexOfGroup(g, kGroupAmpEnv))]; + CHECK(deckGroupWidth(penv) == 252); + CHECK(deckGroupWidth(amp) == 312); + + DeckGroupDesc penvProbe = penv; + penvProbe.captionToggle2.segWidth = 47; + CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven + penvProbe.captionToggle2.segWidth = 48; + CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over + + DeckGroupDesc ampProbe = amp; + ampProbe.captionToggle2.segWidth = 55; + CHECK(deckGroupWidth(ampProbe) == 312); + ampProbe.captionToggle2.segWidth = 56; + CHECK(deckGroupWidth(ampProbe) > 312); +} + // Every group's width, in BOTH play modes, against the measured layout table // (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the // reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the @@ -970,15 +814,7 @@ static void testGateSplineGateRoundTripsToTheSameLayout() { } int main() { - testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks(); - testClickingTheActiveOverlayRadioClearsToNone(); - testANonRadioIdLeavesTheOverlaySelectionAlone(); - testOverlayIsInertExactlyWhenItsGroupToggleIsOff(); - testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); - testAModeToggleIsNeitherLiveNorAnOverlayRadio(); testTheModeTogglesCostNoGroupWidth(); - testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers(); - testOnlyALiveControlsDragTakesTheLiveTier(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); testVelocityGroupOwnsTheThreeCurvesExclusively(); testCurveTargetNamesEachCellsOwnDestination(); @@ -992,6 +828,7 @@ int main() { testDeckFitsInsideTheEnforcedMinimumWindow(); testNoFaceLeavesSlackWhereItsDroppedControlsWere(); testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo(); + testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp(); testEveryGroupWidthMatchesTheMeasuredLayout(); testGateSplineGateRoundTripsToTheSameLayout(); testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); @@ -1001,6 +838,7 @@ int main() { testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor(); testTheMasterDeckInteriorLandsOnBothRowBaselines(); testTheMasterColumnDoesNotDivideItsRunVertically(); + testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow(); testHitTestResolvesTheNewFilterControls(); testBipolarKnobLawRoundTripsAndIsExactAtCentre(); if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); diff --git a/tests/test_deck_groups_state.cpp b/tests/test_deck_groups_state.cpp new file mode 100644 index 0000000..9a7bb62 --- /dev/null +++ b/tests/test_deck_groups_state.cpp @@ -0,0 +1,231 @@ +// Standalone tests for reasampler::instrument::ui::deck_groups' commit-tier routing and +// overlay-selection state machine — no VST3, no REAPER, no framework. Split from +// test_deck_groups.cpp on the seam those fixtures already had: nothing here touches +// layoutDeck, DeckGroupWidth, or any other geometry API — deckParamCommit/liveCommitFor (which +// controls are live, and which drags take the live tier) and the overlay-selection state +// machine (exclusivity, the none resting state, and which selections are inert) are pure +// control-id/enum predicates. test_deck_groups.cpp keeps the geometry/row/width fixtures. + +#include "../src/core/instrument/ui/deck_groups.h" + +#include + +using namespace reasampler; +using namespace reasampler::instrument::ui; + +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 testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { + // The live set: the seven filter tone/modulation knobs, the baseline pitch offset, plus + // every stage time, stage level, hold fraction and curve exponent on all three envelopes — + // in BOTH mode shapes. + const DeckParam live[] = { + DeckParam::kPitch, + DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ, + DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterVel, + DeckParam::kFilterKeyTrack, + DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain, + DeckParam::kRelease, + DeckParam::kTrigAttack, DeckParam::kTrigHold, DeckParam::kTrigDecay, + DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay, + DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease, + DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigHold, DeckParam::kFilterTrigDecay, + DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvHold, DeckParam::kPitchEnvDecay, + DeckParam::kPitchEnvDepth, + DeckParam::kAttackCurve, DeckParam::kDecayCurve, DeckParam::kReleaseCurve, + DeckParam::kTrigAttackCurve, DeckParam::kTrigDecayCurve, + DeckParam::kPitchEnvAttackCurve, DeckParam::kPitchEnvDecayCurve, + DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve, + DeckParam::kFilterEnvReleaseCurve, + DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve, + }; + for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live); + + // The note-on-latched tier: published like a live control, read only at note-on. Asserted as + // its OWN state rather than as "not Reload" — the whole point of widening the predicate is + // that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification + // to decide what it exposes to the host. + const DeckParam latched[] = {DeckParam::kRate}; + for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched); + + // Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion + // is excluded. + const DeckParam reloads[] = { + DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable, + DeckParam::kFilterEnable, DeckParam::kFilterLaw, + DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve, + DeckParam::kKeyTrack, DeckParam::kTrigLength, + DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, + DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode, + DeckParam::kVoiceCount, DeckParam::kVoiceMode, + DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable, + DeckParam::kMasterMeter, DeckParam::kMasterGr, + }; + for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload); + + // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the three lists. A sum check + // would stay green if an edit duplicated one id and dropped another, leaving that one + // unclassified. + for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { + const DeckParam p = static_cast(i); + int seen = 0; + for (DeckParam q : live) if (q == p) ++seen; + for (DeckParam q : latched) if (q == p) ++seen; + for (DeckParam q : reloads) if (q == p) ++seen; + if (seen != 1) std::printf(" (deck id %d classified %d times)\n", i, seen); + CHECK(seen == 1); + } +} + +static void testOnlyALiveControlsDragTakesTheLiveTier() { + // deckParamCommit alone is not what a user experiences — liveCommitFor is, at the editor's + // commit site. Inverting it has to FAIL a test rather than merely read wrong. + const auto knob = [](DeckParam p) { + return liveCommitFor(LiveDragKind::kDeckKnob, static_cast(p)); + }; + CHECK(knob(DeckParam::kFilterCutoff) == LiveCommit::Live); + CHECK(knob(DeckParam::kAttack) == LiveCommit::Live); + CHECK(knob(DeckParam::kPitch) == LiveCommit::Live); + // The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural + // consequence of that consolidation. + CHECK(knob(DeckParam::kTrigAttack) == LiveCommit::Live); + CHECK(knob(DeckParam::kTrigDecayCurve) == LiveCommit::Live); + // Rate keeps its own tier through the drag site: it must not arrive as Live (which would let + // it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob). + CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched); + CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload); + CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload); + CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload); + // The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id + // are not parameter-set controls, so they must never reach the enum. + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -2) == LiveCommit::Reload); + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -1) == LiveCommit::Reload); + CHECK(knob(DeckParam::kCount) == LiveCommit::Reload); + // Every stage value an envelope node can reach is live, in either mode shape. + CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1) == LiveCommit::Live); + // Every other drag (markers, scrollbar, curve nodes) commits through a reload. + CHECK(liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff)) == + LiveCommit::Reload); +} + +// --- The overlay selection state machine --------------------------------------- + +static int radio(DeckParam p) { return static_cast(p); } + +// EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never +// be overlay-active at once, whatever the previous selection was. +static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() { + const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch, + OverlayEnv::kFilter}; + for (OverlayEnv from : states) { + if (from != OverlayEnv::kAmp) { + CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp); + } + if (from != OverlayEnv::kPitch) { + CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) == + OverlayEnv::kPitch); + } + if (from != OverlayEnv::kFilter) { + CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) == + OverlayEnv::kFilter); + } + } +} + +// kNone is a RESTING STATE the user can get back to: clicking the active radio clears it. +static void testClickingTheActiveOverlayRadioClearsToNone() { + CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) == + OverlayEnv::kNone); + CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) == + OverlayEnv::kNone); + CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) == + OverlayEnv::kNone); +} + +// A control that is not one of the three radios selects nothing and clears nothing. +static void testANonRadioIdLeavesTheOverlaySelectionAlone() { + CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone); + CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone); + CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) == + OverlayEnv::kFilter); + CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp); +} + +// The two group gates, spelled the way the predicates read them. Spline flags default off, so +// a case that says nothing about them is asserting the staged behaviour. +static DeckEnableState gates(bool pitchEnv, bool filter) { + DeckEnableState s; + s.pitchEnvEnabled = pitchEnv; + s.filterEnabled = filter; + return s; +} + +// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on +// the same params: a node drag must not reach a value the knob refuses. +static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() { + CHECK(overlayEnvInert(OverlayEnv::kPitch, gates(/*pitchEnv=*/false, /*filter=*/true))); + CHECK(!overlayEnvInert(OverlayEnv::kPitch, gates(true, true))); + CHECK(overlayEnvInert(OverlayEnv::kFilter, gates(true, /*filter=*/false))); + CHECK(!overlayEnvInert(OverlayEnv::kFilter, gates(true, true))); + // Amp has no enable toggle, so it is never inert; kNone draws nothing to grab. + CHECK(!overlayEnvInert(OverlayEnv::kAmp, gates(false, false))); + CHECK(!overlayEnvInert(OverlayEnv::kNone, gates(false, false))); + + // The enable gate alone, which the SPLINE overlay reads: it survives a mode switch, so a + // disabled group's contour is as dead as its knobs. + CHECK(!overlayEnvEnabled(OverlayEnv::kPitch, gates(false, true))); + CHECK(overlayEnvEnabled(OverlayEnv::kAmp, gates(false, false))); + // ...while the staged overlay additionally goes inert once the envelope is drawn: its + // nodes are no longer what the overlay is editing. + DeckEnableState drawn = gates(true, true); + drawn.ampSpline = true; + CHECK(overlayEnvInert(OverlayEnv::kAmp, drawn)); + CHECK(overlayEnvEnabled(OverlayEnv::kAmp, drawn)); +} + +// A deck knob goes inert exactly with its group's own enable toggle — including the filter's +// VELOCITY cell, which sits in the VELOCITY group visually but is a filter parameter and must +// go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck +// checks before ever routing a curve-cell click to the popup). +static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() { + CHECK(deckKnobInert(DeckParam::kFilterVelCurve, gates(/*pitchEnv=*/true, /*filter=*/false))); + CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, gates(true, true))); + CHECK(deckKnobInert(DeckParam::kFilterCutoff, gates(true, false))); + CHECK(!deckKnobInert(DeckParam::kFilterCutoff, gates(true, true))); + CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, gates(/*pitchEnv=*/false, true))); + CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates(true, true))); + // The amp's own velocity cell and every ordinary control are never inert here — inertness + // is a filter/pitch-env-group-only concept until an envelope is drawn. + CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, gates(false, false))); + CHECK(!deckKnobInert(DeckParam::kAttack, gates(false, false))); +} + +// A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs +// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in +// spline_egs_tests alongside the rest of the spline rules.) +static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() { + CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload); + CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload); + CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload); + CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp); + CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch); + CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter); + // A mode toggle must not be mistaken for the overlay-select radio beside it. + CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone); + CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone); +} + +int main() { + testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks(); + testClickingTheActiveOverlayRadioClearsToNone(); + testANonRadioIdLeavesTheOverlaySelectionAlone(); + testOverlayIsInertExactlyWhenItsGroupToggleIsOff(); + testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); + testAModeToggleIsNeitherLiveNorAnOverlayRadio(); + testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers(); + testOnlyALiveControlsDragTakesTheLiveTier(); + if (g_fail == 0) std::printf("deck_groups_state: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} From b956fe0d5a011eab20cec27a85b76d0448023376 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 11:45:47 -0400 Subject: [PATCH 41/56] Commit the limiter's audible state on the click and defer only the host's latency restart setInstrumentParams now arms a sticky pending restart that flushLatencyRestart drains from the sync tick; setState and the bake's adopt flush at their own tails. --- src/shell/instrument/CLAUDE.md | 26 ++++++++------ src/shell/instrument/editor_input_deck.cpp | 11 +++--- src/shell/instrument/editor_session.cpp | 14 ++++---- src/shell/instrument/processor_reload.cpp | 4 +++ src/shell/instrument/processor_state.cpp | 39 +++++++++++++++------ src/shell/instrument/reasampler_editor.h | 5 --- src/shell/instrument/reasampler_processor.h | 21 ++++++++--- 7 files changed, 76 insertions(+), 44 deletions(-) diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index ac2ae49..bdef065 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -107,7 +107,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h ## Modules - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. -- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. @@ -125,16 +125,22 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h the very instance whose frame is on the stack. Deferring by one tick is same-thread and in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into one. -- **The limiter toggle arms on the same principle, and for the same reason.** Its commit - requests the host's `restartComponent(kLatencyChanged)`; a host that services that +- **The limiter toggle splits its commit: the sound is inline, the HOST NOTIFICATION arms.** + Its commit needs the host's `restartComponent(kLatencyChanged)`; a host that services that synchronously runs `setActive(false)`/`setActive(true)`, and OUR `setActive(true)` calls - `reloadInstrument()` — a WAV re-decode plus disk I/O. Inline from `WM_LBUTTONDOWN` that - whole cycle runs with `SetCapture` held. The click writes the editor's own snapshot and - paints at once; the sync tick calls `setLimiterEnabled`, so **the audio and the reported - latency follow the click by up to one tick.** It sits AFTER the drag guard with the bake: - the restart rebuilds the instance, which mid-drag would yank the edit surface exactly as a - reload would. Every other writer of the parameter set (`setState`, the bake's adopt) still - commits and restarts immediately — none of them is inside a mouse handler. + `reloadInstrument()` — a WAV re-decode plus disk I/O, which inline from `WM_LBUTTONDOWN` + would run nested in a mouse handler. So the click commits the parameter set, the audio-thread + mirror and the latency reader at once, and `setInstrumentParams` only ARMS a pending restart + that `flushLatencyRestart` delivers. The editor's sync tick is the general drain and sits + AFTER the drag guard with the bake (the restart rebuilds the instance, which mid-drag would + yank the edit surface exactly as a reload would); `setState` and the bake's adopt flush at + their own tails, because they can commit with no editor open. The arm is a sticky bool, so + toggling twice inside one tick still costs exactly one restart. + **The residual:** between the commit and the flush the host's delay compensation is out of + step with the plugin by `limiterLookaheadSamples` (2 ms — `round(0.002 · rate)`, the + detector's 4-sample group delay INSIDE that budget, not on top), bounded by one 500 ms tick. + Narrowing it further means a second deferral mechanism (a posted window message) rather than + the tick — deliberately not built. - **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index 57f6993..009a50a 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -68,12 +68,11 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { const bool on = (hit.segment == 1); if (on != params_.limiterEnabled) { params_.limiterEnabled = on; - // ARMED here, run on the sync tick — the same treatment the bake gets, and - // for the same reason: the processor's funnel requests the host's latency - // restart, whose deactivate/reactivate calls setActive(true) and re-decodes - // the WAV. Inline, that whole cycle would run nested inside this mouse - // handler with SetCapture held. - limiterPending_ = on; + // Commits the audible state and the persisted state together, here, because + // this is a control the user A/Bs. The funnel only ARMS the host's latency + // restart — the sync tick delivers it — so nothing on this path calls into + // the host from inside a mouse handler. + processor_->setLimiterEnabled(on); } invalidate(); break; diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 9ec7c7d..5b543fa 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -128,14 +128,12 @@ void ReaSamplerEditor::onSyncTimer() { if (drag_ != DragKind::kNone) return; // defer past the in-flight edit - // The limiter click armed it; this is where it runs. Past the drag guard with the bake, - // because the restart it requests makes the host rebuild this instance — mid-drag that - // would yank the edit surface exactly as a reload would. - if (limiterPending_) { - const bool on = *limiterPending_; - limiterPending_.reset(); - processor_->setLimiterEnabled(on); - } + // A parameter commit that flipped the limiter already changed the sound; what waits for this + // tick is only telling the host to re-ask for the latency. Past the drag guard with the bake, + // because the restart makes the host rebuild this instance — mid-drag that would yank the + // edit surface exactly as a reload would. Unconditional: it self-cancels when nothing is + // armed, so no commit site has to remember to ask for it. + processor_->flushLatencyRestart(); // Resolve the bake affordance's availability on the SAME tick that paints it, so it // can never be enabled on one tick and refuse on the next. diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index eb5b769..d032f0d 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -246,6 +246,10 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry, // ONE reload for the re-point and the reset together: it decodes the new file and // publishes the neutral parameters in the same swap. reloadInstrument(); + // The bake's reset may have flipped the limiter; deliver the host's latency restart here + // rather than leaving it to the editor's tick, so an adopt is correct with no editor open. + // At the tail for the same reason setState's is (see there). + flushLatencyRestart(); } void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 33ea8ab..784a258 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -86,6 +86,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { // A new blob is new facts — the legacy lift gets one fresh run per restored state. legacyLiftConcluded_.store(false, std::memory_order_relaxed); reloadInstrument(); + // This caller has no editor to flush for it. At the TAIL on purpose: a host that services the + // restart synchronously deactivates/reactivates, and our setActive(true) reloads — from the + // refs above, which are only fully restored once this function has run to here. + flushLatencyRestart(); return kResultOk; } @@ -157,27 +161,40 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { } // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — // funnels through here, so mirroring the limiter flag at this one point is what keeps the - // audio thread's copy and the latency report from ever lagging what is persisted, and - // requesting the restart here (not just from setLimiterEnabled) is what keeps the host's - // PDC from lagging it too. Coalesced: writing the value already held requests nothing. + // audio thread's copy and the latency report from ever lagging what is persisted. The MIRROR + // is inline, because that is the sound the user clicked for; the host notification is not, + // because this funnel is reachable from inside a mouse handler and restartComponent is not + // safe there (see this directory's CLAUDE.md). publishLimiterEnabled(params.limiterEnabled); - if (limiterFlagChanged && componentHandler) { - // The SDK requires this on the UI thread and answers getLatencySamples only after the - // host's own deactivate/reactivate — so the flag above is already committed by the time - // the host asks. This is a kLatencyChanged restart with the bus untouched, NOT the - // retired per-mode kIoChanged bus renegotiation (see initialize()); do not conflate. - componentHandler->restartComponent(kLatencyChanged); + // Armed AFTER the mirror, so getLatencySamples already answers the new value for the whole + // window the arm stays outstanding. Sticky and idempotent: any number of changes before one + // flush cost one restart, and the flush is the only thing that clears it. + if (limiterFlagChanged) { + latencyRestartPending_.store(true, std::memory_order_release); } } +void ReaSamplerProcessor::flushLatencyRestart() { + // Cleared only once it can actually be delivered — an arm raised before the host connected + // its handler waits for a later flush instead of evaporating. + if (!componentHandler) return; + if (!latencyRestartPending_.exchange(false, std::memory_order_acquire)) return; + // The SDK requires this on the UI thread and answers getLatencySamples only after the host's + // own deactivate/reactivate — so the flag is long committed by the time the host asks. This + // is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode kIoChanged + // bus renegotiation (see initialize()); do not conflate. + componentHandler->restartComponent(kLatencyChanged); +} + void ReaSamplerProcessor::publishLimiterEnabled(bool on) { limiterEnabled_.store(on, std::memory_order_relaxed); limiter_.setEnabled(on); } void ReaSamplerProcessor::setLimiterEnabled(bool on) { - // Thin wrapper: setInstrumentParams is the one funnel that mirrors the flag AND requests - // the restart, so every writer of the parameter set — this one included — agrees. + // Rebased off the PROCESSOR's copy rather than taking a caller-supplied set: an editor + // snapshot may carry edits it has not committed, and writing one back here would clobber + // them. Everything else is setInstrumentParams', the one funnel every writer agrees through. InstrumentParams params = instrumentParams(); params.limiterEnabled = on; setInstrumentParams(params); diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index b6ca274..f67fc69 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -446,11 +446,6 @@ private: std::string bakeMessage_; // last outcome, shown in the title band int bakeMessageTicks_ = 0; // sync ticks the message survives - // The limiter toggle, armed by the click and run on the sync tick — the commit requests a - // host latency restart, which is the same nested-inside-a-mouse-handler hazard the bake - // defers for. Empty = nothing armed. - std::optional limiterPending_; - // The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only): // an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts // down via the sync tick). Never ingests, never inserts a timeline item. diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 4e1f65b..d6f93fd 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -236,15 +236,23 @@ public: void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()] // The master-bus limiter's single enable (persisted in the parameter set). UI thread only: - // a thin wrapper over setInstrumentParams, the one funnel that both mirrors the flag and - // requests the host's kLatencyChanged restart, which the SDK requires be issued from the UI - // thread and which process() must therefore never trigger. Setting the value it already - // holds is a no-op, so repeated clicks on one segment cost no restart. + // a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the + // audio thread INLINE — the sound follows the click — and only ARMS the host's latency + // restart. Setting the value it already holds is a no-op, so repeated clicks on one segment + // cost no restart. bool limiterEnabled() const { return limiterEnabled_.load(std::memory_order_relaxed); } void setLimiterEnabled(bool on); + // Delivers the armed kLatencyChanged restart, at most once per armed window, and does + // nothing when none is armed. Split off the commit because the SDK requires this on the UI + // thread AND a host may service it synchronously — deactivate/reactivate, which reaches our + // setActive(true) and its reloadInstrument — so it must never run nested inside a mouse + // handler. The editor's sync tick is the general drain; the two callers that can commit with + // no editor open (setState, adoptBakedCapture) flush themselves at their own tails. + void flushLatencyRestart(); + // Fires a one-shot preview note-on/off through the live VoiceEngine — the same // noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice // count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio @@ -471,6 +479,11 @@ private: // getLatencySamples answers from. instrument::engine::Limiter limiter_; std::atomic limiterEnabled_{false}; + // Set by the commit funnel when the enable actually changed, cleared only by + // flushLatencyRestart. A sticky bool and not a count on purpose: the host is being told to + // re-ASK, so N changes before one flush need exactly one restart, and whatever + // getLatencySamples answers at that moment is the truth being announced. + std::atomic latencyRestartPending_{false}; // What the audio thread publishes about the output bus each block, relaxed. The peaks and // minGain ACCUMULATE (max / min) across every block since the UI last read, and From 4b0b03d8d544acc42e7f7a1000a98bd0f46e6dd6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 12:13:37 -0400 Subject: [PATCH 42/56] Fix stale post-widen deck-reflow figures (90/144/1190px) across docs and tests, and split test_deck_groups.cpp's width-budget fixtures into a new file. --- docs/PLAN.md | 8 +- docs/TODO.md | 2 +- docs/product/instrument-control-surface.md | 2 +- src/core/instrument/CLAUDE.md | 2 +- src/core/instrument/ui/CMakeLists.txt | 11 +- src/core/instrument/ui/deck_groups.cpp | 4 +- src/core/instrument/ui/sample_bands.h | 2 +- tests/test_deck_groups.cpp | 474 +++------------------ tests/test_deck_groups_measured.cpp | 399 +++++++++++++++++ 9 files changed, 475 insertions(+), 429 deletions(-) create mode 100644 tests/test_deck_groups_measured.cpp diff --git a/docs/PLAN.md b/docs/PLAN.md index 11d1469..b539e0b 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -613,7 +613,9 @@ folded into the tracks below: - **Γ-F4** — there **is** an explicit loop enable, and it lives on the **chrome row**, not in a deck. W2-T2's scope grows accordingly — spec §6.4. - **Γ-F5** — MASTER's reserved slot is **one** cell. The 90 px headroom argument behind - that is spec §1.6 and governs every future control addition. + that is spec §1.6 and governs every future control addition. (**Moved afterwards:** + Γ-W3-T1 widened the row block, so §1.6's ledger is now 82 px — the ruling and its + purchasing power are unchanged, only the number.) - **Γ-F6** — **ship dynamic latency as ruled.** The `restartComponent(kLatencyChanged)` deactivate/reactivate the SDK mandates is accepted: *"the limiter will either be on or off on its instance, toggling during playback is not a use case."* No constant-latency @@ -1083,9 +1085,9 @@ waveform band. Row 1's natural width fits the block **only after this track's `Band|Notch` move**: 1030 today, +42 from W2-T1's PITCH/RATE, −92 here, = **980**. That is this track's fit assertion and W1-T4 deliberately left it open. -- **The 90 px of remaining headroom is the budget for the life of this layout**, and one deck +- **The 82 px of remaining headroom is the budget for the life of this layout**, and one deck cell is 60 px. **This is why MASTER's reserved slot is ONE cell** (Γ-F5, ruled): two would - spend 60 of the 90 up front on a control nobody has named, leaving 30 — which would freeze + spend 60 of the 82 up front on a control nobody has named, leaving 22 — which would freeze row 1 forever, since any later row-1 addition needs 60. Widening MASTER later costs the same 60 it would cost now, and by then the trade is against a real control instead of a guess. **State this ledger where a future reader will hit it** — spec §1.6 is its home, and a diff --git a/docs/TODO.md b/docs/TODO.md index acc9f6f..40cdf4f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -365,7 +365,7 @@ The within-deck stacking idea is retired, not deferred. **The measured-geometry block that used to live here has been deleted, not moved.** It was taken at the 840 px floor with `kDeckCellW = 48` and is wrong twice over — Θ-W6-T1 changed both the floor (980) and the cell metrics (60 × 74). The current, re-derived geometry — every -group's width, both row totals, and the resulting 1190 × 680 floor — is the table in +group's width, both row totals, and the resulting 1198 × 680 floor — is the table in `docs/product/instrument-control-surface.md` §1.2. **Do not resurrect the old numbers.** The unresolved 864-vs-872 px VELOCITY↔VOICE adjacency-threshold discrepancy is retired with them; it was measured against a layout that no longer exists. diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index 8f94b05..a2fdb58 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -311,7 +311,7 @@ Two corollaries for a reader who wants to add something: FILTER's `Band|Notch` move exploits). A cell always costs its 60 px. - **The chrome row is a separate budget.** The toolbar row's right-anchored control run is paid for out of the *title* slot, not out of the window floor — which is why the loop - enable (§6.5) costs zero of the 90. That is a genuinely different purse and must not be + enable (§6.5) costs zero of the 82. That is a genuinely different purse and must not be confused with this one. --- diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 223e000..e81f5ea 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -326,7 +326,7 @@ anything for a trigger shape. ### `ui/` - `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own. -- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack. +- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups_measured.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack. - `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset. - `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means. - `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index a005c02..5f503c9 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -75,10 +75,13 @@ reasampler_test(master_meter LINK master_meter waveform_view) reasampler_pure_library(deck_groups SOURCES deck_groups.cpp LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter) -# sample_bands and master_meter are linked directly for the test: the deck-fits-the-floor-window -# assertion needs the band allocator, and the MASTER-reserve identity needs the column width the -# PRIVATE edge above does not re-export. -reasampler_test(deck_groups LINK deck_groups sample_bands master_meter) +# WHICH descriptors the deck carries, and how they resolve to a layout — no window-floor budget +# assertion here, so this target needs neither sample_bands nor master_meter. +reasampler_test(deck_groups LINK deck_groups) +# The width-BUDGET half, split out on the same seam PRIVATE master_meter already draws above: +# the deck-fits-the-floor-window assertion needs the band allocator, and the MASTER-reserve +# identity needs the column width the PRIVATE edge on deck_groups does not re-export. +reasampler_test(deck_groups_measured LINK deck_groups sample_bands master_meter) # The commit-tier + overlay-selection state machine, split out of deck_groups_tests on the seam # those fixtures already had: deckParamCommit/liveCommitFor and the overlay predicates are pure # control-id/enum logic that touches no layout, so this target needs no sample_bands/master_meter. diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 3dfb7f7..2eb2e60 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -15,7 +15,7 @@ double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi // Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays // no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob // row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen -// the CONTOUR row, which has 144px of slack, not the SOUND row. +// the CONTOUR row, which has 152px of slack, not the SOUND row. constexpr int kEnvModeSegW = 23; } // namespace @@ -133,7 +133,7 @@ std::vector sampleDeckGroups(PlayMode playMode) { { // The lower slot is reserved and draws NOTHING: blank reads as breathing room where a // dashed placeholder would read as unfinished. It is one cell, not two — a second - // would spend 60 of the layout's whole 90px budget on a control nobody has named. + // would spend 60 of the layout's whole 82px budget on a control nobody has named. DeckGroupDesc master; master.id = kGroupMaster; master.captionWidth = 46; diff --git a/src/core/instrument/ui/sample_bands.h b/src/core/instrument/ui/sample_bands.h index eb8cb7d..8b31d63 100644 --- a/src/core/instrument/ui/sample_bands.h +++ b/src/core/instrument/ui/sample_bands.h @@ -55,7 +55,7 @@ struct SampleBands { }; // Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own -// wrapped height (from knob_deck) — the only interior measurement the allocator needs, so +// height (from knob_deck) — the only interior measurement the allocator needs, so // the deck band is exactly as tall as its content. Pure. SampleBands computeSampleBands(int w, int h, int deckHeight); diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 9b75b87..88276d6 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -1,18 +1,16 @@ // Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no -// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH -// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), -// the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve -// cells and its placement immediately left of VOICE, the wrapped deck height at the editor's -// floor width and its fit inside the floor window, the pinned Gate group widths, the editor -// floor derived from the deck's width budget and each group's categorical row, -// that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round -// trip restores the layout exactly, the hit-test reaching the new filter controls, and the -// bipolar knob law's inverse pair. The commit-tier routing and the overlay-selection state -// machine live in test_deck_groups_state.cpp — they touch no layout at all. +// framework. Pins WHICH descriptors the Sample face carries and how they resolve to a layout: +// the signal-flow group order (pitch -> filter -> amp), the Filter group's contents, the +// VELOCITY group's exclusive ownership of the three curve cells and its placement immediately +// left of VOICE, row membership, that no face leaves slack where its dropped controls were, +// that a Gate/Spline/Gate round trip restores the layout exactly, the hit-test reaching the new +// filter controls, and the bipolar knob law's inverse pair. The width-BUDGET fixtures (the +// editor floor's derivation, the row/gutter arithmetic at the floor, MASTER's interior) live in +// test_deck_groups_measured.cpp, which needs sample_bands/master_meter and this file does not. +// The commit-tier routing and the overlay-selection state machine live in +// test_deck_groups_state.cpp — they touch no layout at all. #include "../src/core/instrument/ui/deck_groups.h" -#include "../src/core/instrument/ui/master_meter.h" // kMeterColumnW: MASTER's reserve IS this -#include "../src/core/instrument/ui/sample_bands.h" #include #include @@ -25,9 +23,14 @@ static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) -// The editor's floor width, which is also its default (checkSizeConstraint clamps to it), less -// the band allocator's kPad inset on each side. -static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad; +// A pad and an available width for exercising layoutDeck, kept independent of sample_bands.h — +// this file pins what the deck IS, not the window-floor budget. kSampleAvail equals the real +// floor's available width because it is derived the same way (block + gap + spanning deck); +// that identity, and the window-fact constants (kPad, kEditorMinWidth) it derives from, are +// test_deck_groups_measured.cpp's to own. +static constexpr int kSamplePad = 8; +static constexpr int kSampleAvail = kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW; +static constexpr int kSampleAvailWide = kSampleAvail + 200; // comfortably above the block static int indexOfGroup(const std::vector& g, int id) { for (std::size_t i = 0; i < g.size(); ++i) { @@ -99,7 +102,7 @@ static void testCurveTargetNamesEachCellsOwnDestination() { // treats them as knob cells, so the popup routing rides an ordinary Knob hit. static void testVelocityCellsHitTestWithinTheirGroup() { const std::vector g = sampleDeckGroups(PlayMode::Gate); - const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); + const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail); const DeckGroupLayout& v = dl.groups[static_cast(indexOfGroup(g, kGroupVelocity))]; CHECK(v.cells.size() == 3); @@ -257,8 +260,8 @@ static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() { CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap); CHECK(deckHeight(g) == 216); - for (int avail : {kAvailAtMinWidth, kAvailAtMinWidth + 200, 4000}) { - const DeckLayout dl = layoutDeck(g, kPad, 0, avail); + for (int avail : {kSampleAvail, kSampleAvail + 200, 4000}) { + const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail); CHECK(dl.rowCount == 2); CHECK(dl.height == 216); CHECK(dl.groups.size() == g.size()); @@ -268,7 +271,7 @@ static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() { if (row == DeckRow::Spanning) { CHECK(gl.box.y == 0); CHECK(gl.box.height == kDeckSpanningH); - CHECK(gl.box.right() == kPad + avail); // right-anchored at every width + CHECK(gl.box.right() == kSamplePad + avail); // right-anchored at every width } else { CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]); CHECK(gl.box.height == kDeckGroupH); @@ -278,54 +281,6 @@ static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() { } } -// The guard the raised floor exists to provide: at the smallest window the host can produce, -// the deck band still lands inside the client area AND the waveform still gets its two-lane -// floor. Growing the deck past what the floor height can hold fails HERE instead of silently pushing -// FILTER ENV / AMP / VOICE / MASTER off-screen, where there is no scroll to reach them. -static void testDeckFitsInsideTheEnforcedMinimumWindow() { - for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { - const std::vector g = sampleDeckGroups(mode); - const int h = deckHeight(g); - const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); - CHECK(deckRowCount(g) == 2); // either face - CHECK(b.decks.height == h); - // The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the - // waveform 358, against 328/246 before. Pinned now that both are reached by - // construction rather than by a pack outcome. - CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap); - CHECK(b.waveform.height == 358); - // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the - // deck down until the waveform hits its floor, so any deck too tall to fit stops - // landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the - // degrade can still leave the deck ending at the window edge. - CHECK(b.decks.bottom() == kEditorMinHeight - kPad); - CHECK(b.waveform.height >= kWaveformMinHeight); - } -} - -// The floor is a DERIVED number, and this is the one place the derivation is written down — -// sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is -// the only one that includes both. -static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() { - CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth); - // The budget: what is left between the derived floor and the hard ceiling, and it is spent - // once. A cell costs 60 of it. - CHECK(kEditorCeilingWidth - kEditorMinWidth == 82); - // 82 still buys one more deck cell (60), which is the only purchase the ledger promises — - // the widen below spent 8 px of slack, not the layout's purchasing power. - CHECK(kEditorCeilingWidth - kEditorMinWidth >= kDeckCellW); - // The reflow's 112 px goes entirely to the waveform, so the height does not move. - CHECK(kEditorMinHeight == 680); - // 1190 + 8: the row block was widened 1020 -> 1028 to put the two rows' filter edges on - // one pixel, which is the only reason the floor moved off Γ-W1-T4's number. - CHECK(kEditorMinWidth == 1198); - CHECK(kEditorMinWidth <= kEditorCeilingWidth); - CHECK(kEditorMinHeight <= 720); - // And the row block really is what the two rows justify inside — derived from the floor - // and the spanning reserve, not restated. - CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW); -} - static void testEveryDeckGroupBelongsToExactlyOneRow() { CHECK(deckRowFor(kGroupPitch) == DeckRow::Sound); CHECK(deckRowFor(kGroupFilter) == DeckRow::Sound); @@ -351,210 +306,46 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() { } } -// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes: -// it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move -// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold -// them at 312 in Trigger too — asserted here rather than assumed. -static void testBothRowsAndTheSpanningDeckFitTheBudget() { - for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { - const std::vector g = sampleDeckGroups(mode); - int width[3] = {0, 0, 0}; - int count[3] = {0, 0, 0}; - for (const DeckGroupDesc& d : g) { - const int r = static_cast(deckRowFor(static_cast(d.id))); - width[r] += deckGroupWidth(d); - ++count[r]; - } - const int sound = static_cast(DeckRow::Sound); - const int contour = static_cast(DeckRow::Contour); - const int spanning = static_cast(DeckRow::Spanning); - - CHECK(count[sound] == 4); - CHECK(width[sound] == 980); // 192 + 432 + 192 + 164 - CHECK(count[contour] == 3); - CHECK(width[contour] == 876); // 252 + 312 + 312 - CHECK(count[spanning] == 1); - CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent - - for (int r : {sound, contour}) { - CHECK(width[r] <= kDeckRowBlockW); - // Slack enough that no gutter in the row falls under the minimum. - CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap); - } - } -} - -// The gutters the justification law produces at the floor, and the alignment they buy. The -// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than -// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's -// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three -// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap -// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule. -static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() { - const std::vector g = sampleDeckGroups(PlayMode::Gate); - const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); - - const auto box = [&](int id) { - return dl.groups[static_cast(indexOfGroup(g, id))].box; - }; - // Row 1: flush left, flush right on the block, and three EQUAL gutters — 48 divides by 3 - // with no residue, so no gutter carries a leftover pixel. - CHECK(box(kGroupPitch).x == kPad); - CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 16); - CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16); - CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16); - CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW); - - // Row 2: flush left, flush right, two gutters exactly equal. - CHECK(box(kGroupPitchEnv).x == kPad); - CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76); - CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76); - CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW); - - // The tie-line, block-relative: both filter edges on ONE pixel, which is what the widen - // bought. Pinned as an identity too, so a group-width change cannot pass by moving both. - CHECK(box(kGroupFilterEnv).right() - kPad == 640); - CHECK(box(kGroupFilter).right() - kPad == 640); - CHECK(box(kGroupFilter).right() == box(kGroupFilterEnv).right()); - - // MASTER is right-anchored outside the block, one kDeckGroupGap clear of it. - CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap); - CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth); -} - -// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay -// flush at every width — the property the exact-at-the-floor numbers above are one point of. -// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides -// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past -// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure. -// -// Checked per ROW (tracking the last-seen box in each of the two categorical rows while -// walking dl.groups in deck order), not just deck-order neighbours: two same-row groups can -// sit apart in deck order with a different-row group between them, and a deck-order-only -// check would silently skip that gutter. -static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() { - for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { - const std::vector g = sampleDeckGroups(mode); - int lastDrift = 1 << 20; // sentinel above any real drift - for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) { - const DeckLayout dl = layoutDeck(g, kPad, 0, avail); - const DeckGroupLayout* prevInRow[2] = {nullptr, nullptr}; - for (const DeckGroupLayout& gl : dl.groups) { - const DeckRow row = deckRowFor(static_cast(gl.id)); - if (row == DeckRow::Spanning) continue; - const int r = row == DeckRow::Contour ? 1 : 0; - if (prevInRow[r]) { - CHECK(gl.box.x - prevInRow[r]->box.right() >= kDeckGroupGap); +// The gap fix as a property of the shipped descriptors, not a picture: whichever face a +// mode-dependent group shows, its knob row still spans the group's whole reserved run. The +// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the +// dropped control was. What the run does not cover is the indivisible residue alone, strictly +// under one pixel per cell. Checked at both a tight and a genuinely wider width. +static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { + for (int avail : {kSampleAvail, kSampleAvailWide}) { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail); + CHECK(dl.groups.size() == g.size()); + for (std::size_t i = 0; i < dl.groups.size(); ++i) { + // The spanning deck's slots STACK — the run-division law this pins is the + // horizontal one, and its vertical guard is its own test. + if (g[i].row == DeckRow::Spanning) continue; + const DeckGroupLayout& lay = dl.groups[i]; + const int reserved = static_cast(g[i].cellIds.size()) * kDeckCellW; + const std::size_t present = lay.cells.size(); + CHECK(present > 0); + for (std::size_t k = 0; k < present; ++k) { + const DeckCellLayout& c = lay.cells[k]; + CHECK(c.id >= 0); // a reserve yields width, never a dead rect + CHECK(c.cell.width == lay.cells[0].cell.width); + if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right()); } - prevInRow[r] = ≷ + const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x; + CHECK(reserved - covered < static_cast(present)); + CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX); + CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX); } - const auto right = [&](int id) { - return dl.groups[static_cast(indexOfGroup(g, id))].box.right(); - }; - // Flush right on the block at every width, both rows. - CHECK(right(kGroupVoice) == right(kGroupAmpEnv)); - // Monotone in width rather than oscillating: row 2's two gutters absorb slack - // faster than row 1's three, so the gap only ever opens. - const int drift = right(kGroupFilter) - right(kGroupFilterEnv); - CHECK(drift <= lastDrift); - lastDrift = drift; } - // It really does open up: the tie-line is exact AT the floor and separates above it, - // which is the accepted outcome rather than a near-miss to be pinned back. - CHECK(lastDrift < -50); } } -// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own -// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect -// across both, never a readout per row. -static void testTheMasterDeckInteriorLandsOnBothRowBaselines() { - const std::vector g = sampleDeckGroups(PlayMode::Gate); - const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); - const DeckGroupLayout& m = - dl.groups[static_cast(indexOfGroup(g, kGroupMaster))]; - - CHECK(m.box.width == 142); - CHECK(m.box.height == 216); - // 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total, and the 62 is the meter - // module's own kMeterColumnW rather than a copy of it. That link is the whole point: the - // column is banked to GROW (§1.2), and a reserve that did not track it would leave the - // interior underfilling or overrunning with every test still green. - CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + kMeterColumnW + kDeckGroupPadX == 142); - CHECK(m.column.id == cell(DeckParam::kMasterMeter)); - CHECK(m.column.box.width == kMeterColumnW); - - // One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed - // position and draws nothing. - CHECK(m.cells.size() == 1); - CHECK(m.cells[0].id == cell(DeckParam::kMasterGain)); - CHECK(m.cells[0].cell.y - m.box.y == 26); - const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap; - CHECK(reserveTop - m.box.y == 138); - - // The two baselines are row 1's and row 2's own. - const DeckGroupLayout& filter = - dl.groups[static_cast(indexOfGroup(g, kGroupFilter))]; - const DeckGroupLayout& amp = - dl.groups[static_cast(indexOfGroup(g, kGroupAmpEnv))]; - CHECK(m.cells[0].cell.y == filter.cells[0].cell.y); - CHECK(reserveTop == amp.cells[0].cell.y); - - // The meter: one rect spanning both baselines, 62 x 186. - CHECK(m.column.id == cell(DeckParam::kMasterMeter)); - CHECK(m.column.box.width == 62); - CHECK(m.column.box.height == 186); - CHECK(m.column.box.y == m.cells[0].cell.y); - CHECK(m.column.box.bottom() - m.box.y == 212); -} - -// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left -// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law -// applied vertically — which would stretch the one gain knob over the whole 186 px. -static void testTheMasterColumnDoesNotDivideItsRunVertically() { - const std::vector g = sampleDeckGroups(PlayMode::Gate); - const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); - const DeckGroupLayout& m = - dl.groups[static_cast(indexOfGroup(g, kGroupMaster))]; - CHECK(m.cells[0].cell.height == kDeckCellH); - CHECK(m.cells[0].cell.width == kDeckCellW); - // Under the run-division law the lone present cell would take the whole two-slot run; - // here it takes exactly one slot and leaves the rest empty. - CHECK(m.cells[0].cell.height < m.column.box.height); - CHECK(m.cells[0].cell.bottom() < m.column.box.bottom()); - CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize); - // And dropping the reserve does not move the gain knob or the meter — the slot below it is - // reserved height, so nothing above it depends on whether it is there. - std::vector noReserve = g; - for (DeckGroupDesc& d : noReserve) { - if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)}; - } - const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth); - const DeckGroupLayout& m2 = - dl2.groups[static_cast(indexOfGroup(noReserve, kGroupMaster))]; - CHECK(m2.cells[0].cell == m.cells[0].cell); - CHECK(m2.column.box == m.column.box); -} - -// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column -// derived from either edge lands in the same place — that balance is what let a left-derived -// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or -// a limiter-toggle change would) and the column must still land flush against the group's own -// right padding, derived from innerRight rather than measured past the cell slots. -static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() { - const std::vector g = sampleDeckGroups(PlayMode::Gate); - DeckGroupDesc probe = g[static_cast(indexOfGroup(g, kGroupMaster))]; - probe.captionWidth += 40; // unbalances it: the caption row now measures past the knob row - const std::vector one = {probe}; - const DeckLayout dl = layoutDeck(one, kPad, 0, kAvailAtMinWidth); - const DeckGroupLayout& m = dl.groups[0]; - CHECK(m.box.width > 142); // the widen is real, not absorbed elsewhere - CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX); -} +// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned +// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here. static void testHitTestResolvesTheNewFilterControls() { const std::vector g = sampleDeckGroups(PlayMode::Gate); - const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); + const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail); const DeckGroupLayout& f = dl.groups[static_cast(indexOfGroup(g, kGroupFilter))]; @@ -617,143 +408,6 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() { CHECK(deckNormFromBipolar(3.0) == 1.0); } -// The three mode toggles ride each env group's caption slack, so the deck's wrapped geometry -// is unchanged by them: raising their segment width past the caption headroom would reflow the -// first row and push the deck to a fourth one (see testDeckFitsInsideTheEnforcedMinimumWindow). -static void testTheModeTogglesCostNoGroupWidth() { - for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { - for (const DeckGroupDesc& g : sampleDeckGroups(mode)) { - if (g.captionToggle2.id < 0) continue; - DeckGroupDesc without = g; - without.captionToggle2 = DeckToggleDesc{}; - CHECK(deckGroupWidth(g) == deckGroupWidth(without)); - } - } -} - -// A typical larger window, to check the same properties once the deck has re-wrapped. -static constexpr int kAvailAtLargerWidth = 1100 - 2 * kPad; - -// The gap fix as a property of the shipped descriptors, not a picture: whichever face a -// mode-dependent group shows, its knob row still spans the group's whole reserved run. The -// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the -// dropped control was. What the run does not cover is the indivisible residue alone, strictly -// under one pixel per cell. -static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() { - for (int avail : {kAvailAtMinWidth, kAvailAtLargerWidth}) { - for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { - const std::vector g = sampleDeckGroups(mode); - const DeckLayout dl = layoutDeck(g, kPad, 0, avail); - CHECK(dl.groups.size() == g.size()); - for (std::size_t i = 0; i < dl.groups.size(); ++i) { - // The spanning deck's slots STACK — the run-division law this pins is the - // horizontal one, and its vertical guard is its own test. - if (g[i].row == DeckRow::Spanning) continue; - const DeckGroupLayout& lay = dl.groups[i]; - const int reserved = static_cast(g[i].cellIds.size()) * kDeckCellW; - const std::size_t present = lay.cells.size(); - CHECK(present > 0); - for (std::size_t k = 0; k < present; ++k) { - const DeckCellLayout& c = lay.cells[k]; - CHECK(c.id >= 0); // a reserve yields width, never a dead rect - CHECK(c.cell.width == lay.cells[0].cell.width); - if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right()); - } - const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x; - CHECK(reserved - covered < static_cast(present)); - CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX); - CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX); - } - } - } -} - -// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned -// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here. - -// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus -// padding) is what it measures from, and the caption row must stay under that. The ceiling is -// asserted by construction rather than as a comment: at a caption reserve of 80 the group is -// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the -// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is. -static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() { - const std::vector g = sampleDeckGroups(PlayMode::Gate); - const DeckGroupDesc* pitch = nullptr; - for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d; - CHECK(pitch != nullptr); - if (!pitch) return; - CHECK(pitch->cellIds.size() == 3); - CHECK(pitch->cellIds[0] == static_cast(DeckParam::kKeyTrack)); - CHECK(pitch->cellIds[1] == static_cast(DeckParam::kRate)); - CHECK(pitch->cellIds[2] == static_cast(DeckParam::kPitch)); - CHECK(deckGroupWidth(*pitch) == 192); - CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192); // the knob row IS the measurement - - DeckGroupDesc probe = *pitch; - probe.captionWidth = 80; - CHECK(deckGroupWidth(probe) == 192); // at the ceiling the caption row still fits under it - probe.captionWidth = 81; - CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over -} - -// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47, -// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate -// caption ceiling above is: a change to either group's caption width or its enable toggle -// would otherwise invalidate the recorded numbers with nothing failing. -static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() { - const std::vector g = sampleDeckGroups(PlayMode::Gate); - const DeckGroupDesc& penv = g[static_cast(indexOfGroup(g, kGroupPitchEnv))]; - const DeckGroupDesc& amp = g[static_cast(indexOfGroup(g, kGroupAmpEnv))]; - CHECK(deckGroupWidth(penv) == 252); - CHECK(deckGroupWidth(amp) == 312); - - DeckGroupDesc penvProbe = penv; - penvProbe.captionToggle2.segWidth = 47; - CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven - penvProbe.captionToggle2.segWidth = 48; - CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over - - DeckGroupDesc ampProbe = amp; - ampProbe.captionToggle2.segWidth = 55; - CHECK(deckGroupWidth(ampProbe) == 312); - ampProbe.captionToggle2.segWidth = 56; - CHECK(deckGroupWidth(ampProbe) > 312); -} - -// Every group's width, in BOTH play modes, against the measured layout table -// (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the -// reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the -// contour row's 876 a constant rather than a Gate-only fact. -static void testEveryGroupWidthMatchesTheMeasuredLayout() { - const struct { int id; int width; } want[] = { - {kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432}, - {kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192}, - {kGroupVoice, 164}, {kGroupMaster, 142}, - }; - for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { - const std::vector g = sampleDeckGroups(mode); - CHECK(g.size() == sizeof(want) / sizeof(want[0])); - const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); - for (const auto& w : want) { - const int i = indexOfGroup(g, w.id); - CHECK(i >= 0); - if (i < 0) continue; - CHECK(deckGroupWidth(g[static_cast(i)]) == w.width); - const DeckGroupLayout& lay = - dl.groups[static_cast(indexOfGroup(g, w.id))]; - CHECK(lay.box.width == w.width); - } - // Gate carries no reserves, so its cells are the deck's base size; Trigger's two - // reduced faces divide the same reserved run between fewer cells and get wider ones. - for (const DeckGroupLayout& lay : dl.groups) { - for (const DeckCellLayout& c : lay.cells) { - CHECK(c.cell.width >= kDeckCellW); - if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW); - } - } - } -} - static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) { return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1; } @@ -789,12 +443,12 @@ static bool sameLayout(const DeckLayout& a, const DeckLayout& b) { // forcing rule the real callers do not use. static void testGateSplineGateRoundTripsToTheSameLayout() { PlayParams p; // Gate, all three envelopes staged - const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth); + const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail); p.ampSpline.mode = EnvMode::Spline; enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through CHECK(p.playMode == PlayMode::Trigger); - const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth); + const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail); // The excursion is real: the amp face's cells are strictly wider than Gate's. const DeckGroupLayout& gateAmp = before.groups[static_cast(indexOfGroup(sampleDeckGroups(PlayMode::Gate), @@ -809,12 +463,11 @@ static void testGateSplineGateRoundTripsToTheSameLayout() { p.ampSpline.mode = EnvMode::Staged; CHECK(!splineActive(p)); p.playMode = PlayMode::Gate; // Gate is selectable again once nothing is drawn - const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth); + const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kSamplePad, 0, kSampleAvail); CHECK(sameLayout(before, after)); } int main() { - testTheModeTogglesCostNoGroupWidth(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); testVelocityGroupOwnsTheThreeCurvesExclusively(); testCurveTargetNamesEachCellsOwnDestination(); @@ -825,22 +478,11 @@ int main() { testOnlySlopedStageKnobsCarryAnInnerCurveDial(); testAmpGroupWidthSurvivesAGateTriggerFlip(); testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction(); - testDeckFitsInsideTheEnforcedMinimumWindow(); - testNoFaceLeavesSlackWhereItsDroppedControlsWere(); - testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo(); - testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp(); - testEveryGroupWidthMatchesTheMeasuredLayout(); - testGateSplineGateRoundTripsToTheSameLayout(); - testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); testEveryDeckGroupBelongsToExactlyOneRow(); - testBothRowsAndTheSpanningDeckFitTheBudget(); - testGutterArithmeticAndTheFilterTieLineAtTheFloor(); - testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor(); - testTheMasterDeckInteriorLandsOnBothRowBaselines(); - testTheMasterColumnDoesNotDivideItsRunVertically(); - testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow(); + testNoFaceLeavesSlackWhereItsDroppedControlsWere(); testHitTestResolvesTheNewFilterControls(); testBipolarKnobLawRoundTripsAndIsExactAtCentre(); + testGateSplineGateRoundTripsToTheSameLayout(); if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); return g_fail == 0 ? 0 : 1; } diff --git a/tests/test_deck_groups_measured.cpp b/tests/test_deck_groups_measured.cpp new file mode 100644 index 0000000..1bb28bd --- /dev/null +++ b/tests/test_deck_groups_measured.cpp @@ -0,0 +1,399 @@ +// Layout-BUDGET tests for reasampler::instrument::ui::deck_groups, split from +// test_deck_groups.cpp on the seam CMakeLists.txt already named: these fixtures need +// sample_bands (the window-floor constants, computeSampleBands) and master_meter +// (kMeterColumnW, MASTER's reserve), which test_deck_groups.cpp's WHICH-descriptors fixtures do +// not. Pins the editor floor's derivation from the deck's width budget, the row/gutter +// justification arithmetic at and above the floor, the pinned Gate group widths, and MASTER's +// interior to the pixel. test_deck_groups.cpp pins WHICH descriptors the deck carries; this +// file pins what the width budget MEASURES them at. + +#include "../src/core/instrument/ui/deck_groups.h" +#include "../src/core/instrument/ui/master_meter.h" // kMeterColumnW: MASTER's reserve IS this +#include "../src/core/instrument/ui/sample_bands.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::ui; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// The editor's floor width, which is also its default (checkSizeConstraint clamps to it), less +// the band allocator's kPad inset on each side. +static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad; + +static int indexOfGroup(const std::vector& g, int id) { + for (std::size_t i = 0; i < g.size(); ++i) { + if (g[i].id == id) return static_cast(i); + } + return -1; +} + +static int cell(DeckParam p) { return static_cast(p); } + +// The guard the raised floor exists to provide: at the smallest window the host can produce, +// the deck band still lands inside the client area AND the waveform still gets its two-lane +// floor. Growing the deck past what the floor height can hold fails HERE instead of silently pushing +// FILTER ENV / AMP / VOICE / MASTER off-screen, where there is no scroll to reach them. +static void testDeckFitsInsideTheEnforcedMinimumWindow() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + const int h = deckHeight(g); + const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); + CHECK(deckRowCount(g) == 2); // either face + CHECK(b.decks.height == h); + // The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the + // waveform 358, against 328/246 before. Pinned now that both are reached by + // construction rather than by a pack outcome. + CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap); + CHECK(b.waveform.height == 358); + // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the + // deck down until the waveform hits its floor, so any deck too tall to fit stops + // landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the + // degrade can still leave the deck ending at the window edge. + CHECK(b.decks.bottom() == kEditorMinHeight - kPad); + CHECK(b.waveform.height >= kWaveformMinHeight); + } +} + +// The floor is a DERIVED number, and this is the one place the derivation is written down — +// sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is +// the only one that includes both. +static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() { + CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth); + // The budget: what is left between the derived floor and the hard ceiling, and it is spent + // once. A cell costs 60 of it. + CHECK(kEditorCeilingWidth - kEditorMinWidth == 82); + // 82 still buys one more deck cell (60), which is the only purchase the ledger promises — + // the widen below spent 8 px of slack, not the layout's purchasing power. + CHECK(kEditorCeilingWidth - kEditorMinWidth >= kDeckCellW); + // The reflow's 112 px goes entirely to the waveform, so the height does not move. + CHECK(kEditorMinHeight == 680); + // 1190 + 8: the row block was widened 1020 -> 1028 to put the two rows' filter edges on + // one pixel, which is the only reason the floor moved off its originally specified value. + CHECK(kEditorMinWidth == 1198); + CHECK(kEditorMinWidth <= kEditorCeilingWidth); + CHECK(kEditorMinHeight <= 720); + // And the row block really is what the two rows justify inside — derived from the floor + // and the spanning reserve, not restated. + CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW); +} + +// Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes: +// it was 1030, +42 from PITCH/RATE's third cell and −92 from FILTER's Band|Notch caption move +// take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold +// them at 312 in Trigger too — asserted here rather than assumed. +static void testBothRowsAndTheSpanningDeckFitTheBudget() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + int width[3] = {0, 0, 0}; + int count[3] = {0, 0, 0}; + for (const DeckGroupDesc& d : g) { + const int r = static_cast(deckRowFor(static_cast(d.id))); + width[r] += deckGroupWidth(d); + ++count[r]; + } + const int sound = static_cast(DeckRow::Sound); + const int contour = static_cast(DeckRow::Contour); + const int spanning = static_cast(DeckRow::Spanning); + + CHECK(count[sound] == 4); + CHECK(width[sound] == 980); // 192 + 432 + 192 + 164 + CHECK(count[contour] == 3); + CHECK(width[contour] == 876); // 252 + 312 + 312 + CHECK(count[spanning] == 1); + CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent + + for (int r : {sound, contour}) { + CHECK(width[r] <= kDeckRowBlockW); + // Slack enough that no gutter in the row falls under the minimum. + CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap); + } + } +} + +// The gutters the justification law produces at the floor, and the alignment they buy. +// At the 1028 block the justification law makes the tie-line exact by arithmetic rather than +// by a special rule: row 1's slack is 48 over three gutters (16 each, no residue) and row 2's +// is 152 over two (76 each), which lands both filter edges on 640. Only two of the three +// properties §1.3 once claimed can hold at once — a smallest gutter of exactly kDeckGroupGap +// needs a 1016 block — and 12 is a floor, not a target, so 16 satisfies the real rule. +static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + + const auto box = [&](int id) { + return dl.groups[static_cast(indexOfGroup(g, id))].box; + }; + // Row 1: flush left, flush right on the block, and three EQUAL gutters — 48 divides by 3 + // with no residue, so no gutter carries a leftover pixel. + CHECK(box(kGroupPitch).x == kPad); + CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 16); + CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 16); + CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 16); + CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW); + + // Row 2: flush left, flush right, two gutters exactly equal. + CHECK(box(kGroupPitchEnv).x == kPad); + CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76); + CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76); + CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW); + + // The tie-line, block-relative: both filter edges on ONE pixel, which is what the widen + // bought. Pinned as an identity too, so a group-width change cannot pass by moving both. + CHECK(box(kGroupFilterEnv).right() - kPad == 640); + CHECK(box(kGroupFilter).right() - kPad == 640); + CHECK(box(kGroupFilter).right() == box(kGroupFilterEnv).right()); + + // MASTER is right-anchored outside the block, one kDeckGroupGap clear of it. + CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap); + CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth); +} + +// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay +// flush at every width — the property the exact-at-the-floor numbers above are one point of. +// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides +// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past +// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure. +// +// Checked per ROW (tracking the last-seen box in each of the two categorical rows while +// walking dl.groups in deck order), not just deck-order neighbours: two same-row groups can +// sit apart in deck order with a different-row group between them, and a deck-order-only +// check would silently skip that gutter. +static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + int lastDrift = 1 << 20; // sentinel above any real drift + for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) { + const DeckLayout dl = layoutDeck(g, kPad, 0, avail); + const DeckGroupLayout* prevInRow[2] = {nullptr, nullptr}; + for (const DeckGroupLayout& gl : dl.groups) { + const DeckRow row = deckRowFor(static_cast(gl.id)); + if (row == DeckRow::Spanning) continue; + const int r = row == DeckRow::Contour ? 1 : 0; + if (prevInRow[r]) { + CHECK(gl.box.x - prevInRow[r]->box.right() >= kDeckGroupGap); + } + prevInRow[r] = ≷ + } + const auto right = [&](int id) { + return dl.groups[static_cast(indexOfGroup(g, id))].box.right(); + }; + // Flush right on the block at every width, both rows. + CHECK(right(kGroupVoice) == right(kGroupAmpEnv)); + // Monotone in width rather than oscillating: row 2's two gutters absorb slack + // faster than row 1's three, so the gap only ever opens. + const int drift = right(kGroupFilter) - right(kGroupFilterEnv); + CHECK(drift <= lastDrift); + lastDrift = drift; + } + // It really does open up: the tie-line is exact AT the floor and separates above it, + // which is the accepted outcome rather than a near-miss to be pinned back. + CHECK(lastDrift < -50); + } +} + +// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own +// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect +// across both, never a readout per row. +static void testTheMasterDeckInteriorLandsOnBothRowBaselines() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + const DeckGroupLayout& m = + dl.groups[static_cast(indexOfGroup(g, kGroupMaster))]; + + CHECK(m.box.width == kDeckSpanningW); + CHECK(m.box.height == 216); + // 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total, and the 62 is the meter + // module's own kMeterColumnW rather than a copy of it. That link is the whole point: the + // column is banked to GROW (§1.2), and a reserve that did not track it would leave the + // interior underfilling or overrunning with every test still green. + CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + kMeterColumnW + kDeckGroupPadX == + kDeckSpanningW); + CHECK(m.column.id == cell(DeckParam::kMasterMeter)); + CHECK(m.column.box.width == kMeterColumnW); + + // One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed + // position and draws nothing. + CHECK(m.cells.size() == 1); + CHECK(m.cells[0].id == cell(DeckParam::kMasterGain)); + CHECK(m.cells[0].cell.y - m.box.y == 26); + const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap; + CHECK(reserveTop - m.box.y == 138); + + // The two baselines are row 1's and row 2's own. + const DeckGroupLayout& filter = + dl.groups[static_cast(indexOfGroup(g, kGroupFilter))]; + const DeckGroupLayout& amp = + dl.groups[static_cast(indexOfGroup(g, kGroupAmpEnv))]; + CHECK(m.cells[0].cell.y == filter.cells[0].cell.y); + CHECK(reserveTop == amp.cells[0].cell.y); + + // The meter: one rect spanning both baselines, 62 x 186. + CHECK(m.column.id == cell(DeckParam::kMasterMeter)); + CHECK(m.column.box.width == kMeterColumnW); + CHECK(m.column.box.height == 186); + CHECK(m.column.box.y == m.cells[0].cell.y); + CHECK(m.column.box.bottom() - m.box.y == 212); +} + +// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left +// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law +// applied vertically — which would stretch the one gain knob over the whole 186 px. +static void testTheMasterColumnDoesNotDivideItsRunVertically() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + const DeckGroupLayout& m = + dl.groups[static_cast(indexOfGroup(g, kGroupMaster))]; + CHECK(m.cells[0].cell.height == kDeckCellH); + CHECK(m.cells[0].cell.width == kDeckCellW); + // Under the run-division law the lone present cell would take the whole two-slot run; + // here it takes exactly one slot and leaves the rest empty. + CHECK(m.cells[0].cell.height < m.column.box.height); + CHECK(m.cells[0].cell.bottom() < m.column.box.bottom()); + CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize); + // And dropping the reserve does not move the gain knob or the meter — the slot below it is + // reserved height, so nothing above it depends on whether it is there. + std::vector noReserve = g; + for (DeckGroupDesc& d : noReserve) { + if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)}; + } + const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth); + const DeckGroupLayout& m2 = + dl2.groups[static_cast(indexOfGroup(noReserve, kGroupMaster))]; + CHECK(m2.cells[0].cell == m.cells[0].cell); + CHECK(m2.column.box == m.column.box); +} + +// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column +// derived from either edge lands in the same place — that balance is what let a left-derived +// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or +// a limiter-toggle change would) and the column must still land flush against the group's own +// right padding, derived from innerRight rather than measured past the cell slots. +static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + DeckGroupDesc probe = g[static_cast(indexOfGroup(g, kGroupMaster))]; + probe.captionWidth += 40; // unbalances it: the caption row now measures past the knob row + const std::vector one = {probe}; + const DeckLayout dl = layoutDeck(one, kPad, 0, kAvailAtMinWidth); + const DeckGroupLayout& m = dl.groups[0]; + CHECK(m.box.width > kDeckSpanningW); // the widen is real, not absorbed elsewhere + CHECK(m.column.box.right() == m.box.right() - kDeckGroupPadX); +} + +// The three mode toggles ride each env group's caption slack, on the CONTOUR row: raising +// their segment width past the caption headroom would widen that row and eat its gutters, +// not add a row — row count is a property of the group inventory, not of width. +static void testTheModeTogglesCostNoGroupWidth() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + for (const DeckGroupDesc& g : sampleDeckGroups(mode)) { + if (g.captionToggle2.id < 0) continue; + DeckGroupDesc without = g; + without.captionToggle2 = DeckToggleDesc{}; + CHECK(deckGroupWidth(g) == deckGroupWidth(without)); + } + } +} + +// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus +// padding) is what it measures from, and the caption row must stay under that. The ceiling is +// asserted by construction rather than as a comment: at a caption reserve of 80 the group is +// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the +// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is. +static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckGroupDesc* pitch = nullptr; + for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d; + CHECK(pitch != nullptr); + if (!pitch) return; + CHECK(pitch->cellIds.size() == 3); + CHECK(pitch->cellIds[0] == static_cast(DeckParam::kKeyTrack)); + CHECK(pitch->cellIds[1] == static_cast(DeckParam::kRate)); + CHECK(pitch->cellIds[2] == static_cast(DeckParam::kPitch)); + CHECK(deckGroupWidth(*pitch) == 192); + CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192); // the knob row IS the measurement + + DeckGroupDesc probe = *pitch; + probe.captionWidth = 80; + CHECK(deckGroupWidth(probe) == 192); // at the ceiling the caption row still fits under it + probe.captionWidth = 81; + CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over +} + +// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47, +// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate +// caption ceiling above is: a change to either group's caption width or its enable toggle +// would otherwise invalidate the recorded numbers with nothing failing. +static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckGroupDesc& penv = g[static_cast(indexOfGroup(g, kGroupPitchEnv))]; + const DeckGroupDesc& amp = g[static_cast(indexOfGroup(g, kGroupAmpEnv))]; + CHECK(deckGroupWidth(penv) == 252); + CHECK(deckGroupWidth(amp) == 312); + + DeckGroupDesc penvProbe = penv; + penvProbe.captionToggle2.segWidth = 47; + CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven + penvProbe.captionToggle2.segWidth = 48; + CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over + + DeckGroupDesc ampProbe = amp; + ampProbe.captionToggle2.segWidth = 55; + CHECK(deckGroupWidth(ampProbe) == 312); + ampProbe.captionToggle2.segWidth = 56; + CHECK(deckGroupWidth(ampProbe) > 312); +} + +// Every group's width, in BOTH play modes, against the measured layout table +// (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the +// reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the +// contour row's 876 a constant rather than a Gate-only fact. +static void testEveryGroupWidthMatchesTheMeasuredLayout() { + const struct { int id; int width; } want[] = { + {kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432}, + {kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192}, + {kGroupVoice, 164}, {kGroupMaster, 142}, + }; + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + CHECK(g.size() == sizeof(want) / sizeof(want[0])); + const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); + for (const auto& w : want) { + const int i = indexOfGroup(g, w.id); + CHECK(i >= 0); + if (i < 0) continue; + CHECK(deckGroupWidth(g[static_cast(i)]) == w.width); + const DeckGroupLayout& lay = + dl.groups[static_cast(indexOfGroup(g, w.id))]; + CHECK(lay.box.width == w.width); + } + // Gate carries no reserves, so its cells are the deck's base size; Trigger's two + // reduced faces divide the same reserved run between fewer cells and get wider ones. + for (const DeckGroupLayout& lay : dl.groups) { + for (const DeckCellLayout& c : lay.cells) { + CHECK(c.cell.width >= kDeckCellW); + if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW); + } + } + } +} + +int main() { + testDeckFitsInsideTheEnforcedMinimumWindow(); + testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); + testBothRowsAndTheSpanningDeckFitTheBudget(); + testGutterArithmeticAndTheFilterTieLineAtTheFloor(); + testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor(); + testTheMasterDeckInteriorLandsOnBothRowBaselines(); + testTheMasterColumnDoesNotDivideItsRunVertically(); + testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow(); + testTheModeTogglesCostNoGroupWidth(); + testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo(); + testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp(); + testEveryGroupWidthMatchesTheMeasuredLayout(); + if (g_fail == 0) std::printf("deck_groups_measured: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} From 5c6525fb9190da8dc00667fbef9352a30681c661 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 12:41:57 -0400 Subject: [PATCH 43/56] Decouple the instrument reload from VST3 activation, and make the master meter's accumulate exact --- docs/COMPLETED.md | 18 +++ docs/TODO.md | 68 ++++------- docs/product/instrument-control-surface.md | 77 ++++++------- docs/product/parameter-automation.md | 19 ++-- src/core/instrument/engine/CMakeLists.txt | 6 + src/core/instrument/engine/meter_accumulate.h | 66 +++++++++++ src/core/instrument/ui/master_meter.h | 6 +- src/shell/instrument/CLAUDE.md | 27 +++-- src/shell/instrument/CMakeLists.txt | 2 +- src/shell/instrument/editor_paint_deck.cpp | 10 +- src/shell/instrument/editor_session.cpp | 27 +++-- src/shell/instrument/processor_reload.cpp | 104 ++++++++--------- src/shell/instrument/processor_state.cpp | 36 +++--- src/shell/instrument/reasampler_editor.h | 3 +- src/shell/instrument/reasampler_processor.cpp | 53 +++++---- src/shell/instrument/reasampler_processor.h | 73 +++++++----- tests/test_meter_accumulate.cpp | 106 ++++++++++++++++++ 17 files changed, 462 insertions(+), 239 deletions(-) create mode 100644 src/core/instrument/engine/meter_accumulate.h create mode 100644 tests/test_meter_accumulate.cpp diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index 4bd0834..72644ba 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -6,6 +6,24 @@ original Goal, Verify, and checklist points with boxes marked done. This file holds the current (1.x) cycle's landed milestones only. For all pre-1.0 (version-0) history, see `docs/ARCHIVE.md`. +### Decouple the instrument reload from VST3 activation (filed follow-up, discharged in Γ-W3) + +`ReaSamplerProcessor::setActive` meant two things at once — "the audio thread may run" and "the +decoded `SampleData` is (re)built" — so every host-driven activation cycle paid a bridge read +and a full WAV decode that nothing about activation required. The two lifetimes are now +separate: `setActive(false)` parks the decoded sample and destroys the voice state, +`setActive(true)` rebuilds the voices around the parked sample through the drain-slot swap +`rebuildVoiceEngine` already used for voice-count edits. A cycle costs no disk I/O and no +decode; sounding voices are still destroyed across it (a surviving `live_` would be displaced +into the drain slot and resurrect stale sustained voices as ghosts); an instance with nothing +decoded still routes through the full reload, which is where the pre-v10 legacy lift lives; and +`getLatencySamples()` still answers from the persisted enable, untouched by the cycle. The +build shared by the reload, the voice-param rebuild and the reactivation was factored to one +site so the three cannot drift on the generation stamp or the ring size. Daniel reversed the +deferral (*"I thought we agreed to decouple the unnecessary functions from the reactivation +path"*); Γ-F2 and Γ-F6 are untouched — dynamic latency ships, the deactivate/reactivate is +still the accepted cost of the toggle, just a much cheaper one. + ### Comment-reduction pass (tree-wide, twelve parallel tracks) Cut source comment volume tree-wide: 209 files changed, net **−6,493** lines. diff --git a/docs/TODO.md b/docs/TODO.md index 40cdf4f..922f0ce 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -240,57 +240,33 @@ alpha and this entry is re-filed against the new value. **Nothing here is actionable as a TODO.** Delete this entry when Γ-W1-T1 lands. -## Decouple the instrument reload from VST3 activation +## The editor's drag state machine has no seam, and `reasampler_editor.h` is near the ceiling -**Context (Daniel, 2026-08-01 — Phase Γ fork Γ-F6, ruled closed).** Γ-W1-T2 ships the plugin's -first latency reporting: `getLatencySamples()` returns 0 with the limiter off and the lookahead -with it on, and the toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. The -vendored SDK defines that flag as a host **deactivate/reactivate** -(`pluginterfaces/vst/ivsteditcontroller.h:105-108`). **Dynamic latency reporting is routine for -VST3 instruments and REAPER handles it as a matter of course** — the deactivate/reactivate is -the normal contract, and for a typical plugin `setActive` only allocates and frees buffers. -Γ-F6 was originally posed as "is this SDK cost acceptable?"; Daniel's answer relocated it: -*"you have to have missed something, I used plenty of VST3s inside of REAPER that report PDC -dynamically... Toggling the limiter killing the voices isn't a deal breaker though, the limiter -will either be on or off on its instance, toggling during playback is not a use case."* +**Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **563 lines** against the +~600-line ceiling — 37 lines of margin — and it keeps growing because every new surface on the +Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_` +plus the per-gesture anchors it is read against. -**The wart — and it is ours, not the SDK's.** `ReaSamplerProcessor::setActive(true)` calls -`reloadInstrument()` (`src/shell/instrument/reasampler_processor.cpp:89-97`) — a bridge read -plus a **full WAV re-decode** plus a fresh engine. `setActive(false)` frees `live_`, -`draining_` and the graveyard (`:98-107`). So every host-driven activation cycle — a -latency-change restart, an offline-render bracket, any host that deactivates around transport -state — pays a disk read and a decode that nothing about activation requires. **Activation -currently means two things at once**: "the audio thread may run" and "the decoded `SampleData` -is (re)built." Dynamic latency is simply the first feature that makes the cycle -user-triggerable. +**Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs** +(measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional), and +every input TU both writes it and branches on it. Extracting it is a real refactor of the +editor's input half, not a header move — and doing it inside a wave whose subject is the MASTER +deck would have put an unrelated high-blast-radius change in the same diff. Declining was right; +leaving it unrecorded was not. -**Intended fix.** Separate the two lifetimes: keep the decoded `SampleData` alive across a -deactivate and rebuild only the voice state on reactivate. The mechanism already exists in this -file — `rebuildVoiceEngine` performs exactly that shape (drain-slot swap around the -already-decoded `SampleData`, no bank re-read, no WAV re-decode) for voice-count and voice-mode -edits. This is a lifetime split, not a new mechanism. +**The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload, +with the input TUs mutating it through named transitions rather than assigning `drag_` and its +anchors independently — which is also what would let the invariant "an anchor is only readable +while its own `DragKind` is in flight" be enforced rather than observed. `editor_interaction.h` +already holds the `DragKind` vocabulary and is the natural home. -**The constraint the fix MUST handle.** The deactivate's destruction is deliberate and its -reason is documented at the call site: a surviving `live_` would be displaced into the drain -slot on reactivate and *"resurrect stale sustained voices as ghosts."* **Voice state must still -die across the cycle** — only the decoded PCM survives, and those are two different lifetimes -currently collapsed into one. Second constraint: `setActive(true)` is also the non-editor -legacy-lift trigger for a pre-v10 blob (its opportunistic `refreshRefsFromBank` copies refs in -once the bank blob is readable), so a path that skips the bridge read must keep that lift -reachable — the comment at `:90-96` records the residual load-order race it exists to cover. +**Priority / risk.** Low, but the margin is the clock: the next surface that adds two members to +the header takes it over the ceiling, and at that point the seam gets chosen under time pressure +by whoever is unlucky. Take it before that, not after. -**Priority / risk.** Low; deferred by ruling. Nothing is incorrect today, only wasteful, and -Daniel has explicitly accepted the user-visible consequence (held notes cut on a limiter -toggle). **Trigger conditions — revisit when any one of these holds:** (a) a second -latency-changing control appears, so the cycle stops being a once-per-patch event; (b) the -limiter enable is ever wanted automatable, which `docs/product/parameter-automation.md` §3.8 -currently forbids *because* of this cost; or (c) the re-decode is observed to be perceptible in -REAPER — Γ-W1-T2's review records that observation for exactly this purpose. - -**Done looks like.** A host-driven deactivate/reactivate cycle costs no disk I/O and no WAV -decode; sounding voices are still destroyed across it, with no ghost-resurrection regression; -a pre-v10 blob still lifts; and `getLatencySamples()` still derives from persisted state rather -than from a transient the deactivate cleared. +**Done looks like.** `reasampler_editor.h` is back under the ceiling with room; no TU assigns +`drag_` and an anchor as two independent writes; and the transitions are named where the +`DragKind` catalogue already lives. ## `Sample::sourceMode` has no value meaning "produced by the instrument" diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index a2fdb58..2a93607 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -50,9 +50,8 @@ own width formula, not carried over from a prior measurement. The stale geometry when off, the lookahead when on, reported to the host's PDC. This is **routine VST3 behaviour**; the `restartComponent(kLatencyChanged)` it costs is the normal contract, and the deactivate/reactivate the flag mandates is **accepted** — the toggle is a patch-design - gesture. The only reason the cycle is expensive at all is that **our** `setActive` re-decodes - the WAV, which is a latent improvement filed in `docs/TODO.md`, not a design constraint. - §3.1.1. + gesture. The cycle used to be expensive only because **our** `setActive` re-decoded the WAV; + Γ-W3 decoupled the two lifetimes, so it no longer does. §3.1.1. - **The cortex limiter does not clear the bar** — §3.5. Read it, take nothing. - **Loop gets an explicit enable on the chrome row** (Γ-F4), and the four-mark grammar sits under it. The core finding behind the re-approach: three identical bars draw a @@ -560,31 +559,28 @@ plugins — lookahead limiters, linear-phase EQs and oversampling processors all REAPER handles it as a matter of course. The deactivate/reactivate is the *normal* cost of the flag, and for a typical plugin it is cheap: `setActive` allocates and frees buffers. -**What makes it expensive here is entirely our own design, in one line.** -`ReaSamplerProcessor::setActive` is deliberately destructive in both directions -(`reasampler_processor.cpp:85-109`): +**What made it expensive here was entirely our own design, in one line** — and Γ-W3 removed +that line. `ReaSamplerProcessor::setActive` was deliberately destructive in both directions: -- `setActive(true)` calls `reloadInstrument()` (`:89-97`) — **a bridge read and a full WAV - re-decode**, plus a fresh engine. This is the expensive half, and no part of it is required - by the SDK: it is there because activation was the convenient trigger for a reload, not - because activation implies one. -- `setActive(false)` frees `live_`, `draining_` **and** the graveyard (`:98-107`), so every - sounding voice dies. The comment there explains why that is correct and must not be - softened casually: a surviving `live_` would be displaced into the drain slot on reactivate - and *"resurrect stale sustained voices as ghosts."* +- `setActive(true)` called `reloadInstrument()` — **a bridge read and a full WAV re-decode**, + plus a fresh engine. That was the expensive half, and no part of it was required by the SDK: + it was there because activation was the convenient trigger for a reload, not because + activation implies one. +- `setActive(false)` frees `live_`, `draining_` **and** the graveyard, so every sounding voice + dies. That half is correct and must not be softened casually: a surviving `live_` would be + displaced into the drain slot on reactivate and *"resurrect stale sustained voices as + ghosts."* -**So the cost is ours, and it is ours to reduce.** The reduction is **decoupling the reload -from activation** — keeping the decoded `SampleData` alive across a deactivate while still -destroying voice state, which is exactly the shape `rebuildVoiceEngine`'s drain-slot swap -already implements for voice-count edits. **That is a latent improvement with a clear trigger -condition, filed in `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") — -not a reason to abandon dynamic latency, and not scheduled in this phase.** +**The cost was ours, and it has been reduced (Γ-W3 — see §7.11).** The deactivate now parks the +decoded `SampleData` and the reactivate rebuilds only the voice state around it, through the +same drain-slot swap `rebuildVoiceEngine` uses for voice-count edits. An activation cycle costs +no disk read and no decode; an instance with nothing decoded still takes the full reload, which +is where the pre-v10 legacy lift lives. -**The honest cost of the toggle today, stated plainly:** every sounding note stops and the -sample is re-decoded from disk. **Daniel has accepted it** (Γ-F6): *"Toggling the limiter -killing the voices isn't a deal breaker though, the limiter will either be on or off on its -instance, toggling during playback is not a use case."* There is no fallback design and no -measurement gate. +**The honest cost of the toggle, stated plainly:** every sounding note stops. **Daniel has +accepted it** (Γ-F6): *"Toggling the limiter killing the voices isn't a deal breaker though, +the limiter will either be on or off on its instance, toggling during playback is not a use +case."* There is no fallback design and no measurement gate. #### The standing scar, and why this is nonetheless not the forbidden change @@ -660,10 +656,9 @@ What is in scope alongside it — and what each is actually for: in the **not-automatable** class, and it is emphatically not the plugin's `kIsBypass` parameter either. - **Observe what REAPER does, and record it — as evidence, not as a gate.** Whether notes - cut, whether the re-decode is perceptible, whether transport hiccups, is DAW-observable - only. Record it in Γ-W1-T2's review because it is the trigger-condition evidence for the - `docs/TODO.md` decoupling entry. **No outcome changes the design**; Γ-F6 is closed either - way. + cut and whether transport hiccups is DAW-observable only. The re-decode half of that + question is gone (§7.11), so what remains to observe is the voice cut alone. **No outcome + changes the design**; Γ-F6 is closed either way. ### 3.2 The meter @@ -1401,13 +1396,18 @@ squarely on `ReaSamplerProcessor::setActive`, which is deliberately destructive directions. **Those four are hygiene against the `kIoChanged` scar (§3.1.1), not a hedge against the flag itself** — Γ-F6 is ruled and the restart ships. -**7.11 — `setActive` conflates two lifetimes, and dynamic latency is the first feature that -makes a user notice.** Activation currently means both "the audio thread may run" and "the -decoded `SampleData` is (re)built" (`reasampler_processor.cpp:89-97`). Phase Γ does **not** -separate them — Γ-F6 accepts the cost — but the conflation is now a named, filed improvement -(`docs/TODO.md`, "Decouple the instrument reload from VST3 activation") rather than an -unremarked property. **Do not restructure `setActive` inside this phase**; its destructive -shape is deliberate and its reasoning is documented at the call site. +**7.11 — `setActive` conflated two lifetimes; it no longer does (LANDED, Γ-W3).** Activation +used to mean both "the audio thread may run" and "the decoded `SampleData` is (re)built", so +every host-driven cycle paid a bridge read and a full WAV decode. The two are now separate: +`setActive(false)` parks the decoded sample and destroys the voice state, `setActive(true)` +rebuilds the voices around the parked sample through the drain-slot swap `rebuildVoiceEngine` +already used. **This section's earlier instruction — "do not restructure `setActive` inside +this phase" — was superseded by Daniel's ruling that this track does it**; the deactivate's +destruction of voice state is still deliberate (a surviving `live_` would resurrect stale +sustained voices as ghosts) and only the PCM survives. Nothing parked routes the activation +back through the full reload, which is what keeps the pre-v10 legacy lift reachable. Γ-F6 is +untouched: dynamic latency ships and the deactivate/reactivate is still the accepted cost — +it is simply a much cheaper one. --- @@ -1427,7 +1427,7 @@ ceiling. | **Γ-F3** | Does the log taper raise the 2 s stage-time ceiling? | **REVERSED, same day. Ruled first "not in this phase — stays 2.0 s"; then Daniel: _"extend the stage lengths to 10s."_ The ceiling moves 2.0 → 10.0 in Γ-W1-T1.** The reversal's cause is Ruling 1: parameters now ship in-phase, so the ceiling is a one-way door that has to be walked through *before* them. | **§4.3.1** (new), §4.3; `docs/TODO.md` entry discharged | | **Γ-F4** | Explicit loop enable? | **Yes — on the CHROME ROW.** Not a deck cell; loop is a waveform-overlay concept and has no deck. | **§6.4** (new), §6.5, §7.9 | | **Γ-F5** | MASTER's reserved slot: one cell or two? | **One cell.** Two would spend 60 of the 82 px headroom on an unnamed control and freeze row 1 forever. | **§1.6** (new), §1.4 | -| **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11, `docs/TODO.md` | +| **Γ-F6** | Is the `kLatencyChanged` deactivate/reactivate acceptable as the cost of the toggle? | **Yes — ship dynamic latency as ruled.** No constant-latency fallback, no measurement gate. *Corrected this doc's analysis: the cost is self-inflicted, not SDK-imposed.* | **§3.1.1** (rewritten), §7.10, §7.11; `docs/TODO.md` decoupling entry discharged in Γ-W3 | | **Γ-F7** | VST3 parameter ORDER: signal flow, or the editor's visual rows? | **Signal flow** — *"signal flow order."* The frozen id numbering and the presentation index both follow the deck's own rule; the visual layout is too mobile to freeze against. | **§8.3**; `parameter-automation.md` §6.4 (argument) and §6.2 (the 44-id table) | Three of these corrected this doc rather than confirming it, and all three corrections are @@ -1473,7 +1473,8 @@ reintroduced: than just counting: 1. **§3.1.1 was rewritten, not annotated.** Its prior framing — dynamic latency as exotic and - expensive — was wrong. Dynamic PDC is routine; the expense is our reload-on-activate. + expensive — was wrong. Dynamic PDC is routine; the expense was our reload-on-activate, and + Γ-W3 removed it (§7.11). 2. **The measurement gate was dropped.** Γ-W1-T2's first deliverable is the limiter, not a spike. What remains is an *observation* recorded in review as evidence for the deferred improvement — it gates nothing. diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index 2f056b2..0fed5e7 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -202,8 +202,8 @@ The consequence for this doc is concrete and it is a **subtraction from the para > latency, and the vendored SDK defines `restartComponent(kLatencyChanged)` as *"the host > has to deactivate and reactivate the plug-in"* > (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). In this plugin a deactivate frees -> every sounding voice and a reactivate re-decodes the WAV. **An automation lane toggling -> that parameter would deactivate the plugin on every flip.** +> every sounding voice. **An automation lane toggling that parameter would deactivate the +> plugin on every flip.** Two corollaries the parameter work must carry rather than rediscover: @@ -211,7 +211,7 @@ Two corollaries the parameter work must carry rather than rediscover: binding it to `kIsBypass` would hand the host a control that restarts the component. - **Latency reporting must be derived from persisted state, not from a transient.** The SDK states the new latency is what `getLatencySamples` returns *after* `setActive(true)` — and - this plugin's `setActive(false)` frees essentially everything. Whatever holds the limiter + this plugin's `setActive(false)` destroys the whole voice state. Whatever holds the limiter flag must survive that cycle. Full reasoning, the SDK quotes, and the required verification steps are in @@ -221,12 +221,13 @@ There is no constant-reported-latency fallback — that option is closed, not sh **this section does not shrink to a footnote and the limiter enable does not become automatable.** Plan against the not-automatable classification; it is settled. -**One future condition could reopen it, and it is worth knowing about.** The restart is only -expensive because *this plugin's* `setActive(true)` re-decodes the WAV — not because the SDK -requires it. `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") files that -reduction, and **"the limiter enable is wanted automatable" is one of its named trigger -conditions.** If the parameter work genuinely needs that lane, the answer is to do the -decoupling first, not to re-litigate the classification. +**The decoupling that was filed against this section has LANDED (Γ-W3), and it changes the +cost but not the classification.** `setActive(true)` no longer re-decodes the WAV: the decoded +sample now survives a deactivate and only the voice state is rebuilt +(`instrument-control-surface.md` §7.11). So a flip costs a voice rebuild rather than a disk +read plus a decode — but **the deactivate still frees every sounding voice**, which is the +ground the not-automatable classification actually rests on. Plan against not-automatable; if +the parameter work wants that lane, the question to answer is the voice cut, not the decode. --- diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 4954cab..9c917b0 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -87,3 +87,9 @@ reasampler_test(limiter LINK limiter) reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp) reasampler_test(meter_ballistics LINK meter_ballistics) + +# The meter's ACCUMULATE half, beside the ballistics that consume it. Header-only (the folds +# sit on the audio thread's per-block path), hence INTERFACE. +add_library(meter_accumulate INTERFACE) +target_include_directories(meter_accumulate INTERFACE ${REASAMPLER_SRC_DIR}) +reasampler_test(meter_accumulate LINK meter_accumulate) diff --git a/src/core/instrument/engine/meter_accumulate.h b/src/core/instrument/engine/meter_accumulate.h new file mode 100644 index 0000000..27de0b0 --- /dev/null +++ b/src/core/instrument/engine/meter_accumulate.h @@ -0,0 +1,66 @@ +// meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold +// into the two windows the UI drains, and the drain that starts the next window. The ballistics +// that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio +// thread's per-block path. Templated on the accumulator ONLY so the drain-inside-the-fold +// interleave below can be pinned deterministically instead of raced for. + +#pragma once + +#include + +namespace reasampler::instrument::engine { + +// A lock-backed std::atomic would put a mutex on the audio thread; assert the freedom +// rather than assume it. +static_assert(std::atomic::is_always_lock_free, + "the meter folds run on the audio thread and must be lock-free"); + +// The two windows' identity elements: a peak window that has seen nothing reports silence, a +// gain window that has seen nothing reports no reduction. They are what a consume reinstalls, +// so they live beside the folds rather than at the reader. +inline constexpr float kMeterPeakIdentity = 0.f; +inline constexpr float kMeterGainIdentity = 1.f; + +// Folds one block's reading into its accumulator — a running max for a peak, a running min for +// the limiter's gain — so the ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512 +// all reach the meter instead of the one it happened to sample. +// +// An UNCONDITIONAL read-modify-write, and that is the whole point. The UI's consume is an +// exchange that can land between a plain load and its store, and a load-compare-store fold +// would then drop the block outright: it decided against storing by comparing with a window the +// UI has since taken, so that block's reading enters neither the old window nor the new one. +// The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on +// exit however the two interleave. Bounded — the audio thread is this accumulator's only other +// writer, so one interfering consume costs one retry. Relaxed throughout: the accumulators are +// advisory and order no other state. Block rate, never per frame. +template +inline void foldPeak(Accumulator& acc, float blockPeak) { + float seen = acc.load(std::memory_order_relaxed); + while (!acc.compare_exchange_weak(seen, seen > blockPeak ? seen : blockPeak, + std::memory_order_relaxed, + std::memory_order_relaxed)) { + } +} + +template +inline void foldMinGain(Accumulator& acc, float blockMinGain) { + float seen = acc.load(std::memory_order_relaxed); + while (!acc.compare_exchange_weak(seen, seen < blockMinGain ? seen : blockMinGain, + std::memory_order_relaxed, + std::memory_order_relaxed)) { + } +} + +// Takes what the window accumulated and reinstalls the identity element, which IS what starts +// the next window — so exactly one reader may consume (the shell's MasterBusMeter states who). +template +inline float consumePeak(Accumulator& acc) { + return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed); +} + +template +inline float consumeMinGain(Accumulator& acc) { + return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed); +} + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/ui/master_meter.h b/src/core/instrument/ui/master_meter.h index 5b1378e..a746af4 100644 --- a/src/core/instrument/ui/master_meter.h +++ b/src/core/instrument/ui/master_meter.h @@ -58,7 +58,7 @@ struct MasterMeterUi { double reductionDb = 0.0; // how far the limiter is pulling gain down; 0 = not working // The lamp's hold, on the SAME principle (and the same window) as the peak tick's: without // it a catch smaller than kMeterFallDbPerSecond x the UI period is fully decayed by the - // next frame and the lamp never draws lit at all. + // next frame, so the lamp is dark again after the single repaint the catch landed on. double reductionHoldSeconds = 0.0; }; @@ -91,8 +91,8 @@ bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b); // The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs // a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the -// spec asks only for "a small floor". Lowering it makes the lamp flicker on limiting too slight -// to hear; raising it hides genuine catches, since the limiter's ceiling is only −0.3 dBTP. +// spec asks only for "a small floor". Raising it hides genuine catches, since the limiter's +// ceiling is only −0.3 dBTP. inline constexpr double kGrLampFloorDb = 0.5; bool grLampLit(const MasterMeterUi& m); diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index bdef065..a828667 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -107,7 +107,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h ## Modules - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. -- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. Nothing parked means nothing was decoded, which routes the activation back through the full reload; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. @@ -127,15 +127,16 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h one. - **The limiter toggle splits its commit: the sound is inline, the HOST NOTIFICATION arms.** Its commit needs the host's `restartComponent(kLatencyChanged)`; a host that services that - synchronously runs `setActive(false)`/`setActive(true)`, and OUR `setActive(true)` calls - `reloadInstrument()` — a WAV re-decode plus disk I/O, which inline from `WM_LBUTTONDOWN` - would run nested in a mouse handler. So the click commits the parameter set, the audio-thread - mirror and the latency reader at once, and `setInstrumentParams` only ARMS a pending restart - that `flushLatencyRestart` delivers. The editor's sync tick is the general drain and sits - AFTER the drag guard with the bake (the restart rebuilds the instance, which mid-drag would - yank the edit surface exactly as a reload would); `setState` and the bake's adopt flush at - their own tails, because they can commit with no editor open. The arm is a sticky bool, so - toggling twice inside one tick still costs exactly one restart. + synchronously runs `setActive(false)`/`setActive(true)`, which rebuilds this instance's voice + state — running that inline from `WM_LBUTTONDOWN` would nest it in a mouse handler. So the + click commits the parameter set, the audio-thread mirror and the latency reader at once, and + `setInstrumentParams` only ARMS a pending restart that `flushLatencyRestart` delivers. The + editor's sync tick is the general drain and sits AFTER the drag guard with the bake (the + restart rebuilds the instance, which mid-drag would yank the edit surface exactly as a reload + would); `setState` flushes at its own tail because it can commit with no editor open, and the + bake's adopt does so only to save a tick — its chain runs from that same tick. The arm is + judged against the LAST ANNOUNCED enable, so toggling back to it inside one tick costs no + restart at all. **The residual:** between the commit and the flush the host's delay compensation is out of step with the plugin by `limiterLookaheadSamples` (2 ms — `round(0.002 · rate)`, the detector's 4-sample group delay INSIDE that budget, not on top), bounded by one 500 ms tick. @@ -151,7 +152,11 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h store displayed one block in ~47 and lost the rest — the specified "a peak displays on the first UI frame after it occurs" is what the fold restores. `masterBusMeter()` is CONSUMING, so exactly one caller may hold it; the embed strip reads its own non-consuming - `embedActivityLevel()`. A meter-rate timer remains a separate change and is not in. + `embedActivityLevel()`. **The tick's FIRST read is discarded**, because that caller is the + only consumer: with no editor open the accumulators hold everything since the instance was + created, and advancing off them would open the meter at the session's loudest peak. The clip + latch is not discarded with them — it is a latch the user clears. A meter-rate timer remains a + separate change and is not in. - The bake's availability probe runs on the SAME tick that paints the button, so the control can never be enabled on one tick and refuse on the next. The bake Hold control's applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index cb025e4..a59836b 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -89,7 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") waveform_view loop_marks bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage - limiter meter_ballistics master_meter bake_hold + limiter meter_accumulate meter_ballistics master_meter bake_hold file_bytes curve_law stroke_aa curve_tessellate bake_plan bake_render bake_reset bake_wire wav_codec) diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index c849c4c..6878753 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -37,11 +37,14 @@ std::string tickLabel(int db) { } // The MASTER column: dB scale in the label gutter, one or two bars, the held peak tick, and -// the latched clip cap. `split` is waveformSurface's own lane decision — see master_meter.h. +// the latched clip cap. `split` is the RESOLVED lane decision — see master_meter.h. void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi& state, LaneSplit split) { if (column.width <= 0 || column.height <= 0) return; const MeterRects m = meterRects(column, split); + // A column narrower than the interior needs yields all-empty rects, which under rect.h's + // contract means suppressed — not a zero-height field to fill, tick twelve times and cap. + if (m.field.empty()) return; fillSurface(bmp, toKitBox(m.field), Role::BgCell, InteractionState::Rest); // Scale: a rule every 6 dB, numeralled every 12 with 0 dB heavier — the reference the @@ -60,8 +63,9 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi } } - // The bars. A single-lane surface shows ONE bar off the louder channel: the two are the - // same signal there (dual-mono), so two bars would be a duplicate rather than a reading. + // The bars. A single-lane surface shows ONE bar folding both channels per field + // (meterSingleLaneState) — the two are the same signal there (dual-mono), so two bars would + // be a duplicate rather than a reading. const LICE_pixel barInk = toLice(roleColor(Role::AccentPrimary)); const LICE_pixel holdInk = toLice(roleColor(Role::TextPrimary)); const auto drawBar = [&](const Rect& bar, const instrument::engine::MeterState& ch) { diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 5b543fa..bca7a84 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -112,18 +112,25 @@ void ReaSamplerEditor::onSyncTimer() { // keeps sounding and a frozen bar would misreport it. { const unsigned long long now = GetTickCount64(); - const double elapsed = meterTickMs_ == 0 - ? 0.0 - : static_cast(now - meterTickMs_) / 1000.0; + const unsigned long long previous = meterTickMs_; meterTickMs_ = now; const MasterBusMeter bus = processor_->masterBusMeter(); - const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter( - masterMeter_, - {bus.peakL, bus.peakR, bus.minGain, bus.clip}, - elapsed); - const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_); - masterMeter_ = advanced; - if (changed) invalidate(); + // The accumulators have exactly one consumer — this tick — so with no editor open they + // hold everything since the instance was created. The first read is therefore session + // history, not a window: showing it would put the bar at the loudest peak of the + // session (instantaneous rise, then a 1.5 s hold) and light the GR lamp off a catch + // minutes old, with the limiter possibly off since. Discard it and start the window + // here. The CLIP survives, because it is a latch the user clears rather than a window — + // it is still set in the processor and the next tick reports it. + if (previous != 0) { + const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter( + masterMeter_, + {bus.peakL, bus.peakR, bus.minGain, bus.clip}, + static_cast(now - previous) / 1000.0); + const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_); + masterMeter_ = advanced; + if (changed) invalidate(); + } } if (drag_ != DragKind::kNone) return; // defer past the in-flight edit diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index d032f0d..8bd0f20 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -96,10 +96,6 @@ std::string ReaSamplerProcessor::reloadInstrument() { // retired-slot free is single-writer; never taken on the audio thread. std::lock_guard lock(reloadMutex_); - // Mint this reload's generation number first so the built instrument is stamped - // before publishing. - const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - // 1. Self-contained resolution: the instance-owned refs table is the source of truth. // The live bank blob, when readable, is folded in first (refreshRefsFromBank — the // browser's copy-the-ref-in + recapture-sync mechanism), but its absence changes @@ -124,17 +120,6 @@ std::string ReaSamplerProcessor::reloadInstrument() { // Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the // capture's own channel count below, before the decode. ChannelMode mode = channelMode(); - // Snapshot the voice-system parameters once — baked into the built engine's - // construction (immutable config; a later change rebuilds). - int builtVoiceCount = kDefaultVoiceCount; - VoiceMode builtVoiceMode = VoiceMode::Poly; - MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; - { - std::lock_guard vp(voiceParamsMutex_); - builtVoiceCount = voiceCount_; - builtVoiceMode = voiceMode_; - builtMonoTrigger = monoTrigger_; - } std::string resolvedId; std::unique_ptr built; @@ -177,17 +162,7 @@ std::string ReaSamplerProcessor::reloadInstrument() { } } - if (havePlayable) { - // Preserve OLA window in output frames from the host rate (kPreserveWindowMs), - // pre-sized here so process()-time note-on never allocates. Floored at 2 so a - // valid window is always a real ring, covering a pathological host rate <= 0 too. - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - built = std::make_unique( - std::move(sample), static_cast(builtVoiceCount), gen, - kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); - } + if (havePlayable) built = buildInstrumentLocked(std::move(sample)); // 3. Publish: atomically install the new instrument via the drain-slot swap (see the // header). A null `built` (no ref / unreadable WAV) installs silence while any @@ -246,9 +221,10 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry, // ONE reload for the re-point and the reset together: it decodes the new file and // publishes the neutral parameters in the same swap. reloadInstrument(); - // The bake's reset may have flipped the limiter; deliver the host's latency restart here - // rather than leaving it to the editor's tick, so an adopt is correct with no editor open. - // At the tail for the same reason setState's is (see there). + // The bake's reset may have flipped the limiter; delivering the restart here rather than + // leaving it to the editor's tick is a LATENCY improvement, not a correctness one — the + // bake chain only ever runs from that tick, so the arm would be drained on the next one + // anyway. At the tail for the same reason setState's is (see there). flushLatencyRestart(); } @@ -301,6 +277,10 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr b return e->installedAt < seen; }), graveyard_.end()); + // Any publish supersedes the deactivate's park: whatever is installed here is the newer + // truth, and a park surviving it would be reinstalled over this instrument at the next + // activation (the setState-while-inactive case). + dormantSample_.reset(); LoadedInstrument* prev = live_.exchange(built.release()); // A bake's reset gain lands here rather than at its call site, so the gain and the // capture it belongs to become audible to process() within one block of each other. @@ -317,6 +297,34 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr b if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); } +std::unique_ptr +ReaSamplerProcessor::buildInstrumentLocked(SampleData sample) { + // REQUIRES reloadMutex_ held. The ONE construction of a playable snapshot, so the three + // callers (full reload, voice-param rebuild, reactivation) cannot drift on the generation + // stamp, the voice-system snapshot or the ring size. + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + // Baked into the engine's construction (immutable config; a later change rebuilds). + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } + // Preserve OLA window in output frames from the host rate (kPreserveWindowMs), pre-sized + // here so process()-time note-on never allocates. Floored at 2 so a valid window is always + // a real ring, covering a pathological host rate <= 0 too. Re-derived per build, so a + // reactivation after the host changed its rate gets a ring sized for the new one. + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + return std::make_unique( + std::move(sample), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); +} + void ReaSamplerProcessor::rebuildVoiceEngine() { // Off the audio thread. A voice-param change touches no audio data, so this rebuilds // the engine around a copy of the live instrument's already-decoded SampleData — no @@ -324,31 +332,25 @@ void ReaSamplerProcessor::rebuildVoiceEngine() { std::lock_guard lock(reloadMutex_); LoadedInstrument* cur = live_.load(std::memory_order_acquire); if (!cur) return; // nothing loaded: the new params bake into the next real reload. - - int builtVoiceCount = kDefaultVoiceCount; - VoiceMode builtVoiceMode = VoiceMode::Poly; - MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; - { - std::lock_guard vp(voiceParamsMutex_); - builtVoiceCount = voiceCount_; - builtVoiceMode = voiceMode_; - builtMonoTrigger = monoTrigger_; - } - - const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - // Same Preserve-window derivation as reloadInstrument. - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - // Deep-copy the decoded sample: safe to read concurrently with process() because the // SampleData is immutable after construction and reloadMutex_ prevents `cur` from being // freed. - SampleData sample = cur->sample; - auto built = std::make_unique( - std::move(sample), static_cast(builtVoiceCount), gen, - kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); - publishBuiltLocked(std::move(built)); + publishBuiltLocked(buildInstrumentLocked(cur->sample)); +} + +bool ReaSamplerProcessor::resumeDormantInstrument() { + // Off the audio thread (setActive only). The reactivation half of the lifetime split: the + // voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is. + std::lock_guard lock(reloadMutex_); + // A publish that landed while inactive (setState's reload, a bake's adopt) IS the + // activation state — its voices have never rendered, and it has already superseded the + // park. Rebuilding here would displace a correct instrument into the drain slot. + if (live_.load(std::memory_order_acquire)) return true; + if (!dormantSample_) return false; + // MOVED, not copied: the park exists for this one handoff, and keeping it would hold a + // second copy of the PCM for the whole active lifetime. + publishBuiltLocked(buildInstrumentLocked(std::move(*dormantSample_))); + return true; } void ReaSamplerProcessor::retireIdleDrain() { diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 784a258..ac7056c 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -153,10 +153,8 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() { } void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { - bool limiterFlagChanged = false; { std::lock_guard lock(paramsMutex_); - limiterFlagChanged = (params_.limiterEnabled != params.limiterEnabled); params_ = params; } // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — @@ -167,11 +165,14 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { // safe there (see this directory's CLAUDE.md). publishLimiterEnabled(params.limiterEnabled); // Armed AFTER the mirror, so getLatencySamples already answers the new value for the whole - // window the arm stays outstanding. Sticky and idempotent: any number of changes before one - // flush cost one restart, and the flush is the only thing that clears it. - if (limiterFlagChanged) { - latencyRestartPending_.store(true, std::memory_order_release); - } + // window the arm stays outstanding. Compared against the last ANNOUNCED enable rather than + // against the previous parameter set: off->on->off inside one tick ends at the latency the + // host already knows, and a restart rebuilds the instance, so announcing a latency that + // never changed is pure cost. Any number of changes before one flush still cost at most one + // restart, and this store is the only one that raises OR lowers the arm. + latencyRestartPending_.store( + params.limiterEnabled != latencyAnnounced_.load(std::memory_order_relaxed), + std::memory_order_release); } void ReaSamplerProcessor::flushLatencyRestart() { @@ -179,6 +180,11 @@ void ReaSamplerProcessor::flushLatencyRestart() { // its handler waits for a later flush instead of evaporating. if (!componentHandler) return; if (!latencyRestartPending_.exchange(false, std::memory_order_acquire)) return; + // Latched BEFORE the call: a host that services the restart synchronously re-enters this + // object inside it, so the next commit must compare against the value the host is about to + // read, not against the one it held before. + latencyAnnounced_.store(limiterEnabled_.load(std::memory_order_relaxed), + std::memory_order_relaxed); // The SDK requires this on the UI thread and answers getLatencySamples only after the host's // own deactivate/reactivate — so the flag is long committed by the time the host asks. This // is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode kIoChanged @@ -202,14 +208,14 @@ void ReaSamplerProcessor::setLimiterEnabled(bool on) { MasterBusMeter ReaSamplerProcessor::masterBusMeter() { MasterBusMeter m; - // Exchange, not load: the accumulators hold the window since this was last called, and - // clearing them here is what starts the next window. The audio thread's own fold is a - // load-max-store, so a store landing between this exchange and that store can retain one - // window's peak for one extra frame — it can never LOSE one, which is the property that - // matters for a peak meter. - m.peakL = meterPeakL_.exchange(0.f, std::memory_order_relaxed); - m.peakR = meterPeakR_.exchange(0.f, std::memory_order_relaxed); - m.minGain = meterMinGain_.exchange(1.f, std::memory_order_relaxed); + // Consuming: each read takes the window and reinstalls its identity element, which is what + // starts the next one. The audio thread's fold is an unconditional CAS against exactly that + // (meter_accumulate.h owns the argument), so a fold interleaved with these exchanges lands + // in one window or the other and is never dropped between them. + m.peakL = instrument::engine::consumePeak(meterPeakL_); + m.peakR = instrument::engine::consumePeak(meterPeakR_); + m.minGain = instrument::engine::consumeMinGain(meterMinGain_); + // NOT consumed: the clip is a latch the user clears, not a window. m.clip = meterClip_.load(std::memory_order_relaxed); return m; } diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index f67fc69..f27d642 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -457,7 +457,8 @@ private: bool searchFocused_ = false; // whether the search box has keyboard focus // The MASTER deck's meter, advanced from the published block magnitudes on the sync tick - // (see onSyncTimer for why it runs mid-drag too). meterTickMs_ 0 = never advanced. + // (see onSyncTimer for why it runs mid-drag too, and why the first read is discarded). + // meterTickMs_ 0 = never ticked. instrument::ui::MasterMeterUi masterMeter_; unsigned long long meterTickMs_ = 0; diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index 2d4721b..d95f193 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -79,34 +79,45 @@ tresult PLUGIN_API ReaSamplerProcessor::terminate() { delete live_.exchange(nullptr); delete draining_.exchange(nullptr); graveyard_.clear(); + dormantSample_.reset(); return SingleComponentEffect::terminate(); } tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { - // Activating: build from the currently-selected sample so the first block after - // activation can play. Deactivating: process is now guaranteed stopped, so this is - // the safe point to reclaim the graveyard. Main/UI-thread call. + // Activation governs ONE thing — whether the audio thread may run. The decoded + // SampleData has its own lifetime and survives the cycle (dormantSample_); only the + // voice state is built and destroyed here. Main/UI-thread call. if (state) { - // Resolves + decodes from the instance-owned refs — no bank read needed, so it - // plays regardless of PROJEXTSTATE parse state. Also doubles as the non-editor - // legacy-lift trigger for a pre-v10 blob: reloadInstrument's opportunistic - // refreshRefsFromBank copies refs in when the bank blob is readable by now. + // Rebuild the voices around the sample the deactivate parked — no bridge read, no WAV + // decode — so a host-driven cycle (a kLatencyChanged restart, an offline-render + // bracket) costs no I/O. With nothing to activate from, the full reload runs: it + // resolves + decodes from the instance-owned refs (no bank read, so it plays regardless + // of PROJEXTSTATE parse state) and doubles as the non-editor legacy-lift trigger for a + // pre-v10 blob, whose opportunistic refreshRefsFromBank copies refs in when the bank + // blob is readable by now. Nothing can shadow that lift: a pre-v10 blob resolves + // nothing, so it has neither a parked sample nor a published instrument. // Residual load-order race (DAW-verifiable only): if the host activates before the // project's ext-state parses, nothing retries until the next activation or editor // tick — open a pre-v10 instrument once after upgrading if it restores silent. - reloadInstrument(); + if (!resumeDormantInstrument()) reloadInstrument(); // The host performs this deactivate/reactivate whenever it acts on a kLatencyChanged // request, so the limiter starts each activation with an empty delay line and snapped - // to its persisted state — no transition mute, because there is nothing sounding to be - // continuous with once the block above has destroyed every voice. + // to its persisted state — no transition mute, because the deactivate destroyed every + // voice and the rebuild above starts with none sounding. limiter_.reset(); } else { std::lock_guard lock(reloadMutex_); - // Free EVERYTHING, including live_: its voices are frozen mid-flight, and if it - // survived deactivation the reactivate reload would displace it into the drain - // slot, resurrecting stale sustained voices as ghosts. Reactivation rebuilds from - // scratch above, so nothing is lost. - delete live_.exchange(nullptr); + // Park the decoded PCM; free EVERYTHING else, live_ included. Its voices are frozen + // mid-flight, and if it survived deactivation the reactivate would displace it into + // the drain slot, resurrecting stale sustained voices as ghosts. + std::unique_ptr dying(live_.exchange(nullptr)); + // Moved out ahead of the destruction: `sample` is declared before `engine`, so the + // engine — the only holder of a reference to it — dies first and never reads the + // moved-from value. Empty when nothing was loaded, which is what routes the next + // activation back through the full reload. + dormantSample_ = dying ? std::optional(std::move(dying->sample)) + : std::nullopt; + dying.reset(); delete draining_.exchange(nullptr); graveyard_.clear(); } @@ -309,7 +320,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } // The chain's last stage before the bus, after the gain above. const float minGain = limiter_.process(ch0, ch1, frames); - foldMinGain(meterMinGain_, minGain); + instrument::engine::foldMinGain(meterMinGain_, minGain); // Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). for (int32 ch = 2; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { @@ -324,8 +335,8 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { if (a0 > peakL) peakL = a0; if (a1 > peakR) peakR = a1; } - foldPeak(meterPeakL_, peakL); - foldPeak(meterPeakR_, peakR); + instrument::engine::foldPeak(meterPeakL_, peakL); + instrument::engine::foldPeak(meterPeakR_, peakR); advisoryPeak_.store(peakL > peakR ? peakL : peakR, std::memory_order_relaxed); if (peakL >= 1.f || peakR >= 1.f) meterClip_.store(true, std::memory_order_relaxed); } else if (ch0) { @@ -355,14 +366,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } const float minGain = limiter_.process(ch0, nullptr, frames); - foldMinGain(meterMinGain_, minGain); + instrument::engine::foldMinGain(meterMinGain_, minGain); float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; if (a > peak) peak = a; } - foldPeak(meterPeakL_, peak); - foldPeak(meterPeakR_, peak); + instrument::engine::foldPeak(meterPeakL_, peak); + instrument::engine::foldPeak(meterPeakR_, peak); advisoryPeak_.store(peak, std::memory_order_relaxed); if (peak >= 1.f) meterClip_.store(true, std::memory_order_relaxed); for (int32 ch = 1; ch < out.numChannels; ++ch) { diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index d6f93fd..e5f9dd0 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -22,6 +22,7 @@ #include "core/instrument/map/component_state_io.h" // ComponentState codec #include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter #include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block) +#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume #include "core/instrument/engine/voice_engine.h" namespace reasampler::vst { @@ -137,7 +138,10 @@ public: // What the audio thread published since the LAST call: peaks maxed and minGain minimised // across every block in that window. CONSUMING — it resets the accumulators as it reads, so - // exactly one reader may call it, and that reader is the editor's meter tick. UI thread. + // exactly one reader may call it, and that reader is the editor's meter tick. Two live + // editors would each consume half the windows and both meters would read low; what makes + // that unreachable is the HOST calling createView once per instance, not anything this + // plugin enforces — createView allocates a new editor on every call. UI thread. MasterBusMeter masterBusMeter(); void clearMasterBusClip(); @@ -248,9 +252,10 @@ public: // Delivers the armed kLatencyChanged restart, at most once per armed window, and does // nothing when none is armed. Split off the commit because the SDK requires this on the UI // thread AND a host may service it synchronously — deactivate/reactivate, which reaches our - // setActive(true) and its reloadInstrument — so it must never run nested inside a mouse - // handler. The editor's sync tick is the general drain; the two callers that can commit with - // no editor open (setState, adoptBakedCapture) flush themselves at their own tails. + // setActive(true) — so it must never run nested inside a mouse handler. The editor's sync + // tick is the general drain; setState flushes at its own tail because it can commit with no + // editor open, and adoptBakedCapture does so only to save a tick (its chain runs from that + // same tick, so its arm would drain on the next one regardless). void flushLatencyRestart(); // Fires a one-shot preview note-on/off through the live VoiceEngine — the same @@ -283,6 +288,18 @@ private: // swap as a full reload. No-op when nothing is loaded. Off the audio thread only. void rebuildVoiceEngine(); + // The reactivation half of the activation/decode lifetime split (see dormantSample_): + // rebuilds the voice state around the parked sample and publishes it through the same + // drain-slot swap. True also when a publish landed while inactive, which needs no rebuild. + // False when there is nothing to activate from — the caller then falls back to a full + // reload, which is where the pre-v10 legacy lift lives. Off the audio thread only. + bool resumeDormantInstrument(); + + // Builds a playable snapshot around `sample` at the current voice-system parameters and + // host rate, stamped with a fresh generation. Requires reloadMutex_ held; the ONE + // construction site shared by the reload, the voice-param rebuild and the reactivation. + std::unique_ptr buildInstrumentLocked(SampleData sample); + // Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress // (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse). bool legacyLiftShouldRun(); @@ -293,22 +310,6 @@ private: // Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine. void publishBuiltLocked(std::unique_ptr built); - // Folds one block's reading into the accumulator it belongs to — a running max for a peak, - // a running min for the limiter's gain. Read-modify-write rather than a bare store, so the - // ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512 all reach the meter - // instead of the one it happened to sample. Relaxed throughout: the accumulators are - // advisory, and no other state is ordered against them. Block rate, never per frame. - static void foldPeak(std::atomic& acc, float blockPeak) { - if (blockPeak > acc.load(std::memory_order_relaxed)) { - acc.store(blockPeak, std::memory_order_relaxed); - } - } - static void foldMinGain(std::atomic& acc, float blockMinGain) { - if (blockMinGain < acc.load(std::memory_order_relaxed)) { - acc.store(blockMinGain, std::memory_order_relaxed); - } - } - // Publishes a silent block. EVERY process() path that emits no audio calls this. It clears // only the ADVISORY level, which is a last-block reading: the meter accumulators need // nothing here, because a block that emitted no audio contributes no peak and no gain @@ -383,6 +384,13 @@ private: // Guarded by reloadMutex_, consumed by publishBuiltLocked. std::optional gainAtNextPublish_; + // The decoded PCM parked across a deactivate, so an activation cycle costs no disk read + // and no WAV decode: activation is "the audio thread may run", not "the sample is + // rebuilt". Holds a value only while inactive, and any publish drops it (publishBuiltLocked + // owns why). Voice state is deliberately NOT parked with it; see setActive. Guarded by + // reloadMutex_. + std::optional dormantSample_; + // The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the // audio thread. std::mutex selectionMutex_; @@ -479,20 +487,25 @@ private: // getLatencySamples answers from. instrument::engine::Limiter limiter_; std::atomic limiterEnabled_{false}; - // Set by the commit funnel when the enable actually changed, cleared only by - // flushLatencyRestart. A sticky bool and not a count on purpose: the host is being told to - // re-ASK, so N changes before one flush need exactly one restart, and whatever - // getLatencySamples answers at that moment is the truth being announced. + // Raised (and lowered) by the commit funnel from the difference between the enable and + // latencyAnnounced_, cleared by flushLatencyRestart on delivery. A bool and not a count on + // purpose: the host is being told to re-ASK, so N changes before one flush need exactly one + // restart, and whatever getLatencySamples answers at that moment is the truth announced. std::atomic latencyRestartPending_{false}; + // The enable the host was last told about — false initially, which is what an instance + // that has announced nothing reports. Every arm is judged against this, so a change that + // returns to the announced state costs no restart. + std::atomic latencyAnnounced_{false}; // What the audio thread publishes about the output bus each block, relaxed. The peaks and // minGain ACCUMULATE (max / min) across every block since the UI last read, and - // masterBusMeter() resets them as it reads — the fix for a bar that displayed roughly one - // block in fifty. No dB, no ballistics, no hold timer here; - // the UI runs those off these values and its own elapsed time. - std::atomic meterPeakL_{0.f}; - std::atomic meterPeakR_{0.f}; - std::atomic meterMinGain_{1.f}; + // masterBusMeter() consumes them as it reads — the fix for a bar that displayed roughly one + // block in fifty. The folds and the identity elements below are meter_accumulate's; no dB, + // no ballistics, no hold timer here — the UI runs those off these values and its own + // elapsed time. + std::atomic meterPeakL_{instrument::engine::kMeterPeakIdentity}; + std::atomic meterPeakR_{instrument::engine::kMeterPeakIdentity}; + std::atomic meterMinGain_{instrument::engine::kMeterGainIdentity}; std::atomic meterClip_{false}; // The embed strip's activity level: the LAST block's loudest channel, plainly overwritten. diff --git a/tests/test_meter_accumulate.cpp b/tests/test_meter_accumulate.cpp new file mode 100644 index 0000000..436475a --- /dev/null +++ b/tests/test_meter_accumulate.cpp @@ -0,0 +1,106 @@ +// Standalone tests for reasampler::instrument::engine::meter_accumulate — no VST3, no REAPER, +// no framework. Assert: +// +// * the window semantics — max for a peak, min for the limiter gain, and a consume that both +// reports the window and reinstalls the identity element that starts the next one. +// * the INTERLEAVE the CAS exists for: a consume landing inside a fold must not swallow that +// block. Driven by an accumulator that performs the consume from inside its first CAS, so +// the ordering is pinned rather than raced for. + +#include "../src/core/instrument/engine/meter_accumulate.h" + +#include +#include + +using namespace reasampler::instrument::engine; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Stands in for std::atomic with ONE scripted interference: the first compare-exchange +// runs the UI's consume (identity reinstalled, the window taken) and reports failure exactly as +// the real CAS does — expected updated to what the consume left. Everything after is ordinary. +struct ConsumingAccumulator { + float value; + float identity; + float consumed = -1.f; // what the injected consume took + int casCount = 0; + + float load(std::memory_order) const { return value; } + + bool compare_exchange_weak(float& expected, float desired, std::memory_order, + std::memory_order) { + if (casCount++ == 0) { + consumed = value; + value = identity; + expected = value; + return false; + } + value = desired; + return true; + } +}; + +static void testPeakWindowKeepsTheLoudestBlock() { + std::atomic acc{kMeterPeakIdentity}; + foldPeak(acc, 0.25f); + foldPeak(acc, 0.90f); + foldPeak(acc, 0.40f); // quieter than the window's max: must not lower it + CHECK(acc.load() == 0.90f); + CHECK(consumePeak(acc) == 0.90f); + // Consumed means a NEW window, not a carried-over one. + CHECK(acc.load() == kMeterPeakIdentity); + foldPeak(acc, 0.10f); + CHECK(consumePeak(acc) == 0.10f); +} + +static void testGainWindowKeepsTheDeepestReduction() { + std::atomic acc{kMeterGainIdentity}; + foldMinGain(acc, 0.80f); + foldMinGain(acc, 0.55f); + foldMinGain(acc, 0.95f); // shallower: must not raise the window + CHECK(acc.load() == 0.55f); + CHECK(consumeMinGain(acc) == 0.55f); + // 1.0, not 0.0 — an untouched gain window means "no reduction", and a 0 identity would + // report a total mute on every idle frame. + CHECK(acc.load() == kMeterGainIdentity); +} + +static void testBlocksAtOrBelowTheWindowLeaveItAlone() { + std::atomic acc{kMeterPeakIdentity}; + foldPeak(acc, 0.50f); + foldPeak(acc, 0.50f); + CHECK(acc.load() == 0.50f); + // The post-condition every fold owes, whichever way the comparison went. + foldPeak(acc, 0.20f); + CHECK(acc.load() >= 0.20f); +} + +static void testConsumeInsideAFoldStillLandsTheBlockInTheNewWindow() { + // The window holds a LOUDER peak than the block being folded — the exact case a + // load-compare-store fold skips, so the block would be lost when the consume lands + // between that load and the store it decided not to make. + ConsumingAccumulator acc{0.90f, kMeterPeakIdentity}; + foldPeak(acc, 0.40f); + CHECK(acc.consumed == 0.90f); // the UI got the window it was owed + CHECK(acc.value == 0.40f); // and the block reached the NEW window rather than vanishing + CHECK(acc.casCount == 2); // one interfering consume, exactly one retry + + // Same for the gain window: a block reducing LESS than the window's minimum is the one a + // skipping fold drops, and losing it reports "no reduction" over a block that had some. + ConsumingAccumulator gain{0.60f, kMeterGainIdentity}; + foldMinGain(gain, 0.90f); + CHECK(gain.consumed == 0.60f); + CHECK(gain.value == 0.90f); + CHECK(gain.casCount == 2); +} + +int main() { + testPeakWindowKeepsTheLoudestBlock(); + testGainWindowKeepsTheDeepestReduction(); + testBlocksAtOrBelowTheWindowLeaveItAlone(); + testConsumeInsideAFoldStillLandsTheBlockInTheNewWindow(); + if (g_fail == 0) std::printf("meter_accumulate tests passed\n"); + return g_fail == 0 ? 0 : 1; +} From da14509ab538894192d35830adb4283ab84e2417 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 13:15:47 -0400 Subject: [PATCH 44/56] Restore the bank fold and usage publish to the resume path, guard setActive against repeats, and make the meter fold's bound literal The resume also hands back to a full reload when the fold moves the loaded capture's decode source, so the refs table and the audio cannot skew. --- docs/COMPLETED.md | 30 ++++++++++--- docs/PLAN.md | 27 ++++++------ docs/TODO.md | 16 ++++--- docs/product/instrument-control-surface.md | 11 ++++- src/core/instrument/engine/meter_accumulate.h | 33 +++++++------- src/core/instrument/map/sample_map.cpp | 6 +++ src/core/instrument/map/sample_map.h | 7 +++ src/core/instrument/ui/master_meter.h | 6 ++- src/shell/instrument/CLAUDE.md | 2 +- src/shell/instrument/processor_reload.cpp | 43 +++++++++++++++++-- src/shell/instrument/processor_state.cpp | 17 +++++--- src/shell/instrument/reasampler_processor.cpp | 15 +++++-- src/shell/instrument/reasampler_processor.h | 36 ++++++++++------ tests/test_meter_accumulate.cpp | 7 +-- tests/test_sample_map.cpp | 38 ++++++++++++++++ 15 files changed, 218 insertions(+), 76 deletions(-) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index 72644ba..a3f8f6e 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -17,12 +17,30 @@ separate: `setActive(false)` parks the decoded sample and destroys the voice sta decode; sounding voices are still destroyed across it (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices as ghosts); an instance with nothing decoded still routes through the full reload, which is where the pre-v10 legacy lift lives; and -`getLatencySamples()` still answers from the persisted enable, untouched by the cycle. The -build shared by the reload, the voice-param rebuild and the reactivation was factored to one -site so the three cannot drift on the generation stamp or the ring size. Daniel reversed the -deferral (*"I thought we agreed to decouple the unnecessary functions from the reactivation -path"*); Γ-F2 and Γ-F6 are untouched — dynamic latency ships, the deactivate/reactivate is -still the accepted cost of the toggle, just a much cheaper one. +`getLatencySamples()` still answers from the persisted enable, untouched by the cycle. The build +shared by the reload, the voice-param rebuild and the reactivation was factored to one site so the three cannot drift +on the generation stamp or the ring size. Daniel reversed the deferral (*"I thought we agreed +to decouple the unnecessary functions from the reactivation path"*); Γ-F2 and Γ-F6 are untouched +— dynamic latency ships, the deactivate/reactivate is still the accepted cost of the toggle, +just a much cheaper one. + +**`reloadInstrument` did three things, not one, and the resume path had to keep all three.** The +first review pass discussed only the pre-v10 legacy lift; the other two were dropped silently and +restored in the follow-up. `refreshRefsFromBank` — the recapture sync — and `publishUsage` — the +`rsusage_` prune-protection write — are each a `GetProjExtState` plus a parse, neither disk nor +decode, so both run on the resume path and the spec's "no disk I/O and no WAV decode" still holds +exactly. This matters only with **no editor open**: `pollBankSync`, the only other route to +either, has exactly one caller and it is the editor's sync tick. Restoring the refresh alone +would have been worse than dropping it — the refs would name a recapture's new file while the +parked PCM still played the old one — so the resume compares the selected ref across the fold +(`sameDecodeSource`, `core/instrument/map/sample_map`) and hands back to the full reload when it +moved. The decode is eliminated in the case that matters and re-run in the case that needs it. +Three smaller consequences fell out of the same pass: `setActive` now treats a repeat of the +state it already holds as a no-op (the base is an empty stub, so a repeated deactivate would have +parked an empty optional over a still-valid sample); the park is disengaged before the build +rather than left moved-from, so a throwing build cannot publish permanent silence; and +`flushLatencyRestart` checks `restartComponent`'s `tresult` and rolls its announcement back on a +refusal, since a latch on a value the host never took would strand its delay compensation. ### Comment-reduction pass (tree-wide, twelve parallel tracks) diff --git a/docs/PLAN.md b/docs/PLAN.md index b539e0b..6071886 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -63,9 +63,8 @@ change, then advancing at 1/newDuration). **Phase Γ opened six [Daniel]-class f tracks below and indexed in `docs/product/instrument-control-surface.md` §8. **Γ-F6 closed with a correction to the analysis, not merely a ruling**: dynamic reported latency is routine for VST3 instruments and REAPER handles it as a matter of course; what makes the mandated -restart expensive *here* is self-inflicted (`setActive(true)` calls `reloadInstrument`), so -the cost is ours to reduce and the reduction is filed in `docs/TODO.md` rather than designed -around. +restart expensive *here* was self-inflicted (`setActive(true)` called `reloadInstrument`), so +the cost was ours to reduce. **That reduction landed in Γ-W3** — see `docs/COMPLETED.md`. **Γ-F3 was subsequently REVERSED and a seventh fork opened AND CLOSED, all by Daniel's later rulings of 2026-08-01.** Γ-F3 (*"the stage-time ceiling stays 2.0 s"*) is replaced by *"extend @@ -651,14 +650,15 @@ every parameter's **plain unit, range and display precision** (§6.7). Today the **What the Γ-F6 ruling changed in the analysis, not just in the plan.** Dynamic latency reporting is **routine** for VST3 instruments and REAPER handles it as a matter of course; the SDK's deactivate/reactivate requirement (`pluginterfaces/vst/ivsteditcontroller.h:105-108`) -is the normal contract, not an exotic one. What makes the cycle expensive **here** is entirely -our own doing: `ReaSamplerProcessor::setActive(true)` calls `reloadInstrument()` — a bridge -read plus a full WAV re-decode (`reasampler_processor.cpp:89-97`) — where a typical plugin's -`setActive` only allocates and frees buffers, and the deactivate side's freeing of -`live_`/`draining_`/graveyard (`:98-107`) is likewise our own design. **The cost is therefore -ours to reduce if it ever matters, and the reduction is decoupling reload from activation — -not abandoning dynamic latency.** That improvement is filed as a `docs/TODO.md` entry with its -trigger condition; it is not scheduled in this phase. +is the normal contract, not an exotic one. What made the cycle expensive **here** was entirely +our own doing: `ReaSamplerProcessor::setActive(true)` called `reloadInstrument()` — a bridge +read plus a full WAV re-decode — where a typical plugin's `setActive` only allocates and frees +buffers. **The cost was therefore ours to reduce, and the reduction was decoupling reload from +activation — not abandoning dynamic latency.** **Landed in Γ-W3**: the activate branch +(`reasampler_processor.cpp:86-132`) now resumes the voice state around a parked `SampleData` +and reloads only when there is nothing to resume from or a bank refresh moved what the park was +decoded from; the deactivate branch parks the PCM and frees everything else. Narrative and +consequences are in `docs/COMPLETED.md`. **Sequencing against Phase Ξ — the ordering claim is RETIRED and replaced by an owned correction.** This plan previously asserted that Γ must run before Ξ-W2 and called it *"a @@ -1421,9 +1421,8 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). nothing new is persisted and therefore it should not; if the `setState` verification says otherwise, it takes the reserved rung and says so. - **Closed, do not reopen:** Rate lifted from latched to live (§3.5 records the cost); the - limiter enable made automatable (§3.8 — its one reopening condition is the `docs/TODO.md` - reload/activation decoupling, and the answer is to do that first, not to re-litigate the - classification). + limiter enable made automatable (§3.8 — its one reopening condition was the reload/activation + decoupling, which landed in Γ-W3, so the condition is discharged rather than pending). --- diff --git a/docs/TODO.md b/docs/TODO.md index 922f0ce..bd0f01f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -242,17 +242,19 @@ alpha and this entry is re-filed against the new value. ## The editor's drag state machine has no seam, and `reasampler_editor.h` is near the ceiling -**Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **563 lines** against the -~600-line ceiling — 37 lines of margin — and it keeps growing because every new surface on the +**Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **564 lines** against the +~600-line ceiling — 36 lines of margin — and it keeps growing because every new surface on the Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_` plus the per-gesture anchors it is read against. **Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs** -(measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional), and -every input TU both writes it and branches on it. Extracting it is a real refactor of the -editor's input half, not a header move — and doing it inside a wave whose subject is the MASTER -deck would have put an unrelated high-blast-radius change in the same diff. Declining was right; -leaving it unrecorded was not. +(measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional). Of +the six input TUs, three write it and branch on it (`editor_input`, `_waveform`, `_curve`) and +three only write it (`_chrome`, `_browse`, `_deck`) — which is what makes the anchor invariant +observed rather than enforced. Extracting it is a real refactor of the editor's input half, not +a header move — and doing it inside a wave whose subject is the MASTER deck would have put an +unrelated high-blast-radius change in the same diff. Declining was right; leaving it unrecorded +was not. **The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload, with the input TUs mutating it through named transitions rather than assigning `drag_` and its diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index 2a93607..b5c3ebb 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -1409,6 +1409,15 @@ back through the full reload, which is what keeps the pre-v10 legacy lift reacha untouched: dynamic latency ships and the deactivate/reactivate is still the accepted cost — it is simply a much cheaper one. +**What "cheaper" does NOT mean: skipping the bank fold.** `reloadInstrument` also runs the +recapture sync and the `rsusage_` prune-protection publish, and with no editor open the +activation is the only place either happens (`pollBankSync` runs off the editor's sync tick and +nothing else). Both are a `GetProjExtState` plus a parse — neither disk nor decode — so both run +on the resume path too, and a fold that moves the loaded capture's decode source hands back to +the full reload rather than resuming PCM the bank has superseded. A resume that refreshed the +refs without re-decoding would be the worst of the three: the table would name a recapture's new +file while the voices played the old one. + --- ## 8. Forks — SEVEN ruled (one later reversed), NONE open @@ -1484,7 +1493,7 @@ than just counting: 4. **The verification requirements survive unchanged**, because they were always about the `kIoChanged` scar (a dual-mono capture panned hard right by a prior mid-session `restartComponent`), not about this flag. -5. **The reduction is filed**, with a trigger condition, in `docs/TODO.md`. +5. **The reduction was filed with a trigger condition and has since LANDED** (Γ-W3 — §7.11). ### 8.3 Γ-F7 — RULED: signal flow. The parameter order diff --git a/src/core/instrument/engine/meter_accumulate.h b/src/core/instrument/engine/meter_accumulate.h index 27de0b0..3b0251a 100644 --- a/src/core/instrument/engine/meter_accumulate.h +++ b/src/core/instrument/engine/meter_accumulate.h @@ -1,8 +1,8 @@ // meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold // into the two windows the UI drains, and the drain that starts the next window. The ballistics // that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio -// thread's per-block path. Templated on the accumulator ONLY so the drain-inside-the-fold -// interleave below can be pinned deterministically instead of raced for. +// thread's per-block path. The folds are templated on the accumulator ONLY so the +// drain-inside-the-fold interleave below can be pinned deterministically instead of raced for. #pragma once @@ -30,36 +30,39 @@ inline constexpr float kMeterGainIdentity = 1.f; // would then drop the block outright: it decided against storing by comparing with a window the // UI has since taken, so that block's reading enters neither the old window nor the new one. // The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on -// exit however the two interleave. Bounded — the audio thread is this accumulator's only other -// writer, so one interfering consume costs one retry. Relaxed throughout: the accumulators are -// advisory and order no other state. Block rate, never per frame. +// exit however the two interleave. STRONG, so the loop is bounded by the interference it is +// written against: the audio thread is the only writer besides the UI's single consume, and +// weak's permitted spurious failure would make an unbounded retry count reachable with no +// interference at all. Three calls per block, so the strong form costs nothing measurable. +// Relaxed throughout: the accumulators are advisory and order no other state. `Accumulator` is +// templated only so a test can pin the interleave; it must behave as std::atomic. template inline void foldPeak(Accumulator& acc, float blockPeak) { float seen = acc.load(std::memory_order_relaxed); - while (!acc.compare_exchange_weak(seen, seen > blockPeak ? seen : blockPeak, - std::memory_order_relaxed, - std::memory_order_relaxed)) { + while (!acc.compare_exchange_strong(seen, seen > blockPeak ? seen : blockPeak, + std::memory_order_relaxed, + std::memory_order_relaxed)) { } } template inline void foldMinGain(Accumulator& acc, float blockMinGain) { float seen = acc.load(std::memory_order_relaxed); - while (!acc.compare_exchange_weak(seen, seen < blockMinGain ? seen : blockMinGain, - std::memory_order_relaxed, - std::memory_order_relaxed)) { + while (!acc.compare_exchange_strong(seen, seen < blockMinGain ? seen : blockMinGain, + std::memory_order_relaxed, + std::memory_order_relaxed)) { } } // Takes what the window accumulated and reinstalls the identity element, which IS what starts // the next window — so exactly one reader may consume (the shell's MasterBusMeter states who). -template -inline float consumePeak(Accumulator& acc) { +// Concrete: only the folds have the interleave a test seam buys, and a template over one +// instantiation models nothing. +inline float consumePeak(std::atomic& acc) { return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed); } -template -inline float consumeMinGain(Accumulator& acc) { +inline float consumeMinGain(std::atomic& acc) { return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed); } diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 417eb30..25fe0c5 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -104,6 +104,12 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, } } +bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b) { + return a.relativePath == b.relativePath && a.rootNote == b.rootNote && + a.channelCount == b.channelCount && a.loop.hasLoop == b.loop.hasLoop && + a.loop.start == b.loop.start && a.loop.end == b.loop.end; +} + LegacyLiftDecision legacyLiftDecision(const std::optional& banksJson, const std::vector& ids) { if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry; diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 0cac6b6..0dd6172 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -85,6 +85,13 @@ std::vector referencedSampleIds(const std::string& selectionId); void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, const std::vector& ids); +// True when two refs would build the same SampleData: path plus every intrinsic +// resolveCapture folds. displayName is excluded on purpose — it is a label, never a decode +// input. Exists so a caller holding an ALREADY-DECODED sample can ask whether a refresh moved +// what that sample was decoded from; comparing the fields at the call site instead would go +// stale the first time this struct gains one. +bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b); + // Legacy-lift terminating decision: can a refs lift make progress against this bank blob // for the ids the instance references? // * Retry — blob absent/empty/unparseable: not readable yet, keep retrying. diff --git a/src/core/instrument/ui/master_meter.h b/src/core/instrument/ui/master_meter.h index a746af4..39042f7 100644 --- a/src/core/instrument/ui/master_meter.h +++ b/src/core/instrument/ui/master_meter.h @@ -91,8 +91,10 @@ bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b); // The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs // a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the -// spec asks only for "a small floor". Raising it hides genuine catches, since the limiter's -// ceiling is only −0.3 dBTP. +// spec asks only for "a small floor". It is bounded on BOTH sides: raising it hides genuine +// catches, since the limiter's ceiling is only −0.3 dBTP; lowering it turns the lamp into a +// "some sample crossed the ceiling" light, because the gain law is ceiling/peak and so reports +// an arbitrarily small reduction for a peak arbitrarily close to the ceiling. inline constexpr double kGrLampFloorDb = 0.5; bool grLampLit(const MasterMeterUi& m); diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index a828667..36b7d22 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -107,7 +107,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h ## Modules - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. -- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. Nothing parked means nothing was decoded, which routes the activation back through the full reload; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 8bd0f20..865db9a 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -268,8 +268,7 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, } void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { - // REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the - // one safety-critical swap dance (see the header's drain-slot proof). + // REQUIRES reloadMutex_ held (see the header's drain-slot proof). const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), @@ -347,9 +346,45 @@ bool ReaSamplerProcessor::resumeDormantInstrument() { // park. Rebuilding here would displace a correct instrument into the drain slot. if (live_.load(std::memory_order_acquire)) return true; if (!dormantSample_) return false; + + // The activation is still where a bank change made with NO EDITOR OPEN is picked up: + // pollBankSync, the only other route to either of the two calls below, runs off the + // editor's sync tick and nothing else. Both are a GetProjExtState plus a parse — no disk + // and no decode, which is what lets them stay on a path whose whole point is skipping + // those two. + const std::string selId = selectedSampleId(); + const std::vector ids = referencedSampleIds(selId); + SampleRefs refs; + bool sourceMoved = false; + { + std::optional banksJson = + bridge_.readReasamplerExtState(kProjExtBanksKey); + std::lock_guard rl(refsMutex_); + if (banksJson) { + const SelectedSample* before = findRef(sampleRefs_, selId); + const std::optional was = + before ? std::optional(*before) : std::nullopt; + refreshRefsFromBank(sampleRefs_, *banksJson, ids); + const SelectedSample* now = findRef(sampleRefs_, selId); + sourceMoved = !was || !now || !sameDecodeSource(*was, *now); + } + refs = sampleRefs_; + } + // A recapture that landed while this instance was inactive makes the park the WRONG audio, + // and refreshing the refs without re-decoding would leave the table naming one file while + // the voices play another. Hand back to the full reload, which decodes the new one. + if (sourceMoved) return false; + // MOVED, not copied: the park exists for this one handoff, and keeping it would hold a - // second copy of the PCM for the whole active lifetime. - publishBuiltLocked(buildInstrumentLocked(std::move(*dormantSample_))); + // second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a + // throwing build leaves nothing to resume — the next activation then takes the reload + // rather than publishing an empty sample as permanent silence. + SampleData resumed = std::move(*dormantSample_); + dormantSample_.reset(); + publishBuiltLocked(buildInstrumentLocked(std::move(resumed))); + // The prune-protection republish reloadInstrument owes on every publish: it is also what + // heals an rsusage_ key whose write failed when this instance last set its state. + publishUsage(refs, ids); return true; } diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index ac7056c..ba0a098 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -87,8 +87,8 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { legacyLiftConcluded_.store(false, std::memory_order_relaxed); reloadInstrument(); // This caller has no editor to flush for it. At the TAIL on purpose: a host that services the - // restart synchronously deactivates/reactivates, and our setActive(true) reloads — from the - // refs above, which are only fully restored once this function has run to here. + // restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads + // against the refs above, which are only fully restored once this function has run to here. flushLatencyRestart(); return kResultOk; } @@ -183,13 +183,20 @@ void ReaSamplerProcessor::flushLatencyRestart() { // Latched BEFORE the call: a host that services the restart synchronously re-enters this // object inside it, so the next commit must compare against the value the host is about to // read, not against the one it held before. - latencyAnnounced_.store(limiterEnabled_.load(std::memory_order_relaxed), - std::memory_order_relaxed); + const bool previouslyAnnounced = latencyAnnounced_.exchange( + limiterEnabled_.load(std::memory_order_relaxed), std::memory_order_relaxed); // The SDK requires this on the UI thread and answers getLatencySamples only after the host's // own deactivate/reactivate — so the flag is long committed by the time the host asks. This // is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode kIoChanged // bus renegotiation (see initialize()); do not conflate. - componentHandler->restartComponent(kLatencyChanged); + if (componentHandler->restartComponent(kLatencyChanged) == kResultOk) return; + // A refused restart leaves the host's delay compensation on the OLD value, so the latch has + // to come back off it: announcing a value the host never took would let a later toggle BACK + // to that value arm nothing, stranding the host's view permanently. Re-armed instead, which + // costs one retry per drain in a host that always refuses. The SDK documents no refusal + // semantics, so whether any host returns non-kResultOk here is `[verify — DAW]`. + latencyAnnounced_.store(previouslyAnnounced, std::memory_order_relaxed); + latencyRestartPending_.store(true, std::memory_order_release); } void ReaSamplerProcessor::publishLimiterEnabled(bool on) { diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index d95f193..1ce0c08 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -87,11 +87,18 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { // Activation governs ONE thing — whether the audio thread may run. The decoded // SampleData has its own lifetime and survives the cycle (dormantSample_); only the // voice state is built and destroyed here. Main/UI-thread call. + // + // Neither branch is idempotent on its own — a repeated deactivate would park an empty + // optional over a still-valid sample, and a repeated activate would reset the limiter over + // a live delay line — and the SDK base is an empty stub that guards neither. + // `[verify — DAW]` whether any host actually repeats the call. + if (static_cast(state) == active_) return kResultOk; + active_ = static_cast(state); if (state) { - // Rebuild the voices around the sample the deactivate parked — no bridge read, no WAV - // decode — so a host-driven cycle (a kLatencyChanged restart, an offline-render - // bracket) costs no I/O. With nothing to activate from, the full reload runs: it - // resolves + decodes from the instance-owned refs (no bank read, so it plays regardless + // Rebuild the voices around the sample the deactivate parked — no WAV decode — so a + // host-driven cycle (a kLatencyChanged restart, an offline-render bracket) costs no + // disk read. With nothing to activate from, the full reload runs: it resolves + + // decodes from the instance-owned refs (no bank read required, so it plays regardless // of PROJEXTSTATE parse state) and doubles as the non-editor legacy-lift trigger for a // pre-v10 blob, whose opportunistic refreshRefsFromBank copies refs in when the bank // blob is readable by now. Nothing can shadow that lift: a pre-v10 blob resolves diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index e5f9dd0..3135a3e 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -139,9 +139,9 @@ public: // What the audio thread published since the LAST call: peaks maxed and minGain minimised // across every block in that window. CONSUMING — it resets the accumulators as it reads, so // exactly one reader may call it, and that reader is the editor's meter tick. Two live - // editors would each consume half the windows and both meters would read low; what makes - // that unreachable is the HOST calling createView once per instance, not anything this - // plugin enforces — createView allocates a new editor on every call. UI thread. + // editors would each consume half the windows and both meters would read low; the single + // reader rests on the HOST calling createView once per instance `[verify — DAW]`, not on + // anything this plugin enforces — createView allocates a new editor on every call. UI thread. MasterBusMeter masterBusMeter(); void clearMasterBusClip(); @@ -289,10 +289,12 @@ private: void rebuildVoiceEngine(); // The reactivation half of the activation/decode lifetime split (see dormantSample_): - // rebuilds the voice state around the parked sample and publishes it through the same - // drain-slot swap. True also when a publish landed while inactive, which needs no rebuild. - // False when there is nothing to activate from — the caller then falls back to a full - // reload, which is where the pre-v10 legacy lift lives. Off the audio thread only. + // folds the live bank blob into the refs, rebuilds the voice state around the parked + // sample, publishes it through the same drain-slot swap, and republishes usage. True also + // when a publish landed while inactive, which needs no rebuild. False when there is nothing + // to activate from, or when that fold moved what the park was decoded from — the caller + // then falls back to a full reload, which decodes the new file and is also where the pre-v10 + // legacy lift lives. Off the audio thread only. bool resumeDormantInstrument(); // Builds a playable snapshot around `sample` at the current voice-system parameters and @@ -307,7 +309,8 @@ private: // Publishes `built` (null = install silence) into live_: prunes the graveyard by the // last process()-published generation, swaps `built` into live_, displaces the previous // live into the drain slot, and parks the evicted drain instrument in the graveyard. - // Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine. + // Requires reloadMutex_ held — shared by reloadInstrument, rebuildVoiceEngine and + // resumeDormantInstrument. void publishBuiltLocked(std::unique_ptr built); // Publishes a silent block. EVERY process() path that emits no audio calls this. It clears @@ -391,6 +394,10 @@ private: // reloadMutex_. std::optional dormantSample_; + // Whether the host has us active, so setActive can treat a repeat of the state it already + // holds as a no-op (it owns why). Main/UI thread only, like setActive itself. + bool active_ = false; + // The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the // audio thread. std::mutex selectionMutex_; @@ -488,13 +495,14 @@ private: instrument::engine::Limiter limiter_; std::atomic limiterEnabled_{false}; // Raised (and lowered) by the commit funnel from the difference between the enable and - // latencyAnnounced_, cleared by flushLatencyRestart on delivery. A bool and not a count on - // purpose: the host is being told to re-ASK, so N changes before one flush need exactly one - // restart, and whatever getLatencySamples answers at that moment is the truth announced. + // latencyAnnounced_, cleared by flushLatencyRestart on delivery and re-raised there if the + // host refuses. A bool and not a count on purpose: the host is being told to re-ASK, so N + // changes before one flush need exactly one restart, and whatever getLatencySamples answers + // at that moment is the truth announced. std::atomic latencyRestartPending_{false}; - // The enable the host was last told about — false initially, which is what an instance - // that has announced nothing reports. Every arm is judged against this, so a change that - // returns to the announced state costs no restart. + // The enable the host has ACCEPTED — false initially, which is what an instance that has + // announced nothing reports. Every arm is judged against this, so a change that returns to + // the announced state costs no restart. std::atomic latencyAnnounced_{false}; // What the audio thread publishes about the output bus each block, relaxed. The peaks and diff --git a/tests/test_meter_accumulate.cpp b/tests/test_meter_accumulate.cpp index 436475a..e6c4fc9 100644 --- a/tests/test_meter_accumulate.cpp +++ b/tests/test_meter_accumulate.cpp @@ -20,7 +20,8 @@ static int g_fail = 0; // Stands in for std::atomic with ONE scripted interference: the first compare-exchange // runs the UI's consume (identity reinstalled, the window taken) and reports failure exactly as -// the real CAS does — expected updated to what the consume left. Everything after is ordinary. +// the real CAS does — expected updated to what the consume left. Everything after is ordinary, +// which is what makes the retry count assertable: a strong CAS fails only under interference. struct ConsumingAccumulator { float value; float identity; @@ -29,8 +30,8 @@ struct ConsumingAccumulator { float load(std::memory_order) const { return value; } - bool compare_exchange_weak(float& expected, float desired, std::memory_order, - std::memory_order) { + bool compare_exchange_strong(float& expected, float desired, std::memory_order, + std::memory_order) { if (casCount++ == 0) { consumed = value; value = identity; diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 52c03c2..5138782 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -531,6 +531,43 @@ static void testRefreshRefsFromBankUpsertAndOwnership() { CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40); } +static void testSameDecodeSourceTracksEveryDecodeInput() { + // The predicate a resumed (already-decoded) instrument is gated on: every field that + // changes what buildSampleData produces must read as different, and the display-only + // name must not. + SelectedSample a; + a.relativePath = "b/a.wav"; + a.rootNote = 36; + a.channelCount = 2; + a.loop.hasLoop = true; + a.loop.start = 100; + a.loop.end = 900; + CHECK(sameDecodeSource(a, a)); + + SelectedSample recaptured = a; + recaptured.relativePath = "b/a2.wav"; // the recapture case: a new file behind one id + CHECK(!sameDecodeSource(a, recaptured)); + + SelectedSample reRooted = a; + reRooted.rootNote = 40; + CHECK(!sameDecodeSource(a, reRooted)); + + SelectedSample reChanneled = a; + reChanneled.channelCount = 1; // drives the channel-mode auto-default, hence the decode + CHECK(!sameDecodeSource(a, reChanneled)); + + SelectedSample loopOff = a; + loopOff.loop.hasLoop = false; + CHECK(!sameDecodeSource(a, loopOff)); + + SelectedSample loopMoved = a; + loopMoved.loop.start = 101; + CHECK(!sameDecodeSource(a, loopMoved)); + loopMoved = a; + loopMoved.loop.end = 901; + CHECK(!sameDecodeSource(a, loopMoved)); +} + static void testRetainRefsFiltersToPlayedSet() { // getState hygiene: only the entries the instance currently plays persist — the table // cannot grow with browsing history. Order of survivors is preserved. @@ -1015,6 +1052,7 @@ int main() { testReferencedSampleIdsIsTheLoadedCapture(); testFindRefLooksUpTheOwnedCopy(); testRefreshRefsFromBankUpsertAndOwnership(); + testSameDecodeSourceTracksEveryDecodeInput(); testRetainRefsFiltersToPlayedSet(); testLegacyLiftDecision(); testResolvePlayConvertsWallClockAtTheRate(); From e87d044042e2e53a1cdcca4c8be52a733429626e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 13:24:57 -0400 Subject: [PATCH 45/56] docs: record Phase Gamma Wave 3's two landed tracks in COMPLETED --- docs/COMPLETED.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index a3f8f6e..c34c465 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -1172,3 +1172,79 @@ judging it visually in the DAW and has not yet ruled.** **Neither track has been verified in a running DAW; both are asserted in CTest only.** The full test suite passes on the merged result — **99/99, Debug config, on one machine** — not a general cross-platform or Release-config claim. + +### Γ-W3-T1 — deck-reflow + +The knob deck's row law stops being a wrap outcome and becomes a property of the group +descriptor, by construction: two categorical rows — Sound (PITCH/RATE, FILTER, VELOCITY, +VOICE) and Contour (PITCH ENV, FILTER ENV, AMP ENVELOPE) — plus a double-height, +right-anchored MASTER bus deck outside both, carrying the limiter enable toggle, one +reserved cell, the output meter column, and a passive gain-reduction lamp. `DeckRow { +Sound, Contour, Spanning }` and `deckRowFor` (`ui/deck_groups`) are an exhaustive switch +over every `DeckGroupId`, so a group added later without a row assignment is a compile +error; the greedy whole-group wrap this replaces is gone entirely, not merely unreached at +this width. + +FILTER's `Band|Notch` toggle moves from the knob row into its own caption's previously +unused second toggle slot, taking the group from 524 to 432 px (−92) — the reduction that +lets row 1 (980 px natural) fit inside the row block. VOICE deliberately keeps its +`Retrig|Legato` row toggle rather than following suit: moving it to the caption would make +VOICE *wider* (226 px vs. 164), since its caption row is already the binding side. + +**The row block widened 1020 → 1028 px and the editor floor moved 1190 → 1198 px +(Daniel's ruling, 2026-08-02).** The originally specified 1020 could not simultaneously +deliver the filter tie-line (both rows' FILTER/FILTER ENV right edges landing at the same +x) and equal, no-narrower-than-12px gutters on both rows — the three properties were never +jointly satisfiable at that width. At 1028 all hold: row 1's three gutters land at +16/16/16, row 2's two at 76/76, and both FILTER and FILTER ENV land their right edge at x += 640. Ceiling headroom against the 1280 px cap is now 82 px. + +The MASTER meter's per-block state moved from a plain overwriting store to an accumulated +one: at 48 kHz/512-frame blocks, roughly 47 blocks elapse between two 500 ms UI ticks, so +the overwriting store had displayed one block in ~47 and dropped the rest. The processor +now folds a per-channel peak max and a limiter min-gain across the whole interval, and the +consuming `masterBusMeter()` read clears the accumulators as it drains them. + +**The instrument reload was decoupled from VST3 activation as part of this track** — +`setActive(false)` now parks the decoded `SampleData` and destroys only the voice state, +`setActive(true)` rebuilds the voices around the parked sample, so a host-driven +activation cycle costs no disk read and no WAV decode. This discharges the `docs/TODO.md` +follow-up already recorded in full detail at the top of this file ("Decouple the +instrument reload from VST3 activation") — not restated here. + +The limiter toggle's commit is split so that cheaper cycle stays off the mouse handler: +the audible state — the parameter, the audio-thread mirror, the latency reader — commits +inline on the click; only the host's `restartComponent(kLatencyChanged)` notification is +deferred, drained by the editor's existing 500 ms sync tick. + +**Not verified in a running DAW — CTest-asserted only:** the meter at its 500 ms UI +cadence, the GR lamp under real limiter action, the limiter toggle's latency +renegotiation, the clip cap's click-to-clear, and the recapture-while-editor-closed path +(the bank fold and its predicate are unit-covered; the activation that drives them is +not). + +### Γ-W3-T2 — bake-reset-amendment + +The correction Phase Γ owed Phase Ξ: Ξ-W2-T1's bake shipped ahead of the sequencing this +plan asserted, so its reset list predated rate, pitch offset, the limiter enable, and the +loop enable. The finding, on reading what actually shipped: **`resetAfterBake` needed no +code change.** All four already reset by construction — none was ever added to the +survivor copy-back list, and the function's shape is "default everything, copy back only +survivors," so anything never named a survivor already resets. The track shipped +field-by-field assertions over two independently-dialled fixtures (never struct equality, +which would pass while silently letting a survivor slip through undetected) plus a +spot-check sweep confirming both fixtures actually moved every asserted field off its +default, so the coverage is mutation-verified rather than merely present. + +**One invariant correction:** `bake/CLAUDE.md` had claimed the whole signal chain prints, +master gain included. It doesn't — the render's gain multiply is the only master-stage +value it prints; the limiter runs in the processor's block, off the bake path entirely. +The claim is now scoped to gain alone, with an explicit note that "the bake prints the +gain" does not generalize to the rest of the master stage. + +**Outstanding, not closed by this track.** The limiter's exclusion from the printed master +stage is a real audible gap — a capture baked with the limiter engaged comes back +unlimited — and Daniel has ruled that a future track will change the bake to print the +limiter. Until that lands this is a recorded, known limitation, not an oversight. + +**Neither track has been verified in a running DAW; both are asserted in CTest only.** From c7afa3a80f29599da1abd5e75e002f77a393a056 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 13:34:55 -0400 Subject: [PATCH 46/56] docs: collapse Phase Gamma Wave 3 to its landed record, open bake-prints-limiter --- docs/PLAN.md | 305 ++++++++++++++++++--------------------------------- 1 file changed, 108 insertions(+), 197 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 6071886..72dcffe 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1005,213 +1005,117 @@ running DAW; both are asserted in CTest only** — 99/99, Debug config, on one m ### Γ-W3 — The reflow, and the bake correction -**Depends on Γ-W2 for:** the PITCH/RATE descriptor (W2-T1) — the reflow measures the real -three-cell group, and laying it out against a forecast of that group means re-measuring -afterward. **This is the whole reason the arrangement is late**, and it is why the canvas was -split out of it into W1-T4. **T2 depends on the same wave for a different reason:** rate and -pitch offset must exist before the bake's reset list can name them. +**Depended on Γ-W2** for the PITCH/RATE descriptor (W2-T1) — the reflow measured the real +three-cell group rather than a forecast of it, which is the whole reason the arrangement was +scheduled late. **Depended on Γ-W1** for W1-T2's published meter/GR/clip state, which MASTER's +deck draws, and W1-T4's floor, budget constants and row predicate, which T1 consumed rather +than re-derived. **T2 depended on Phase Ξ** — `Ξ-W2-T1 (resample-bake-chain)` landing on `dev` +first, the phase's only external gate — see `docs/COMPLETED.md` for the full narrative of each +landed track. -**Depends on Γ-W1 for:** W1-T2's published meter/GR/clip state, which MASTER's deck draws -(drawing against a stub would mean building the meter twice), and W1-T4's floor, budget -constants and row predicate, which T1 **consumes rather than re-derives**. **T2 depends on -W1-T2 for the limiter enable flag**, the third of the three values it must add. +**Two tracks have landed** — Γ-W3-T1 (`deck-reflow`) and Γ-W3-T2 (`bake-reset-amendment`) — see +`docs/COMPLETED.md` for the full narrative of each. **A third track is now open and has not +run:** Γ-W3-T3 (`bake-prints-limiter`), added below on Daniel's ruling of 2026-08-02. -**Depends on Phase Ξ for T2 — the phase's only EXTERNAL gate.** `Ξ-W2-T1 -(resample-bake-chain)` must have landed on `dev` before T2 dispatches. T2 amends what that -track shipped; it cannot amend a branch. - -**Two tracks, disjoint by surface — but T2's disjointness is CONDITIONAL and must be -confirmed, not assumed.** T1 owns `ui/knob_deck`, `ui/deck_groups` (row-predicate -consumption, FILTER's caption move, MASTER's inventory) and `shell/instrument/editor_paint_deck`. -T2 owns the bake's reset step wherever Ξ-W2-T1 put it. **T2's first act is to read what -actually shipped and confirm its reset surface touches none of T1's three modules.** If the -shipped reset enumerates controls through `deck_groups` or `deck_values`, the two are not -disjoint and **T2 serializes behind T1 inside the wave** — a named contingency, taken openly, -not discovered at merge. That risk is real precisely because this plan cannot predict the -shipped shape; predicting it is what put the phase in this position. - -**Why T1 is one track.** The row law, the group inventory and the double-height deck are one -geometry decision spread over `knob_deck`, `deck_groups` and the deck painter. Splitting it -would put two tracks in the same pure modules. **The window floor is no longer part of it** — -W1-T4 set it two waves ago, and this track must not move it. - -**Neither track takes a payload rung.** T1 is layout only; T2 changes a reset list, not a -format. +**None of the three tracks takes a payload rung.** T1 was layout only; T2 changed a reset list, +not a format; T3 changes what the render's audio contains, not what is stored. #### Γ-W3-T1 — `deck-reflow` -**Goal.** Two categorical rows plus a double-height MASTER bus deck, inside a 1280 × 720 -ceiling, returning 112 px to the waveform. +**Landed** — see `docs/COMPLETED.md` for the full narrative. The knob deck's row law stops +being a wrap outcome and becomes a property of the group descriptor: two categorical rows +(Sound — PITCH/RATE, FILTER, VELOCITY, VOICE; Contour — PITCH ENV, FILTER ENV, AMP ENVELOPE) +plus a double-height, right-anchored MASTER bus deck outside both, carrying the limiter enable +toggle, one reserved cell, the output meter column and a passive gain-reduction lamp. +`DeckRow`/`deckRowFor` (`ui/deck_groups`) is an exhaustive switch over every `DeckGroupId`, so a +group added later without a row assignment is a compile error; the greedy whole-group wrap this +replaces is gone entirely. FILTER's `Band|Notch` moves into its caption's previously-unused +second toggle slot, taking the group from 524 to 432 px; VOICE deliberately keeps its +`Retrig|Legato` row toggle, since moving it to the caption would make VOICE wider, not narrower. -**Spec:** `docs/product/instrument-control-surface.md` §1 (the whole section, incl. the §1.2 -measured table **and §1.6, the headroom ledger**) and §3.2–3.3 (what MASTER draws). **§7 lists -the invariants this track invalidates or widens — read it before touching `knob_deck.h`.** +**The row block widened 1020 → 1028 px and the editor floor moved 1190 → 1198 px (Daniel's +ruling, 2026-08-02).** The originally specified 1020 could not simultaneously deliver the +filter tie-line (both rows' FILTER/FILTER ENV right edges at one x) and equal, +no-narrower-than-12px gutters on both rows; at 1028 all three hold, with 82 px of headroom left +against the 1280 px ceiling. -**Surface boundary — owns:** `core/instrument/ui/knob_deck` (the row law, the double-height -group, the justification — **consuming** W1-T4's budget constants, not restating them), -`core/instrument/ui/deck_groups` (consumption of W1-T4's row predicate, FILTER's `Band|Notch` -caption move, MASTER's inventory), and `shell/instrument/editor_paint_deck` (the MASTER -meter/limiter/bubble draw). **Does not own** `kEditorMinWidth` or any budget constant — those -are W1-T4's and are **read**, never moved — nor any parameter, the limiter DSP, or the -waveform band. +The MASTER meter's per-block state moved from a plain overwriting store to an accumulated one — +at 48 kHz/512-frame blocks roughly 47 blocks elapse between two 500 ms UI ticks, and the +overwriting store had been displaying one block in ~47 and dropping the rest. The processor now +folds a per-channel peak max and a limiter min-gain across the whole interval, drained by +`masterBusMeter()`. **The instrument reload was decoupled from VST3 activation as part of this +track** — `setActive(false)` now parks the decoded `SampleData` and destroys only the voice +state, `setActive(true)` rebuilds the voices around the parked sample — discharging the +`docs/TODO.md` follow-up already recorded in full there. The limiter toggle's commit is split so +the audible state commits inline on the click and only the host's +`restartComponent(kLatencyChanged)` notification is deferred to the editor's existing 500 ms +sync tick. -**Behavior.** -- **Row 1 (sound), one row, non-negotiable:** PITCH/RATE 192 · FILTER 432 · VELOCITY 192 · - VOICE 164 = **980** natural. -- **Row 2 (contour):** PITCH ENV 252 · FILTER ENV 312 · AMP ENVELOPE 312 = **876** natural. -- **MASTER is double-height (216 px) and right-anchored**, outside both rows, 142 px wide. -- **FILTER's `Band|Notch` moves from its row-toggle position to the caption corner**, taking - the group 524 → **432** (−92 px). It occupies FILTER's currently-unused `captionToggle2` - slot — **no new geometry is required**. -- **VOICE keeps its `Retrig|Legato` row toggle.** Moving it to the caption makes VOICE - *wider* (226, not narrower), because its caption row is the binding side. Verified; do not - "fix" it. -- **Justification law, applied to BOTH rows:** space-between within the row block; slack - divided equally among the row's (n−1) gutters, integer residue to the leftmost; - **no gutter narrower than `kDeckGroupGap` (12)**. **Decks are never stretched.** MASTER is - not part of either row's justification. -- **Row block = 1028 px at the floor** (widened from the originally specified 1020 — Daniel, - 2026-08-02), giving row 1 gutters 16/16/16 and row 2 gutters 76/76, at which width - **FILTER's right edge and FILTER ENV's right edge both land on x = 640**. The tie-line and - both rows' equal gutters hold at 1028 because each row's slack divides by its gutter count - with no residue. **This is why the floor is 1198 and not 1190.** The originally specified - 1020 delivered NEITHER the tie-line (638 vs 636) nor the claimed exactly-`kDeckGroupGap` - smallest gutter (13); the three properties were never simultaneously satisfiable, and 12 is - a floor rather than a target — spec §1.3 records all three deviations. Above the floor the - tie-line drifts and that is accepted (spec §1.3). -- **The bands are already 216 / 358 and the floor was already 1190 × 680** — Γ-W1-T4 landed all - four in wave 1, and the greedy wrap happened to reach two rows at that width. **This track - changes only the row block and the floor (see above); the rest it makes true by construction rather than by coincidence.** - Row 1's natural width fits the block **only after this track's `Band|Notch` move**: 1030 - today, +42 from W2-T1's PITCH/RATE, −92 here, = **980**. That is this track's fit assertion - and W1-T4 deliberately left it open. -- **The 82 px of remaining headroom is the budget for the life of this layout**, and one deck - cell is 60 px. **This is why MASTER's reserved slot is ONE cell** (Γ-F5, ruled): two would - spend 60 of the 82 up front on a control nobody has named, leaving 22 — which would freeze - row 1 forever, since any later row-1 addition needs 60. Widening MASTER later costs the same - 60 it would cost now, and by then the trade is against a real control instead of a guess. - **State this ledger where a future reader will hit it** — spec §1.6 is its home, and a - reader proposing a new knob needs to see it before they propose. -- **MASTER's interior** (spec §1.4, exact to the pixel): caption row with the limiter toggle - and a **round** 12 px `warn` GR bubble in the far corner (non-interactive — the same slot the - envelope decks' radio uses; round so it reads as a lamp, not a control); **gain knob in the - upper-left cell at box-relative y = 26** and a **reserved empty slot at y = 138** — i.e. the - two cells land on row 1's and row 2's knob baselines exactly, which is what stitches the - spanning deck to both rows; **meter column 62 px wide × 186 px tall** on the right. -- **Three rules not to generalise wrongly:** MASTER's left column uses **fixed cell slots at - the two baselines, NOT the horizontal run-division law** (that law would stretch one knob - over 186 px); the reserved slot **draws nothing** (blank reads as breathing room, a dashed - placeholder reads as unfinished); the meter is **one rect spanning both baselines**, not two - per-row meters. -- **The meter draws W1-T2's published state**, with the ballistics run on the UI timer. - **Bar count follows the same `LaneSplit` decision `waveformSurface` already folds** (channel - mode ∧ source channel count) — one wide bar when the waveform draws one lane, two skinnier - bars when it draws two. Not a second rule: a mono source in stereo mode is dual-mono, and - two identical bars would be a lie. -- **Meter appearance:** bar in `accent/primary`; peak-hold tick 2 px in `text/primary`; clip - cap in `warn`, latched, click-to-clear; scale linear in dB over −60…+6 with ticks every - 6 dB and numerals at 0/−12/−24/−36/−48/−60, the 0 dB tick heavier. **No green/yellow/red - segmentation** — `warn` stays reserved for clip states. - -**Acceptance criteria.** -- At the floor width the deck lays out in **exactly two rows plus the spanning MASTER**, - **by construction** — asserted against the group inventory, not observed as a wrap outcome. -- Every group's width matches the §1.2 table exactly, **in both Gate and Trigger** (row 2's - natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at - 312 in both modes — assert it). -- Row 1 and row 2 are **flush left and flush right**; at the floor width the filter tie-line - is exact (both edges at x = 640) and BOTH rows' gutters are equal. -- **Row 1's natural width is 980 and fits the 1028 block** — the fit Γ-W1-T4 could not yet - assert, closed here by the `Band|Notch` move. -- **`kEditorMinWidth` is 1198 and the floor is still ≤ 1280 × 720** — moved 1190 → 1198 by this - track, verified against Γ-W1-T4's derived test rather than a second copy of it. -- The waveform band is **358 px at the floor**, and the deck band is 216 — **unchanged from the - interim, now reached by construction**: `deckRowCount` at and above the floor is 2 because the - row predicate says so, not because a wrap landed there. Assert against the group inventory. -- MASTER's gain knob shares a knob baseline with FILTER's knobs; its reserved slot shares one - with AMP ENVELOPE's. -- The meter reads correctly in mono and stereo, the peak-hold tick holds 1.5 s, the clip cap - latches and clears, and the GR bubble lights only while the limiter reduces gain. -- **With the limiter engaged the clip cap never latches** on material the limiter is catching; - if it does, that is a defect report against W1-T2, not a user error. -- `knob_deck`'s and `sample_bands`' tests are updated to the new law, and the invalidated - notes in `knob_deck.h` (the fourteen-pixel headroom figure; the cells-and-floor pairing) are - **re-derived, not deleted** — spec §7.1, §7.4. - -**Open questions.** -- **[propose at review]** Whether the greedy whole-group wrap survives at all as a sub-floor - degrade, or is replaced outright by explicit row assignment. What is **not** optional: at - and above the floor width the layout is the specified arrangement, reached by construction. - `DeckLayout::rowCount`/`::height` change meaning either way (spec §7.3). -- **No [Daniel] questions.** Forks Γ-F5 (**one cell**) and Γ-F1 (**680 stays**) are both - ruled; they are stated in Behavior above, not carried here as options. -- **[verify]** `deck_groups.cpp`'s `kEnvModeSegW = 23` ceiling rises to **47** once PITCH ENV - is on row 2 (AMP binds at 55). No change is required; the comment stating the old ceiling - stops being true and must be corrected (spec §7.2). +**Not verified in a running DAW — CTest-asserted only:** the meter at its 500 ms UI cadence, the +GR lamp under real limiter action, the limiter toggle's latency renegotiation, the clip cap's +click-to-clear, and the recapture-while-editor-closed path. #### Γ-W3-T2 — `bake-reset-amendment` -**Goal.** Complete the resample bake's reset list against the control surface that now -exists — the correction Phase Γ owes Phase Ξ because Ξ-W2-T1 shipped ahead of the sequencing -this plan asserted. +**Landed** — see `docs/COMPLETED.md` for the full narrative. The correction Phase Γ owed Phase +Ξ: Ξ-W2-T1's bake shipped ahead of the sequencing this plan asserted, so its reset list predated +rate, pitch offset, the limiter enable, and the loop enable. **`resetAfterBake` needed no code +change** — all four already reset by construction, since the function defaults everything and +copies back only survivors, and none of the four was ever named a survivor. The track shipped +field-by-field assertions over two independently-dialled fixtures, never struct equality, plus a +mutation-verified spot-check sweep confirming both fixtures actually moved every asserted field +off its default. -**Consolidates:** nothing from the seventeen. It is a **correction obligation**, not a -feature (see "Flagged for awareness" item 2). +**One invariant correction:** `bake/CLAUDE.md` had claimed the whole signal chain prints, master +gain included. It doesn't — the render's gain multiply is the only master-stage value it +prints; the limiter runs in the processor's block, off the bake path entirely. -**Spec:** `docs/product/instrument-control-surface.md` §3.4, and Ξ-W2-T1's own "Reset scope" -block above — **which is the ratified rule this track applies, not a rule it may reinterpret.** +**Outstanding, not closed by this track.** A capture baked with the limiter engaged comes back +unlimited — a real audible gap, and Daniel has ruled that a future track will change the bake to +print the limiter. **That track is now Γ-W3-T3, below.** -**Surface boundary — owns:** the bake's parameter-reset step, wherever Ξ-W2-T1 landed it, and -its tests. **Does not own** the bake chain, the crossing architecture, the replace-vs-add -decision, the capture path, any deck module, any painter, any parameter, or any -`ComponentState` version. **It changes what a shipped list contains — nothing else.** +**Neither track has been verified in a running DAW; both are asserted in CTest only.** -**Behavior.** -- **Three values join the reset list**, all classified against Ξ-W2's own ratified rule - ("reset what the bake baked in"), all **reset**, none of them a new Daniel decision: - **rate**, **pitch offset**, and **limiter enabled**. The limiter's reasoning is worth - carrying rather than re-deriving: master gain is already on the reset list, so the bake - includes the master stage, so the limiter's effect is in the audio. -- **Verified against what shipped, not against what was predicted.** This plan named three - values before either the bake or the controls existed. **Read Ξ-W2-T1's landed reset list - first** and reconcile: if it already anticipated any of the three, say so and drop it; if - it classified something differently from `docs/product/instrument-control-surface.md` §3.4, - **the landed code is the fact and this plan is the prediction** — escalate the difference, - do not silently overwrite either. -- **Re-run Ξ-W2-T1's own "genuinely new parameter" check over everything Phase Γ added**, not - just the three named. Γ also ships the loop enable (W2-T2) and raises the stage-time - ceiling (W1-T1). Classify each **against the rule**: the loop enable is a loop fact whose - effect is in the rendered audio (**reset**, with the loop points it travels with); the - ceiling is not a parameter at all. State each disposition; silence is not one. -- **Root note still survives.** The bake's most load-bearing exception is untouched by this - track — capturing at root is what makes root survivable, and resetting it would detune - every subsequent iteration. +#### Γ-W3-T3 — `bake-prints-limiter` -**Acceptance criteria.** -- After a bake, **rate reads 100 %, pitch offset 0 st, and the limiter reads bypassed** — and - the root note, key-tracking and the VOICE group are still untouched. -- **The bake stays audible and faithful with the new controls dialled in**: dial rate, pitch - offset and the limiter, bake, and the neutral instrument playing the programmed note sounds - as the dialled one did — the criterion Ξ-W2-T1 already carries, now actually exercised over - Γ's controls. -- **A reconciliation note in the track's review** stating, per value, whether the landed code - already covered it, and recording any difference between what shipped and what §3.4 - predicted. -- **No format change, no new field, no version bump, no change to the crossing architecture - or the replace-vs-add decision.** A regression baseline proves the bake's audio is - otherwise unchanged. +**Not started. Opened by Daniel's ruling, 2026-08-02.** -**Open questions.** -- **No [Daniel] questions.** The rule is ratified and §3.4's classification is derived from - it. -- **[verify, FIRST]** the disjointness contingency in the wave header: read the shipped reset - step and confirm it touches none of Γ-W3-T1's modules. If it does, serialize behind T1 and - say so. -- **Explicitly NOT this track's:** the two consequences automation adds to the bake — the - reset having to notify the host, and a host lane re-imposing its curve onto baked audio. - Both are **Γ-W4-T1's**, because that track creates them. Doing this correction once, before - automation, and letting Γ-W4-T1 add its own obligation on top is deliberate: the - alternative is an amendment to an amendment. +**Goal.** Print the limiter through the bake's master stage, so a capture baked with the +limiter engaged returns limited audio rather than unlimited audio. + +**Why this exists.** Γ-W3-T2's own finding disproved the premise +`docs/product/instrument-control-surface.md` §3.4's reset classification rested on: +`renderBake` (`core/instrument/bake/bake_render.cpp`) prints only a flat master-gain multiply, +and the limiter (`core/instrument/engine/limiter`) runs in the processor's `process()` block, +off the bake path entirely. Until this track lands, this is a recorded, known limitation — see +`docs/COMPLETED.md`'s Γ-W3-T2 entry — not an oversight. + +**Consolidates:** nothing from the seventeen. A correction, on the same footing as Γ-W3-T2 (see +"Work in this plan that is not one of the seventeen"). + +**Spec:** none yet written. This ruling postdates §3.4 and has no product-doc section of its +own; §3.4 is superseded on this one point, which a future scoping pass of this track should +correct there as well as here. + +**Surface boundary — likely, not yet confirmed against a full scoping pass:** owns +`core/instrument/bake/bake_render` (the gain-multiply step, extended to also run the signal +through a limiter), consuming `core/instrument/engine/limiter` — not owned, not modified. Does +not own the processor's live block, the limiter DSP itself, the parameter surface, or +`bake_reset` (the limiter-enable reset classification is already Γ-W3-T2's, landed). + +**Open questions — none of this is ruled yet, only the goal is:** +- **[propose at review]** Whether the bake instantiates its own `Limiter` — mirroring + `renderBake`'s existing bake-only `VoiceEngine`, off the audio thread, never linked into + `reaper_reasampler` — or reaches the limiter's settled behavior some other way. The + bake-only-engine precedent (`bake/CLAUDE.md`) argues for the former. +- **[propose at review]** Whether the limiter's lookahead needs any accommodation in an + offline, non-realtime render — the processor's `getLatencySamples()` PDC report exists for + the live block, and a bake is not on that clock, so this may be a non-issue; it has not been + checked. +- **[propose at review]** Whether this track also corrects `bake/CLAUDE.md`'s invariant text + ("the limiter is not [printed]") alongside the code, once scoped in full. +- **No [Daniel] question on the goal itself** — the ruling above is the goal; what is open is + the mechanism, not whether to do it. --- @@ -3520,9 +3424,9 @@ proof it exists to give. from `TODO-1.0.md`, and not a track this plan originally scoped. The second such track in this plan today; if others appear, they belong on this list rather than in the table. -- **All of Phase Γ** (`pg-*`). **Twelve tracks across four waves** (W1 seven, W2 two, W3 two, - W4 one), from a direct interview with Daniel (2026-08-01) and his four later rulings the - same day, not from `TODO-1.0.md`. Listed +- **All of Phase Γ** (`pg-*`). **Thirteen tracks across four waves** (W1 seven, W2 two, W3 + three, W4 one), from a direct interview with Daniel (2026-08-01) and his four later rulings + the same day, not from `TODO-1.0.md`. Listed here as a block rather than per track, because the whole phase is outside the source doc; the product reasoning lives in `docs/product/instrument-control-surface.md` and the parameter system's in `docs/product/parameter-automation.md` §§6–10. **Two `docs/TODO.md` @@ -3542,6 +3446,11 @@ proof it exists to give. plan's sequencing claim. If more corrections of this shape appear, they belong here rather than in the table — the table is a completeness proof over `TODO-1.0.md`, and a correction has no source row to point at. +- **Γ-W3-T3 `bake-prints-limiter` is also a CORRECTION, not a feature**, and for the same + reason as T2: it exists only because T2's own finding disproved the premise §3.4's reset + classification rested on. Unlike the twelve above, it does not come from the 2026-08-01 + interview or that day's four rulings — it is a separate ruling, one day later (2026-08-02), + opened after T2 landed and found the gap. - **All of Phase Ε** (`pe-*`). **Six tracks across three waves**, from a direct request (Daniel, 2026-08-02), not from `TODO-1.0.md`. Listed here as a block, like Γ and Ψ; the product reasoning lives in `docs/product/bank-package.md`. It **supersedes nothing** — @@ -3652,9 +3561,11 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends W2 New controls, and the overlay's marks — landed [2 tracks] T1 pitch-rate-deck ............ Rate + Pitch, Varisp/Presrv compounding [rung 2] T2 loop-crossfade-ux .......... four-mark grammar; fade painted where it is heard - W3 The reflow, and the bake correction [2 tracks] + W3 The reflow, and the bake correction [3 tracks; T1/T2 landed, T3 open] T1 deck-reflow ................ two rows + double-height MASTER, by construction - T2 bake-reset-amendment ....... the Xi correction Gamma owns [needs Xi-W2-T1 on dev] + T2 bake-reset-amendment ....... the Xi correction Gamma owns + T3 bake-prints-limiter ........ NOT STARTED — bake to print the limiter + through the master stage [Daniel, 2026-08-02] W4 VST3 parameters [1 track] T1 vst3-parameter-set ......... 44 derived params, frozen id table [Ruling 1] [rung 3 RESERVED, spent only if verify says so] @@ -3669,8 +3580,8 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends Γ-W4-T1's storage verification forces it. Shared files, named: engine/CMakeLists.txt (W1-T2 | W1-T5), ui/CMakeLists.txt (W1-T1 | W1-T3), editor_session.cpp (W2-T1 | W2-T2) — all textual adjacency, not - semantic contention. W3-T2's disjointness from W3-T1 is CONDITIONAL: confirm it against - what Xi-W2-T1 shipped, and serialize behind T1 if it does not hold. + semantic contention. W3-T2's disjointness from W3-T1, once a CONDITIONAL risk to confirm + against what Xi-W2-T1 shipped, is resolved: both tracks landed — see docs/COMPLETED.md. Phase Psi — The extension trust pass (none of the seventeen; a direct list of seven) W1 Exact bounds, disciplined switches, reachable actions, resolved drops [4 tracks] From bfaa0f26148de7b1cb473df2e40020d192b7401d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 15:14:16 -0400 Subject: [PATCH 47/56] Report the instrument's automatable parameters to the host under a frozen id table, in signal-flow order, with real units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 42 of 44 ids issued: pitch key-track and Trigger length stay reserved pending a live path. Master gain reclassified Live — it never reloaded. --- CLAUDE.md | 8 +- src/core/instrument/CLAUDE.md | 15 +- src/core/instrument/CMakeLists.txt | 2 + src/core/instrument/bake/CLAUDE.md | 10 + .../engine/filter/filter_params.cpp | 7 + .../instrument/engine/filter/filter_params.h | 5 + src/core/instrument/engine/master_gain.cpp | 12 - src/core/instrument/engine/master_gain.h | 6 - src/core/instrument/param/CLAUDE.md | 85 +++++++ src/core/instrument/param/CMakeLists.txt | 17 ++ src/core/instrument/param/param_format.cpp | 76 ++++++ src/core/instrument/param/param_format.h | 26 ++ src/core/instrument/param/param_id.cpp | 105 ++++++++ src/core/instrument/param/param_id.h | 135 ++++++++++ src/core/instrument/param/param_units.cpp | 232 ++++++++++++++++++ src/core/instrument/param/param_units.h | 64 +++++ src/core/instrument/ui/deck_groups.cpp | 6 +- src/core/instrument/ui/deck_groups.h | 5 + src/core/instrument/ui/deck_values.cpp | 7 - src/core/instrument/ui/deck_values.h | 17 +- src/shell/instrument/CLAUDE.md | 32 +++ src/shell/instrument/CMakeLists.txt | 2 + src/shell/instrument/editor_controls.cpp | 149 +++-------- src/shell/instrument/editor_input.cpp | 11 +- src/shell/instrument/editor_input_deck.cpp | 16 +- src/shell/instrument/editor_platform.cpp | 8 +- src/shell/instrument/editor_session.cpp | 6 + src/shell/instrument/instrument_params.cpp | 194 +++++++++++++++ src/shell/instrument/processor_state.cpp | 25 +- src/shell/instrument/reasampler_editor.h | 15 +- src/shell/instrument/reasampler_processor.cpp | 2 + src/shell/instrument/reasampler_processor.h | 49 ++++ tests/test_deck_groups_state.cpp | 11 +- tests/test_deck_values.cpp | 29 --- tests/test_master_gain.cpp | 13 - tests/test_param_format.cpp | 174 +++++++++++++ tests/test_param_id.cpp | 193 +++++++++++++++ tests/test_param_units.cpp | 191 ++++++++++++++ 38 files changed, 1742 insertions(+), 218 deletions(-) create mode 100644 src/core/instrument/param/CLAUDE.md create mode 100644 src/core/instrument/param/CMakeLists.txt create mode 100644 src/core/instrument/param/param_format.cpp create mode 100644 src/core/instrument/param/param_format.h create mode 100644 src/core/instrument/param/param_id.cpp create mode 100644 src/core/instrument/param/param_id.h create mode 100644 src/core/instrument/param/param_units.cpp create mode 100644 src/core/instrument/param/param_units.h create mode 100644 src/shell/instrument/instrument_params.cpp create mode 100644 tests/test_param_format.cpp create mode 100644 tests/test_param_id.cpp create mode 100644 tests/test_param_units.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 0529fc8..debab73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. -Per-module detail — what each file owns, its invariants — lives in the twenty-three per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below. +Per-module detail — what each file owns, its invariants — lives in the twenty-four per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below. ## Settled decisions @@ -84,7 +84,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on ## Architecture: the load-bearing split -`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-three directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth. +`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-four directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth. | Directory | Scope | |---|---| @@ -95,6 +95,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on | `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset | | `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages | | `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets | +| `src/core/instrument/param/` | the VST3 parameter surface's pure half — the FOREVER-FROZEN id table, the exposed set derived from the commit predicate, the plain-value layer, and the one formatter per unit category | | `src/core/json/` | the hand-rolled JSON lexical layer | | `src/core/model/` | the pure bank/sample index and its multi-bank container | | `src/core/reclaim/` | pure prune orphan computation | @@ -117,7 +118,8 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on The top-level split is by the pure/shell discipline: `core/` never includes REAPER or VST3 SDK types; `shell/` is where those host types are actually touched — the discriminator is "may this file touch a host type, REAPER *or* VST3 SDK." Subsystem directories sit beneath `core/` (see the -table above); `core/instrument/` further subdivides into `engine/` / `map/` / `note/` / `ui/`. Namespaces +table above); `core/instrument/` further subdivides into `bake/` / `engine/` / `map/` / `note/` / +`param/` / `ui/`. Namespaces mirror directories — `reasampler::` for `core/`, house style for `shell/`. `app/` holds `main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch. diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index e81f5ea..64f35ef 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -17,6 +17,11 @@ subdirectories: - **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame window, the offline render over a voice engine built for that render alone, and the ratified post-bake reset. See `bake/CLAUDE.md`. +- **`param/`** — what the instrument tells a VST3 host about its automatable parameters, + with no VST3 type in it: the FOREVER-FROZEN id table, the exposed set derived from + `deckParamCommit`, the plain-value layer, and the one formatter per unit category. Sits + ABOVE `ui/` — the list is a function of the commit predicate, never the reverse. See + `param/CLAUDE.md`. - **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band interiors, waveform, keyboard strip, capture browser, param controls, envelope overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is @@ -309,7 +314,7 @@ anything for a trigger shape. never buy itself a higher lowest-findable fundamental. - `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. -- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. +- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob, the processor multiply and the host's `toPlain` so the needle, persisted value, audio multiply and reported dB cannot drift. Math only — the dB label is `param/param_format`'s, so the editor and the host cannot print it two ways. - `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle. - `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`. @@ -349,10 +354,10 @@ anything for a trigger shape. value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be "simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit table, and where each control's full scale enters — a whole DISPLAYED percent is a different - norm step at 0..100 %, 0..200 % and ±100 %), and `formatEnvTimeMs`, the - ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so - two stage times are comparable at a glance. A display-unit decision only — nothing about the - stored representation changes. Links the header-only `play_seconds`, deliberately not + norm step at 0..100 %, 0..200 % and ±100 %). Display FORMATTING is not here — `param/`'s + `param_format` owns the one formatter per unit category, because the host and the editor must + be its two callers and neither may hold a second implementation. Links the header-only + `play_seconds`, deliberately not `sample_map`: `PlaySeconds` is the whole of what a deck edits, and linking the mapping would drag the bank model and the WAV codec in behind it. The shell keeps only the controls the parameter set does not carry (key-track, voice count, master gain, preview velocity) and the diff --git a/src/core/instrument/CMakeLists.txt b/src/core/instrument/CMakeLists.txt index 70081c3..3077cf8 100644 --- a/src/core/instrument/CMakeLists.txt +++ b/src/core/instrument/CMakeLists.txt @@ -2,6 +2,8 @@ add_subdirectory(engine) add_subdirectory(map) add_subdirectory(note) add_subdirectory(ui) +# After ui: the VST3 parameter identity reads the deck's commit predicate and its value binding. +add_subdirectory(param) # Last: bake composes the three above it. add_subdirectory(bake) diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 4656b90..13912a1 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -54,6 +54,16 @@ decision about what the render made obsolete. - **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list candidate at all** — it bounds a knob's taper, is never itself a dialed value, and so has no disposition to classify against the ratified reset rule. +- **A host automation lane outranks the reset, and the bake cannot clear it — a NAMED + limitation, not a bug.** Every reset-class value that is also an exposed VST3 parameter is + now notified to the host (the reset writes through `setInstrumentParams`, which is the one + notification funnel), so the host's DISPLAY follows the reset. A lane, however, lives in the + host's project data: if a reset-class parameter carries one, the host replays its curve onto + audio that already has that processing baked in — double processing, and the "sounds as the + dialled instrument sounded just before the click" claim does not hold in that case. There is + no detection available: `IAutomationState` reports the host's automation mode for the whole + plug-in, not per parameter, so both "refuse the bake" and "reset only the un-automated ones" + are unbuildable rather than merely unattractive. The user's remedy is to remove the lane. ## Modules diff --git a/src/core/instrument/engine/filter/filter_params.cpp b/src/core/instrument/engine/filter/filter_params.cpp index d22e240..4aeb4f0 100644 --- a/src/core/instrument/engine/filter/filter_params.cpp +++ b/src/core/instrument/engine/filter/filter_params.cpp @@ -52,6 +52,13 @@ float filterDriveDepthFromNorm(float norm) { return static_cast(kFilterDriveDepthMax * n * n); } +float filterNormFromDriveDepth(float depth) { + if (!(depth > 0.0f)) return 0.0f; // also catches NaN + if (depth >= kFilterDriveDepthMax) return 1.0f; + return static_cast( + std::sqrt(static_cast(depth) / static_cast(kFilterDriveDepthMax))); +} + float filterNormFromQ(float q) { if (!(q > kFilterQMin)) return 0.0f; if (q >= kFilterQMax) return 1.0f; diff --git a/src/core/instrument/engine/filter/filter_params.h b/src/core/instrument/engine/filter/filter_params.h index 94485f8..8a3fcc7 100644 --- a/src/core/instrument/engine/filter/filter_params.h +++ b/src/core/instrument/engine/filter/filter_params.h @@ -49,4 +49,9 @@ float filterNormFromQ(float q); // linear rather than merely close. float filterDriveDepthFromNorm(float norm); +// Exact inverse of filterDriveDepthFromNorm; out-of-range depth clamps to 0 or 1. The analytic +// inverse of a frozen law is not a change to it — it has the standing the two inverses above +// already have. +float filterNormFromDriveDepth(float depth); + } // namespace reasampler::instrument::engine::filter diff --git a/src/core/instrument/engine/master_gain.cpp b/src/core/instrument/engine/master_gain.cpp index 2d644df..b48ba58 100644 --- a/src/core/instrument/engine/master_gain.cpp +++ b/src/core/instrument/engine/master_gain.cpp @@ -6,7 +6,6 @@ #include #include -#include #include namespace reasampler::instrument::engine { @@ -37,15 +36,4 @@ double masterGainNormFromLinear(double linear) { return masterGainNormFromDb(20.0 * std::log10(linear)); } -void formatMasterGainLabel(double norm, char* buf, std::size_t len) { - if (!buf || len == 0) return; - norm = clamp01(norm); - if (norm <= 0.0) { - std::snprintf(buf, len, "-inf"); - return; - } - const double db = masterGainDbFromNorm(norm); - std::snprintf(buf, len, "%+.1fdB", db); -} - } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/master_gain.h b/src/core/instrument/engine/master_gain.h index 93b7ea1..f34f351 100644 --- a/src/core/instrument/engine/master_gain.h +++ b/src/core/instrument/engine/master_gain.h @@ -7,8 +7,6 @@ #pragma once -#include - namespace reasampler::instrument::engine { // norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb @@ -31,8 +29,4 @@ double masterGainLinearFromNorm(double norm); // true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps. double masterGainNormFromLinear(double linear); -// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB"). -// Writes at most `len` bytes including the terminator. -void formatMasterGainLabel(double norm, char* buf, std::size_t len); - } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/param/CLAUDE.md b/src/core/instrument/param/CLAUDE.md new file mode 100644 index 0000000..c65fe4b --- /dev/null +++ b/src/core/instrument/param/CLAUDE.md @@ -0,0 +1,85 @@ +# src/core/instrument/param — the VST3 parameter surface's pure half + +## Scope + +What the instrument tells a VST3 host about its automatable parameters, with no VST3 type +anywhere: the frozen id table, the exposed set derived from the deck's commit predicate, the +plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), and the one +formatter per unit category. The VST3 shell (`shell/instrument/instrument_params`) adapts +these onto `Steinberg::Vst::Parameter`; it decides nothing. + +A sixth peer of `engine/` / `map/` / `note/` / `bake/` / `ui/`, and it sits ABOVE `ui/`: the +parameter list is a function of `deckParamCommit` and the value binding, never the reverse. + +## Invariants + +### The id table is FOREVER-FROZEN + +`param_id.h`'s header states the rule in full and is its one home. It sits on the same footing +as the extension's `"STABLE_FOREVER_STRING"` command ids, the two VST3 class UIDs +(`core/wire/reasampler_uid.h`) and the params-payload field order +(`map/component_state_io.h`) — the fourth member of that family, not a new kind of rule. + +**The table carries every assigned number, including numbers not issued today.** Membership of +the parameter list is `isExposed`'s answer, not the table's. A row whose control is currently +`Reload`-tier keeps its number reserved: the day that control gains a live path it is exposed +under the number already written beside it, and no other id moves. That is what the +block-and-step scheme buys, and it is why a refusal to promote a control is cheap. + +### The list follows the predicate; the predicate is never bent to fill the list + +A control is an exposed parameter **iff** `deckParamCommit` classifies it `Live` or +`NoteOnLatched`. There is no second membership table and no per-control exception. Adding a +parameter means giving a control a live path in `deck_groups`, at which point it qualifies by +the same rule that excluded it. + +### `toPlain` is a READ-side mapping and changes no stored value + +Reporting Hz / Q / drive depth for the filter's four means **calling** `filter_params`' frozen +laws, never replacing them: those four persist as normalized doubles in payload v9, so their +laws are already wire-frozen. The same holds for `master_gain`'s dB sweep and `curve_law`'s +exponent travel. Every law here is called; none is restated. + +### ONE formatter per unit category, two callers + +`param_format` returns the DIGITS of a plain value. The editor's knob label renders those digits +plus its own static chrome (the unit suffix, a curve dial's `^`); the host receives the same +digits from `getParamStringByValue` and the unit string from `ParameterInfo::units`. There is no +second implementation on either side — that is why `formatEnvTimeMs` and `formatMasterGainLabel` +no longer exist. + +## Modules + +- `param_id` — the frozen `ParamId` constants, the `ParamRow` table (id, `DeckParam`, `IUnitInfo` + unit, title, shortTitle) in ascending id, `isExposed`, and the derived `exposedParams()`. + Ascending id IS the presentation order, so identity order and presentation order agree by + construction rather than by maintenance. +- `param_units` — `UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized` + pair, and the defaults read off a default-constructed `PlaySeconds`. +- `param_format` — the eight formatters and the digits parser behind `getParamValueByString`. + +## Gotchas + +- **`defaultNormalized` is COMPUTED, never a literal.** It is `toNormalized(defaultPlain)` for + every tapered control, so a host's reset-to-default and the editor's double-click land on the + same value. The filter's four are the one exception and for the opposite reason: their stored + value already IS the normalized one, so their default normalized value is that double verbatim + and no taper participates in the reset path at all. +- **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.** + No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule + out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere. +- **A curve exponent inside the knob detent but not exactly neutral reads `1.00` to the host.** + The detent lives in `curve_law`'s norm↔exponent map and the host's only handle is the norm, so + the host cannot see an off-detent near-neutral exponent — reachable only by an overlay knot + drag, which writes through `curveFromLevelAt` rather than the knob law. The editor's own label + deliberately reads the stored field directly and still shows the true value; that divergence is + structural to VST3, not a formatter defect. +- **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared −60…+24 range + on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. +- **The filter's four store their position as a `float`, so a not-yet-stored norm can display + one digit differently.** A host previewing a value it has sent but that has not round-tripped + through the stored float differs from the editor by up to a float ulp; at a value landing + exactly on a display rounding boundary that is worth one integer percent on morph. Both + surfaces read the MODEL in every settled state, so this is a transient of the write itself, + not a standing divergence — `test_param_format` holds those four to the plain value rather + than to the string for exactly this reason. diff --git a/src/core/instrument/param/CMakeLists.txt b/src/core/instrument/param/CMakeLists.txt new file mode 100644 index 0000000..97c95eb --- /dev/null +++ b/src/core/instrument/param/CMakeLists.txt @@ -0,0 +1,17 @@ +# The frozen id table and the derived exposed set. Links deck_groups alone: the exposed set IS +# deckParamCommit's answer, and identity needs nothing else. +reasampler_pure_library(param_id SOURCES param_id.cpp LINK PUBLIC deck_groups) +reasampler_test(param_id LINK param_id) + +# The norm <-> plain layer. deck_values carries the tapers' full scales and the two field +# resolvers the defaults are read through; filter_params and master_gain are the frozen laws the +# filter's four and the gain report through, CALLED rather than restated. +reasampler_pure_library(param_units + SOURCES param_units.cpp + LINK PUBLIC deck_values param_taper curve_law master_gain filter) +reasampler_test(param_units LINK param_units param_id) + +reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units) +# param_id is linked for the test only: the one-formatter-two-consumers assertion sweeps the +# exposed set, which is identity's answer rather than this module's. +reasampler_test(param_format LINK param_format param_id) diff --git a/src/core/instrument/param/param_format.cpp b/src/core/instrument/param/param_format.cpp new file mode 100644 index 0000000..43a68fe --- /dev/null +++ b/src/core/instrument/param/param_format.cpp @@ -0,0 +1,76 @@ +// param_format.cpp — see param_format.h. + +#include "core/instrument/param/param_format.h" + +#include +#include +#include +#include + +namespace reasampler::instrument::param { + +void formatPlain(UnitKind kind, double plain, char* buf, std::size_t len) { + if (!buf || len == 0) return; + switch (kind) { + case UnitKind::Time: + // Never switches to seconds, so the ceiling reads 10000 and not 10 — units is one + // static string per parameter and cannot change with magnitude. Sub-10 ms keeps a + // decimal so a short attack is not rounded to a bare "0". + std::snprintf(buf, len, plain < 10.0 ? "%.1f" : "%.0f", plain); + return; + case UnitKind::Semitones: + std::snprintf(buf, len, "%+.1f", plain); + return; + case UnitKind::PercentUnipolar: + case UnitKind::PercentKeyTrack: + std::snprintf(buf, len, "%.0f", plain); + return; + case UnitKind::PercentBipolar: + std::snprintf(buf, len, "%+.0f", plain); + return; + case UnitKind::PercentRate: + // One decimal, not integer percent: the snap grid is whole semitones and those do + // not land on integer percent (+1 st = 105.946 %), so an integer display would print + // a snapped position as a value the snap cannot produce. + std::snprintf(buf, len, "%.1f", plain); + return; + case UnitKind::Decibels: + if (!std::isfinite(plain)) { std::snprintf(buf, len, "-inf"); return; } + std::snprintf(buf, len, "%+.1f", plain); + return; + case UnitKind::Hertz: + // The "k" abbreviation is RETIRED: units is one static string per parameter, so a + // magnitude-switching unit is not expressible, and keeping "12.8k" in the editor + // alone would be exactly the host/editor divergence one formatter exists to forbid. + std::snprintf(buf, len, "%.0f", plain); + return; + case UnitKind::Dimensionless: + std::snprintf(buf, len, "%.2f", plain); + return; + } + buf[0] = '\0'; +} + +void formatPlainFor(DeckParam deck, double plain, char* buf, std::size_t len) { + formatPlain(unitKindFor(deck), plain, buf, len); +} + +bool parsePlain(UnitKind kind, const char* text, double& plain) { + if (!text) return false; + if (kind == UnitKind::Decibels) { + // The one non-numeric string any formatter emits, so the one the parser must recognise. + for (const char* p = text; *p; ++p) { + if (*p == 'i' && p[1] == 'n' && p[2] == 'f') { + plain = -std::numeric_limits::infinity(); + return true; + } + } + } + char* end = nullptr; + const double value = std::strtod(text, &end); + if (end == text) return false; + plain = value; + return true; +} + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_format.h b/src/core/instrument/param/param_format.h new file mode 100644 index 0000000..49814df --- /dev/null +++ b/src/core/instrument/param/param_format.h @@ -0,0 +1,26 @@ +// param_format.h — ONE formatter per unit category, and the editor and the host are both its +// callers. It returns the DIGITS of a plain value: no embedded unit, no magnitude-switched unit, +// no width-conditional abbreviation. The editor's knob label adds its own static chrome (the +// unit suffix, a curve dial's "^"); the host receives these digits from getParamStringByValue and +// the unit from ParameterInfo::units. There is no second implementation on either side — the two +// surfaces disagreeing about what a value reads as is a defect class this closes structurally. + +#pragma once + +#include + +#include "core/instrument/param/param_units.h" + +namespace reasampler::instrument::param { + +// Writes at most `len` bytes including the terminator. +void formatPlain(UnitKind kind, double plain, char* buf, std::size_t len); + +// The digits a control reads as at `plain`. Convenience over formatPlain for the common case. +void formatPlainFor(DeckParam deck, double plain, char* buf, std::size_t len); + +// Digits -> plain, for getParamValueByString. False when the text carries no number; a trailing +// unit suffix is tolerated, since a user retyping a displayed value keeps it. +bool parsePlain(UnitKind kind, const char* text, double& plain); + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_id.cpp b/src/core/instrument/param/param_id.cpp new file mode 100644 index 0000000..7525592 --- /dev/null +++ b/src/core/instrument/param/param_id.cpp @@ -0,0 +1,105 @@ +// param_id.cpp — see param_id.h. The table is written out rather than derived: a derived id is +// a function of something else, and every such input then has to never change. A literal table +// makes the freeze visible AT THE POINT OF CHANGE — it cannot be renumbered by accident, because +// renumbering it means editing the numbers. + +#include "core/instrument/param/param_id.h" + +namespace reasampler::instrument::param { + +namespace { + +// Within a block the order is the group's own semantic order — envelope stages in temporal +// order, filter cells in solve order — seeded ONCE here and never re-seeded from cellIds. A +// group's cell order is exactly as mobile as the deck's row order is; this sequence is a +// property of THIS table, which is what lets DeckParam keep its "runtime-only, free to change" +// licence. +const std::vector& table() { + static const std::vector kTable = { + {kParamKeyTrackPitch, DeckParam::kKeyTrack, kUnitPitch, "Key Track", "KeyTrk"}, + {kParamRate, DeckParam::kRate, kUnitPitch, "Playback Rate", "Rate"}, + {kParamPitchOffset, DeckParam::kPitch, kUnitPitch, "Pitch Offset", "Pitch"}, + + {kParamPitchEnvAttack, DeckParam::kPitchEnvAttack, kUnitPitchEnv, "Pitch Env Attack", "PEnvA"}, + {kParamPitchEnvAttackCurve, DeckParam::kPitchEnvAttackCurve, kUnitPitchEnv, "Pitch Env Attack Curve", "PEnvAC"}, + {kParamPitchEnvHold, DeckParam::kPitchEnvHold, kUnitPitchEnv, "Pitch Env Hold", "PEnvH"}, + {kParamPitchEnvDecay, DeckParam::kPitchEnvDecay, kUnitPitchEnv, "Pitch Env Decay", "PEnvD"}, + {kParamPitchEnvDecayCurve, DeckParam::kPitchEnvDecayCurve, kUnitPitchEnv, "Pitch Env Decay Curve", "PEnvDC"}, + {kParamPitchEnvDepth, DeckParam::kPitchEnvDepth, kUnitPitchEnv, "Pitch Env Depth", "PEnvDp"}, + + {kParamFilterMorph, DeckParam::kFilterMorph, kUnitFilter, "Filter Morph", "Morph"}, + {kParamFilterCutoff, DeckParam::kFilterCutoff, kUnitFilter, "Filter Cutoff", "Cutoff"}, + {kParamFilterQ, DeckParam::kFilterQ, kUnitFilter, "Filter Q", "Q"}, + {kParamFilterDrive, DeckParam::kFilterDrive, kUnitFilter, "Filter Drive", "Drive"}, + {kParamFilterModAmount, DeckParam::kFilterModAmt, kUnitFilter, "Filter Env Amount", "FEnvAmt"}, + {kParamFilterVelAmount, DeckParam::kFilterVel, kUnitFilter, "Filter Vel Amount", "FVelAmt"}, + {kParamKeyTrackFilter, DeckParam::kFilterKeyTrack, kUnitFilter, "Filter Key Track", "FKeyTrk"}, + + {kParamFilterEnvAttack, DeckParam::kFilterEnvAttack, kUnitFilterEnv, "Filter Env Attack", "FEnvA"}, + {kParamFilterEnvAttackCurve, DeckParam::kFilterEnvAttackCurve, kUnitFilterEnv, "Filter Env Attack Curve", "FEnvAC"}, + {kParamFilterEnvHold, DeckParam::kFilterEnvHold, kUnitFilterEnv, "Filter Env Hold", "FEnvH"}, + {kParamFilterEnvDecay, DeckParam::kFilterEnvDecay, kUnitFilterEnv, "Filter Env Decay", "FEnvD"}, + {kParamFilterEnvDecayCurve, DeckParam::kFilterEnvDecayCurve, kUnitFilterEnv, "Filter Env Decay Curve", "FEnvDC"}, + {kParamFilterEnvSustain, DeckParam::kFilterEnvSustain, kUnitFilterEnv, "Filter Env Sustain", "FEnvS"}, + {kParamFilterEnvRelease, DeckParam::kFilterEnvRelease, kUnitFilterEnv, "Filter Env Release", "FEnvR"}, + {kParamFilterEnvReleaseCurve, DeckParam::kFilterEnvReleaseCurve, kUnitFilterEnv, "Filter Env Release Curve", "FEnvRC"}, + {kParamFilterTrigAttack, DeckParam::kFilterTrigAttack, kUnitFilterEnv, "Filter Trig Attack", "FTrgA"}, + {kParamFilterTrigAttackCurve, DeckParam::kFilterTrigAttackCurve, kUnitFilterEnv, "Filter Trig Attack Curve", "FTrgAC"}, + {kParamFilterTrigHold, DeckParam::kFilterTrigHold, kUnitFilterEnv, "Filter Trig Hold", "FTrgH"}, + {kParamFilterTrigDecay, DeckParam::kFilterTrigDecay, kUnitFilterEnv, "Filter Trig Decay", "FTrgD"}, + {kParamFilterTrigDecayCurve, DeckParam::kFilterTrigDecayCurve, kUnitFilterEnv, "Filter Trig Decay Curve", "FTrgDC"}, + + {kParamAmpAttack, DeckParam::kAttack, kUnitAmp, "Amp Attack", "AmpA"}, + {kParamAmpAttackCurve, DeckParam::kAttackCurve, kUnitAmp, "Amp Attack Curve", "AmpAC"}, + {kParamAmpHold, DeckParam::kHold, kUnitAmp, "Amp Hold", "AmpH"}, + {kParamAmpDecay, DeckParam::kDecay, kUnitAmp, "Amp Decay", "AmpD"}, + {kParamAmpDecayCurve, DeckParam::kDecayCurve, kUnitAmp, "Amp Decay Curve", "AmpDC"}, + {kParamAmpSustain, DeckParam::kSustain, kUnitAmp, "Amp Sustain", "AmpS"}, + {kParamAmpRelease, DeckParam::kRelease, kUnitAmp, "Amp Release", "AmpR"}, + {kParamAmpReleaseCurve, DeckParam::kReleaseCurve, kUnitAmp, "Amp Release Curve", "AmpRC"}, + {kParamTriggerLength, DeckParam::kTrigLength, kUnitAmp, "Trigger Length", "TrgLen"}, + {kParamAmpTrigAttack, DeckParam::kTrigAttack, kUnitAmp, "Amp Trig Attack", "ATrgA"}, + {kParamAmpTrigAttackCurve, DeckParam::kTrigAttackCurve, kUnitAmp, "Amp Trig Attack Curve", "ATrgAC"}, + {kParamAmpTrigHold, DeckParam::kTrigHold, kUnitAmp, "Amp Trig Hold", "ATrgH"}, + {kParamAmpTrigDecay, DeckParam::kTrigDecay, kUnitAmp, "Amp Trig Decay", "ATrgD"}, + {kParamAmpTrigDecayCurve, DeckParam::kTrigDecayCurve, kUnitAmp, "Amp Trig Decay Curve", "ATrgDC"}, + + {kParamMasterGain, DeckParam::kMasterGain, kUnitMaster, "Master Gain", "Gain"}, + }; + return kTable; +} + +} // namespace + +const std::vector& paramTable() { return table(); } + +bool isExposed(DeckParam deck) { + return ui::deckParamCommit(deck) != ui::LiveCommit::Reload; +} + +const std::vector& exposedParams() { + static const std::vector kExposed = [] { + std::vector rows; + for (const ParamRow& row : table()) { + if (isExposed(row.deck)) rows.push_back(row); + } + return rows; + }(); + return kExposed; +} + +const ParamRow* exposedRowFor(ParamId id) { + for (const ParamRow& row : exposedParams()) { + if (row.id == id) return &row; + } + return nullptr; +} + +ParamId paramIdFor(DeckParam deck) { + for (const ParamRow& row : table()) { + if (row.deck == deck) return row.id; + } + return 0; +} + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_id.h b/src/core/instrument/param/param_id.h new file mode 100644 index 0000000..dfb3839 --- /dev/null +++ b/src/core/instrument/param/param_id.h @@ -0,0 +1,135 @@ +// param_id.h — the VST3 parameter identity space: the frozen id table, its DeckParam binding, +// the deck-group units, and the exposed set DERIVED from the commit predicate. Pure: no VST3 +// type appears here, so the whole contract is provable without a host. + +#pragma once + +#include +#include + +#include "core/instrument/ui/deck_groups.h" // DeckParam + deckParamCommit (the predicate) + +namespace reasampler::instrument::param { + +using ui::DeckParam; + +// A host records this number into automation lanes inside project files this repo does not own +// and cannot migrate. +// +// THE PARAMETER-ID TABLE IS FOREVER-FROZEN, on the same footing as the extension's +// "STABLE_FOREVER_STRING" command ids, the two VST3 class UIDs (core/wire/reasampler_uid.h) and +// the params-payload field order (map/component_state_io.h): +// - No id is ever reassigned, reused or re-pointed. A control whose meaning genuinely changes +// takes a NEW id; the old one is marked dead here and never re-issued. +// - No exposed parameter's normalization ever changes — not its taper, not either range +// endpoint, not its stepCount. The normalization IS the meaning of every recorded point. +// - A parameter's meaning never depends on a mode. The Gate-face and Trigger-face stage times +// are separate stored fields and take separate ids. +// - A new control takes the next free slot inside its own group's block, never the next number +// at the end of the table. +// Display strings, titles and precision are NOT frozen — they are what a user reads, not what a +// lane stores. +using ParamId = std::uint32_t; + +// Blocks of 100 per deck group in SIGNAL-FLOW order, steps of 10 within a block, a curve dial at +// its outer knob's id + 1. Blocks start at 1000 so the first legitimate id is not also the most +// likely bug value (a default-initialised ParamId). Nine free slots between neighbours put a +// control added later numerically beside its siblings instead of at the end of the table. +// 1500-1599 (VELOCITY) and 1600-1699 (VOICE) are RESERVED and empty — a control either group +// ever gains lands in its own range rather than in whatever range happened to be free. +enum : ParamId { + kParamKeyTrackPitch = 1000, + kParamRate = 1010, + kParamPitchOffset = 1020, + + kParamPitchEnvAttack = 1100, + kParamPitchEnvAttackCurve= 1101, + kParamPitchEnvHold = 1110, + kParamPitchEnvDecay = 1120, + kParamPitchEnvDecayCurve = 1121, + kParamPitchEnvDepth = 1130, + + kParamFilterMorph = 1200, + kParamFilterCutoff = 1210, + kParamFilterQ = 1220, + kParamFilterDrive = 1230, + kParamFilterModAmount = 1240, + kParamFilterVelAmount = 1250, + kParamKeyTrackFilter = 1260, + + kParamFilterEnvAttack = 1300, + kParamFilterEnvAttackCurve = 1301, + kParamFilterEnvHold = 1310, + kParamFilterEnvDecay = 1320, + kParamFilterEnvDecayCurve = 1321, + kParamFilterEnvSustain = 1330, + kParamFilterEnvRelease = 1340, + kParamFilterEnvReleaseCurve = 1341, + kParamFilterTrigAttack = 1350, + kParamFilterTrigAttackCurve = 1351, + kParamFilterTrigHold = 1360, + kParamFilterTrigDecay = 1370, + kParamFilterTrigDecayCurve = 1371, + + kParamAmpAttack = 1400, + kParamAmpAttackCurve = 1401, + kParamAmpHold = 1410, + kParamAmpDecay = 1420, + kParamAmpDecayCurve = 1421, + kParamAmpSustain = 1430, + kParamAmpRelease = 1440, + kParamAmpReleaseCurve = 1441, + kParamTriggerLength = 1450, + kParamAmpTrigAttack = 1460, + kParamAmpTrigAttackCurve = 1461, + kParamAmpTrigHold = 1470, + kParamAmpTrigDecay = 1480, + kParamAmpTrigDecayCurve = 1481, + + kParamMasterGain = 1700, +}; + +// IUnitInfo units, one per deck group that carries an exposed parameter. 0 is the SDK's root +// unit, so these start at 1. Softer than the id freeze but user-facing and cached by some hosts. +enum : std::int32_t { + kUnitRoot = 0, + kUnitPitch = 1, + kUnitPitchEnv = 2, + kUnitFilter = 3, + kUnitFilterEnv = 4, + kUnitAmp = 5, + kUnitMaster = 6, +}; + +struct ParamRow { + ParamId id; + DeckParam deck; + std::int32_t unit; + const char* title; // survives truncation + const char* shortTitle; // distinct, for a narrow host column +}; + +// The WHOLE frozen assignment, in ascending id — which is also the presentation order, so +// identity order and presentation order agree by construction rather than by maintenance. +// Membership of the parameter list is NOT decided here: a row is issued to the host only if +// isExposed() says so. A row whose control is not exposed today keeps its number reserved for +// the day that control gains a live path, which is what the block-and-step scheme buys. +const std::vector& paramTable(); + +// A control is an exposed VST3 parameter IF AND ONLY IF its commit class is Live or +// NoteOnLatched. Derived from deckParamCommit, never hand-maintained: the list follows the +// predicate, and the predicate is never bent to fill the list. +bool isExposed(DeckParam deck); + +// paramTable() filtered by isExposed, still in ascending id. This is exactly what the host is +// told, in the order it is told. +const std::vector& exposedParams(); + +// The row for an id, or null when the id is unknown or its control is not exposed. +const ParamRow* exposedRowFor(ParamId id); + +// The id a control is numbered as, or 0 when the control has no row at all. Answers for +// unexposed rows too — the number is a property of the table, not of today's membership. +ParamId paramIdFor(DeckParam deck); + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_units.cpp b/src/core/instrument/param/param_units.cpp new file mode 100644 index 0000000..ffd659b --- /dev/null +++ b/src/core/instrument/param/param_units.cpp @@ -0,0 +1,232 @@ +// param_units.cpp — see param_units.h. Every law here is CALLED, never restated: the stage-time +// and semitone tapers are param_taper's, the curve travel is curve_law's, the filter's four are +// filter_params' own frozen laws, and the dB sweep is master_gain's. + +#include "core/instrument/param/param_units.h" + +#include "core/instrument/engine/filter/filter_params.h" +#include "core/instrument/engine/master_gain.h" +#include "core/instrument/map/play_seconds.h" +#include "core/instrument/ui/deck_values.h" +#include "core/instrument/ui/param_taper.h" +#include "core/util/clamp01.h" +#include "core/util/curve_law.h" + +namespace reasampler::instrument::param { + +namespace { + +using map::PlaySeconds; +using util::clamp01; + +// A whole displayed percent is a different plain full scale per category; these are the two +// non-100 ones, named rather than inlined so the range table and the maps cannot disagree. +constexpr double kPercentFullScale = 100.0; +const double kKeyTrackFullScale = kPercentFullScale * ui::kKeyTrackMax; // 0..200 % + +} // namespace + +UnitKind unitKindFor(DeckParam deck) { + switch (deck) { + case DeckParam::kAttack: + case DeckParam::kHold: + case DeckParam::kDecay: + case DeckParam::kRelease: + case DeckParam::kTrigAttack: + case DeckParam::kTrigDecay: + case DeckParam::kPitchEnvAttack: + case DeckParam::kPitchEnvDecay: + case DeckParam::kFilterEnvAttack: + case DeckParam::kFilterEnvHold: + case DeckParam::kFilterEnvDecay: + case DeckParam::kFilterEnvRelease: + case DeckParam::kFilterTrigAttack: + case DeckParam::kFilterTrigDecay: + return UnitKind::Time; + case DeckParam::kPitch: + case DeckParam::kPitchEnvDepth: + return UnitKind::Semitones; + case DeckParam::kSustain: + case DeckParam::kTrigLength: + case DeckParam::kTrigHold: + case DeckParam::kPitchEnvHold: + case DeckParam::kFilterEnvSustain: + case DeckParam::kFilterTrigHold: + case DeckParam::kFilterMorph: + return UnitKind::PercentUnipolar; + case DeckParam::kKeyTrack: + case DeckParam::kFilterKeyTrack: + return UnitKind::PercentKeyTrack; + case DeckParam::kFilterModAmt: + case DeckParam::kFilterVel: + return UnitKind::PercentBipolar; + case DeckParam::kRate: + return UnitKind::PercentRate; + case DeckParam::kMasterGain: + return UnitKind::Decibels; + case DeckParam::kFilterCutoff: + return UnitKind::Hertz; + default: + // The twelve curve exponents, Q and drive. Everything else has no row at all. + return UnitKind::Dimensionless; + } +} + +const char* unitStringFor(DeckParam deck) { + switch (unitKindFor(deck)) { + case UnitKind::Time: return "ms"; + case UnitKind::Semitones: return "st"; + case UnitKind::PercentUnipolar: + case UnitKind::PercentKeyTrack: + case UnitKind::PercentBipolar: + case UnitKind::PercentRate: return "%"; + case UnitKind::Decibels: return "dB"; + case UnitKind::Hertz: return "Hz"; + case UnitKind::Dimensionless: return ""; + } + return ""; +} + +PlainRange plainRangeFor(DeckParam deck) { + switch (deck) { + case DeckParam::kFilterQ: + return {static_cast(engine::filter::kFilterQMin), + static_cast(engine::filter::kFilterQMax)}; + case DeckParam::kFilterDrive: + return {0.0, static_cast(engine::filter::kFilterDriveDepthMax)}; + default: + break; + } + switch (unitKindFor(deck)) { + case UnitKind::Time: return {0.0, ui::kEnvTimeMaxSeconds * 1000.0}; + case UnitKind::Semitones: return {-ui::kPitchDepthMaxSemis, ui::kPitchDepthMaxSemis}; + case UnitKind::PercentUnipolar: return {0.0, kPercentFullScale}; + case UnitKind::PercentKeyTrack: return {0.0, kKeyTrackFullScale}; + case UnitKind::PercentBipolar: return {-kPercentFullScale, kPercentFullScale}; + case UnitKind::PercentRate: return {ui::kRateMinRatio * kPercentFullScale, + ui::kRateMaxRatio * kPercentFullScale}; + case UnitKind::Decibels: return {engine::kMasterGainMinDb, engine::kMasterGainMaxDb}; + case UnitKind::Hertz: return {static_cast(engine::filter::kFilterCutoffMinHz), + static_cast(engine::filter::kFilterCutoffMaxHz)}; + case UnitKind::Dimensionless: return {util::kCurveMin, util::kCurveMax}; + } + return {}; +} + +double toPlain(DeckParam deck, double normalized) { + switch (deck) { + case DeckParam::kFilterCutoff: + return static_cast( + engine::filter::filterCutoffHzFromNorm(static_cast(normalized))); + case DeckParam::kFilterQ: + return static_cast( + engine::filter::filterQFromNorm(static_cast(normalized))); + case DeckParam::kFilterDrive: + return static_cast( + engine::filter::filterDriveDepthFromNorm(static_cast(normalized))); + case DeckParam::kMasterGain: + // -inf at norm 0 — true silence, and the one plain value outside the declared range. + return engine::masterGainDbFromNorm(normalized); + default: + break; + } + switch (unitKindFor(deck)) { + case UnitKind::Time: + return ui::timeSecondsFromNorm(normalized) * 1000.0; + case UnitKind::Semitones: + return ui::depthSemitonesFromNorm(normalized, ui::kPitchDepthMaxSemis); + case UnitKind::PercentUnipolar: + return clamp01(normalized) * kPercentFullScale; + case UnitKind::PercentKeyTrack: + return clamp01(normalized) * kKeyTrackFullScale; + case UnitKind::PercentBipolar: + return ui::deckBipolarFromNorm(normalized) * kPercentFullScale; + case UnitKind::PercentRate: + return ui::rateRatioFromNorm(normalized, ui::kRateMinRatio, ui::kRateMaxRatio) * + kPercentFullScale; + case UnitKind::Dimensionless: + return util::curveFromKnobNorm(normalized); + case UnitKind::Decibels: + case UnitKind::Hertz: + break; // handled above + } + return normalized; +} + +double toNormalized(DeckParam deck, double plain) { + switch (deck) { + case DeckParam::kFilterCutoff: + return static_cast( + engine::filter::filterNormFromCutoffHz(static_cast(plain))); + case DeckParam::kFilterQ: + return static_cast( + engine::filter::filterNormFromQ(static_cast(plain))); + case DeckParam::kFilterDrive: + return static_cast( + engine::filter::filterNormFromDriveDepth(static_cast(plain))); + case DeckParam::kMasterGain: + return engine::masterGainNormFromDb(plain); + default: + break; + } + switch (unitKindFor(deck)) { + case UnitKind::Time: + return ui::timeNormFromSeconds(plain / 1000.0); + case UnitKind::Semitones: + return ui::depthNormFromSemitones(plain, ui::kPitchDepthMaxSemis); + case UnitKind::PercentUnipolar: + return clamp01(plain / kPercentFullScale); + case UnitKind::PercentKeyTrack: + return clamp01(plain / kKeyTrackFullScale); + case UnitKind::PercentBipolar: + return ui::deckNormFromBipolar(plain / kPercentFullScale); + case UnitKind::PercentRate: + return ui::rateNormFromRatio(plain / kPercentFullScale, ui::kRateMinRatio, + ui::kRateMaxRatio); + case UnitKind::Dimensionless: + return util::knobNormFromCurve(plain); + case UnitKind::Decibels: + case UnitKind::Hertz: + break; // handled above + } + return plain; +} + +bool storesNormalized(DeckParam deck) { + PlaySeconds defaults; + return ui::deckFloatField(deck, defaults) != nullptr; +} + +double defaultPlain(DeckParam deck) { + PlaySeconds defaults; + // The filter's four store the normalized position itself, so their plain default is that + // stored position read THROUGH the law — the law is the display, never the storage. + if (const float* stored = ui::deckFloatField(deck, defaults)) { + return toPlain(deck, static_cast(*stored)); + } + if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case + const double* field = ui::deckDoubleField(deck, defaults); + if (!field) return 0.0; + switch (unitKindFor(deck)) { + case UnitKind::Time: return *field * 1000.0; // stored seconds + case UnitKind::PercentUnipolar: return *field * kPercentFullScale; + case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2 + case UnitKind::PercentBipolar: return *field * kPercentFullScale; + case UnitKind::PercentRate: return *field * kPercentFullScale; // stored ratio + case UnitKind::Semitones: + case UnitKind::Dimensionless: return *field; // already the plain unit + case UnitKind::Decibels: + case UnitKind::Hertz: break; // handled above + } + return *field; +} + +double defaultNormalized(DeckParam deck) { + PlaySeconds defaults; + if (const float* stored = ui::deckFloatField(deck, defaults)) { + return static_cast(*stored); // verbatim: no taper on the reset path + } + return toNormalized(deck, defaultPlain(deck)); +} + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_units.h b/src/core/instrument/param/param_units.h new file mode 100644 index 0000000..58b3c88 --- /dev/null +++ b/src/core/instrument/param/param_units.h @@ -0,0 +1,64 @@ +// param_units.h — the plain-value layer the host reads a parameter through: the unit category, +// the plain range, and the norm <-> plain pair. `toPlain` IS the taper's forward map and +// `toNormalized` its inverse, so the host's normalization, the knob's needle angle and the +// overlay node's position are the SAME function rather than three that agree today. + +#pragma once + +#include "core/instrument/ui/deck_groups.h" // DeckParam + +namespace reasampler::instrument::param { + +using ui::DeckParam; + +// The eight DISPLAY categories. A category fixes the units string and the digit precision; the +// norm <-> plain LAW is per control, because three of the dimensionless controls (the curve +// exponents, Q, drive) share a display and share no law. +enum class UnitKind { + Time, // ms, 0..10000 + Semitones, // st, -24..+24, always signed + PercentUnipolar, // %, 0..100 + PercentKeyTrack, // %, 0..200 + PercentBipolar, // %, -100..+100, always signed + PercentRate, // %, 50..200, one decimal + Decibels, // dB, -60..+24, always signed; norm 0 reads -inf + Hertz, // Hz, 20..20000 + Dimensionless, // no unit, two decimals +}; + +struct PlainRange { + double min = 0.0; + double max = 1.0; +}; + +UnitKind unitKindFor(DeckParam deck); + +// The units string ParameterInfo carries — "" for the dimensionless category. Carried SEPARATELY +// from the digits, which is the SDK's own convention (RangeParameter::toString prints the number; +// the Parameter constructor takes units as its own argument). +const char* unitStringFor(DeckParam deck); + +PlainRange plainRangeFor(DeckParam deck); + +// A straight line drawn in a host automation lane is NOT linear in these plain units, and that is +// deliberate: exponential in ms, linear in octaves on cutoff, linear in dB on master gain, a +// linear pitch glide on rate, and slow-near-zero on the two centre-expanded semitone throws. It +// follows from reporting real units over a musically-shaped taper; the remedy for a user who +// wants a literal-units ramp is the host's own curve tools, never a change to the taper. +double toPlain(DeckParam deck, double normalized); +double toNormalized(DeckParam deck, double plain); + +// The filter's four tone controls STORE their normalized position (payload v9), so their default +// normalized value is that stored double verbatim and no taper participates in a host's +// reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not +// replacing them. +bool storesNormalized(DeckParam deck); + +// The default, read off a default-constructed PlaySeconds — there is no second table of defaults, +// and no normalized default is ever written as a literal. defaultNormalized is COMPUTED as +// toNormalized(defaultPlain) for every tapered control, which is what makes a host's +// reset-to-default and the editor's double-click land on the same value. +double defaultPlain(DeckParam deck); +double defaultNormalized(DeckParam deck); + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 2eb2e60..8988a5a 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -204,6 +204,11 @@ LiveCommit deckParamCommit(DeckParam id) { // The one note-on-latched control; the header owns why. case DeckParam::kRate: return LiveCommit::NoteOnLatched; + // Live by the tier's own definition — one atomic store the audio thread picks up at the + // next block, no bridge read and no re-decode. It reaches the audio beside the live + // block rather than through it, which is why it carries no LiveValues field; that is a + // question of ROUTE, and this predicate answers TIER. + case DeckParam::kMasterGain: case DeckParam::kPitch: case DeckParam::kAttack: case DeckParam::kHold: @@ -270,7 +275,6 @@ LiveCommit deckParamCommit(DeckParam id) { case DeckParam::kVoiceCount: case DeckParam::kVoiceMode: case DeckParam::kMonoTrigger: - case DeckParam::kMasterGain: case DeckParam::kLimiterEnable: // MASTER's two readouts reach no parameter at all — the same footing as the overlay // radios above. diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index 42443de..6370593 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -164,6 +164,11 @@ DeckParam curveParamFor(DeckParam knob); // Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and // inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too. // +// kMasterGain is Live and is the one live control that does NOT ride the live block: it is a +// lock-free atomic on the processor which the audio thread applies as a post-sum multiply. The +// tier answers "does an edit reach the audio without a reload", not "which mechanism carries +// it" — classifying it Reload would have said a gain move re-decodes the WAV, which it never did. +// // kRate is the one NoteOnLatched control, and the reason is a real feature rather than a // plumbing detail: loop points and contours both scale with rate, and both are note-on folds — // resolveLoop runs once per note-on and a contour resolves against the note's own span. A live diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index 47d911e..f2d61d6 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -4,7 +4,6 @@ #include #include -#include #include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value) #include "core/instrument/engine/master_gain.h" // the dB taper the whole-dB snap reads @@ -374,10 +373,4 @@ double snapDeckParamNorm(DeckParam id, double norm) { return norm; } -void formatEnvTimeMs(double seconds, char* buf, std::size_t len) { - if (!buf || len == 0) return; - const double ms = seconds * 1000.0; - std::snprintf(buf, len, ms < 10.0 ? "%.1f ms" : "%.0f ms", ms); -} - } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index 4e52258..1ddd276 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -1,13 +1,11 @@ -// deck_values.h — the deck's control-id <-> parameter-set BINDING and its display units: the -// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, the -// double-click reset, and the ms time-constant formatter. Split from the editor shell so the -// whole domain map is provable without a host; deck_groups owns WHICH controls exist, this owns -// what each one's value MEANS. +// deck_values.h — the deck's control-id <-> parameter-set BINDING and its snap units: the +// normalized 0..1 a knob shows, the write back into the stored seconds/fractions/positions, and +// the double-click reset. Split from the editor shell so the whole domain map is provable +// without a host; deck_groups owns WHICH controls exist, this owns what each one's value MEANS, +// and param/param_format owns how it READS. #pragma once -#include - #include "core/instrument/engine/time_stretch.h" // kStretchRateMin/Max (Rate's own range) #include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target) #include "core/instrument/ui/deck_groups.h" // DeckParam @@ -75,9 +73,4 @@ UnitCategory deckParamUnit(DeckParam id); // whole displayed percent and therefore take different norm steps. double snapDeckParamNorm(DeckParam id, double norm); -// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in -// one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short -// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator. -void formatEnvTimeMs(double seconds, char* buf, std::size_t len); - } // namespace reasampler::instrument::ui diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 36b7d22..cc8a260 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -84,6 +84,37 @@ The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is declared ahead of the instrument slots at that member in `reasampler_processor.h`. +**The VST3 parameter surface is a THIRD surface onto the one model, never a second copy.** The +pure half — the frozen id table, the exposed set, the plain-value layer, the formatter — is +`core/instrument/param` and is documented there; this directory only adapts it. + +- **The blob stays authoritative.** `getState` serializes the model and nothing new is + persisted; the controller's own value list is a cache written FROM the model and never read + as truth. A load pushes the model into that cache through `syncParamsFromModel` WITHOUT + notifying the host, which the SDK requires. +- **`setInstrumentParams` is the notification funnel**, for the same reason it is already the + limiter mirror's: every writer of the parameter set — the editor's commits, `setState`, the + bake's adopt — passes through it, so no internal write can leave the host displaying, and on + next touch re-imposing, a superseded value. Master gain has its own funnel + (`setMasterGainLinear`) because it is the one exposed control that does not ride the + parameter set. +- **`setState` ordering against the host's first parameter block is irrelevant by + construction.** There is one model and one funnel per control, so whichever writes last wins + and the host's display follows the model either way — the ordering is not assumed, it is + removed as a question. +- **`process()` reads no parameter queue and is unchanged by the parameter surface.** A host + write arrives on the UI/main thread and reaches the audio thread through the SAME live block + the editor's knobs publish into, observed once per `render()` — block boundaries, last write + wins. `[verify — DAW]` that REAPER delivers automation to a single-component plug-in through + `IEditController::setParamNormalized` and not through `ProcessData::inputParameterChanges` + alone; if it is the latter only, an RT-safe drain is required and `process()` is where it + would have to land. +- **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what + is exposed, an invented map would hijack CCs the user's controller already sends, and + REAPER's own per-parameter MIDI learn covers the case without freezing anything. + `IParameterFunctionName` and `IAutomationState` are assessed and not implemented; the reasons + are in the product spec and are not re-surveyed here. + **Non-goals / guardrails.** - The instrument never captures and never inserts into the arrange. Playback is a read-only act over the bank. Any instrument path that places a timeline item, or that @@ -112,6 +143,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. - `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry. +- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws and the formatter. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. - `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index a59836b..13994fd 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -46,6 +46,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") reasampler_processor.cpp processor_state.cpp processor_reload.cpp + instrument_params.cpp # The editor family is split on the Sample face's band axis: session/bridge state, # param plumbing plus the shared band-layout resolve, then paint and input in # matching sets, plus the two band-independent surfaces and the platform TU. @@ -89,6 +90,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") waveform_view loop_marks bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage + param_id param_units param_format limiter meter_accumulate meter_ballistics master_meter bake_hold file_bytes curve_law stroke_aa curve_tessellate diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 63c8a91..93b057b 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -13,11 +13,12 @@ #include #include -#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws #include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper +#include "core/instrument/param/param_format.h" // THE formatter every value label reads through +#include "core/instrument/param/param_id.h" // whether a control has a parameter row at all #include "core/instrument/ui/bake_hold.h" // the Hold knob's ladder map #include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition) -#include "core/instrument/ui/deck_values.h" // the parameter-set binding + its ms units +#include "core/instrument/ui/deck_values.h" // the parameter-set binding #include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height) #include "core/util/clamp01.h" #include "core/util/curve_law.h" // the ONE curve-exponent domain @@ -33,18 +34,13 @@ using instrument::ui::deckHeight; using instrument::ui::kDeckKnobSize; using instrument::ui::kPad; using instrument::ui::deckParamNorm; -using instrument::ui::formatEnvTimeMs; using instrument::ui::kEnvTimeMaxSeconds; using instrument::ui::kKeyTrackMax; using instrument::ui::resetDeckParam; using instrument::ui::sampleDeckGroups; using instrument::ui::setDeckParam; -using instrument::engine::formatMasterGainLabel; using instrument::engine::masterGainLinearFromNorm; using instrument::engine::masterGainNormFromLinear; -using instrument::engine::filter::filterCutoffHzFromNorm; -using instrument::engine::filter::filterDriveDepthFromNorm; -using instrument::engine::filter::filterQFromNorm; using util::clamp01; namespace { @@ -220,117 +216,36 @@ void ReaSamplerEditor::applyDeckKnob(int id, double norm) { } } -std::string ReaSamplerEditor::deckValueLabel(int id) const { - char buf[24]; - buf[0] = '\0'; - const PlaySeconds& play = params_.play; - switch (id < 0 ? ParamControl::kCount : static_cast(id)) { - case ParamControl::kAttack: - formatEnvTimeMs(play.adsr.attackSeconds, buf, sizeof(buf)); break; - case ParamControl::kHold: - formatEnvTimeMs(play.adsr.holdSeconds, buf, sizeof(buf)); break; - case ParamControl::kDecay: - formatEnvTimeMs(play.adsr.decaySeconds, buf, sizeof(buf)); break; - case ParamControl::kSustain: - snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break; - case ParamControl::kRelease: - formatEnvTimeMs(play.adsr.releaseSeconds, buf, sizeof(buf)); break; - case ParamControl::kTrigLength: - snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; - case ParamControl::kTrigAttack: - formatEnvTimeMs(play.trigAhd.attackSeconds, buf, sizeof(buf)); break; - case ParamControl::kTrigHold: - snprintf(buf, sizeof(buf), "%.0f%%", play.trigAhd.holdFraction * 100.0); break; - case ParamControl::kTrigDecay: - formatEnvTimeMs(play.trigAhd.decaySeconds, buf, sizeof(buf)); break; - case ParamControl::kPitchEnvAttack: - formatEnvTimeMs(play.pitchEnv.shape.attackSeconds, buf, sizeof(buf)); break; - case ParamControl::kPitchEnvHold: - snprintf(buf, sizeof(buf), "%.0f%%", play.pitchEnv.shape.holdFraction * 100.0); break; - case ParamControl::kPitchEnvDecay: - formatEnvTimeMs(play.pitchEnv.shape.decaySeconds, buf, sizeof(buf)); break; - case ParamControl::kPitchEnvDepth: - snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; - case ParamControl::kKeyTrack: - snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break; - case ParamControl::kRate: { - // One decimal below 100 % only: the taper is linear in semitones, so the lower half - // spends 50 percentage points on the same twelve semitones the upper half spends - // 100 on — a whole percent is twice as coarse a step down there. - const double pct = play.playRate * 100.0; - snprintf(buf, sizeof(buf), pct < 100.0 ? "%.1f%%" : "%.0f%%", pct); - break; - } - case ParamControl::kPitch: - snprintf(buf, sizeof(buf), "%+.1fst", play.pitchOffsetSemitones); break; - case ParamControl::kVoiceCount: - snprintf(buf, sizeof(buf), "%d", voiceCount_); break; - case ParamControl::kMasterGain: - formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break; - // Filter readouts run the stored normalized positions back through the module's OWN - // laws, so what the label says is what the kernel is solved for. - case ParamControl::kFilterMorph: { - const double m = play.filter.settings.morphNorm; - snprintf(buf, sizeof(buf), "%.0f%%", m * 100.0); - break; - } - case ParamControl::kFilterCutoff: { - const float hz = filterCutoffHzFromNorm(play.filter.settings.cutoffNorm); - if (hz >= 1000.0f) snprintf(buf, sizeof(buf), "%.2fk", hz / 1000.0f); - else snprintf(buf, sizeof(buf), "%.0fHz", hz); - break; - } - case ParamControl::kFilterQ: - snprintf(buf, sizeof(buf), "%.2f", - static_cast(filterQFromNorm(play.filter.settings.resonanceNorm))); - break; - case ParamControl::kFilterDrive: - snprintf(buf, sizeof(buf), "%.2f", - static_cast(filterDriveDepthFromNorm(play.filter.settings.driveNorm))); - break; - case ParamControl::kFilterModAmt: - snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break; - case ParamControl::kFilterVel: - snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break; - case ParamControl::kFilterKeyTrack: - snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break; - case ParamControl::kFilterEnvAttack: - formatEnvTimeMs(play.filter.env.attackSeconds, buf, sizeof(buf)); break; - case ParamControl::kFilterEnvHold: - formatEnvTimeMs(play.filter.env.holdSeconds, buf, sizeof(buf)); break; - case ParamControl::kFilterEnvDecay: - formatEnvTimeMs(play.filter.env.decaySeconds, buf, sizeof(buf)); break; - case ParamControl::kFilterEnvSustain: - snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break; - case ParamControl::kFilterEnvRelease: - formatEnvTimeMs(play.filter.env.releaseSeconds, buf, sizeof(buf)); break; - case ParamControl::kFilterTrigAttack: - formatEnvTimeMs(play.filter.trigEnv.attackSeconds, buf, sizeof(buf)); break; - case ParamControl::kFilterTrigHold: - snprintf(buf, sizeof(buf), "%.0f%%", play.filter.trigEnv.holdFraction * 100.0); break; - case ParamControl::kFilterTrigDecay: - formatEnvTimeMs(play.filter.trigEnv.decaySeconds, buf, sizeof(buf)); break; - // Every curve exponent reads the same way: the neutral shows as 1.00. - case ParamControl::kAttackCurve: - case ParamControl::kDecayCurve: - case ParamControl::kReleaseCurve: - case ParamControl::kTrigAttackCurve: - case ParamControl::kTrigDecayCurve: - case ParamControl::kPitchEnvAttackCurve: - case ParamControl::kPitchEnvDecayCurve: - case ParamControl::kFilterEnvAttackCurve: - case ParamControl::kFilterEnvDecayCurve: - case ParamControl::kFilterEnvReleaseCurve: - case ParamControl::kFilterTrigAttackCurve: - case ParamControl::kFilterTrigDecayCurve: - snprintf(buf, sizeof(buf), "^%.2f", curveExponentFor(id, play)); - break; - default: - // The chrome knobs (preview velocity, bake Hold) are labeled at their own call - // site; nothing else here. - break; +double ReaSamplerEditor::deckPlainValue(int id) const { + const auto deck = static_cast(id); + // A curve exponent is read off its stored field, never round-tripped through the knob law: + // that law's centre detent snaps anything near-neutral back to exactly 1.0, so a round trip + // would misreport a stored exponent that isn't neutral as 1.00. The host has only the norm + // and therefore cannot make this distinction — param/CLAUDE.md records the divergence. + if (instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent) { + return curveExponentFor(id, params_.play); } - return std::string(buf); + return instrument::param::toPlain(deck, deckControlNorm(id)); +} + +std::string ReaSamplerEditor::deckValueLabel(int id) const { + if (id < 0 || id >= static_cast(ParamControl::kCount)) return {}; + const auto deck = static_cast(id); + // The one deck knob with no plain-value layer at all: an already-integer count. + if (deck == ParamControl::kVoiceCount) { + char buf[24]; + snprintf(buf, sizeof(buf), "%d", voiceCount_); + return std::string(buf); + } + if (instrument::param::paramIdFor(deck) == 0) return {}; + + // The digits come from the ONE formatter; everything the editor adds around them is static + // chrome — a constant prefix or suffix cannot diverge from what the host shows. + char digits[24]; + instrument::param::formatPlainFor(deck, deckPlainValue(id), digits, sizeof(digits)); + const char* caret = + instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : ""; + return caret + std::string(digits) + instrument::param::unitStringFor(deck); } EnvClampBounds ReaSamplerEditor::envClampBounds() const { diff --git a/src/shell/instrument/editor_input.cpp b/src/shell/instrument/editor_input.cpp index e498961..2bb32fd 100644 --- a/src/shell/instrument/editor_input.cpp +++ b/src/shell/instrument/editor_input.cpp @@ -85,6 +85,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { invalidate(); } if (drag_ == DragKind::kNone) return; + // Stack RAII rather than a call at each exit: this handler leaves through several early + // returns, and the release commit's final performEdit must land INSIDE the bracket the grab + // opened while the bracket itself may not outlive the handler on any path. + struct GestureClose { + ReaSamplerProcessor* p; + ~GestureClose() { if (p) p->endParamGesture(); } + } gestureClose{processor_}; const DragKind kind = drag_; const int paramId = dragParamId_; const int curveIdx = curvePointIndex_; @@ -107,9 +114,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { // directly. Voice count: the label/needle tracks live during the drag but the engine // rebuild (setVoiceCount) fires ONCE here on release — not per integer step. const bool deckTransient = - kind == DragKind::kDeckKnob && - (paramId == -2 || paramId == static_cast(ParamControl::kVoiceCount) || - paramId == static_cast(ParamControl::kMasterGain)); + kind == DragKind::kDeckKnob && deckKnobIsProcessorSide(paramId); if (kind == DragKind::kScrollThumb || deckTransient) { // Commit the voice count now that the drag is complete (one rebuild per full drag). if (deckTransient && processor_ && diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index 009a50a..8e19644 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -125,6 +125,12 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { dragStartParams_ = params_; dragStartX_ = x; dragStartY_ = y; + // Opens the host's edit bracket for the whole gesture, so a host in touch or latch mode + // records one continuous edit rather than a burst of one-point ones. Closed on release + // and on capture-lost; a control with no parameter row is a no-op inside the processor. + if (processor_) { + processor_->beginParamGesture(static_cast(dragParamId_)); + } invalidate(); } // The deck band swallows its own clicks either way — no fall-through to the waveform. @@ -188,8 +194,14 @@ void ReaSamplerEditor::dragDeck(int x, int y) { } applyDeckKnob(dragParamId_, norm); // A live control is delivered on every move, not only on release — that is the whole - // point: the note already sounding tracks the hand on the knob. - if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive(); + // point: the note already sounding tracks the hand on the knob. The processor-side knobs + // are skipped because applyDeckKnob already wrote them straight through; commitLive would + // only re-push an unchanged parameter set. The release and capture-lost paths ask this same + // question; the reset path never reaches the commit for them at all. + if (!deckKnobIsProcessorSide(dragParamId_) && + dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) { + commitLive(); + } invalidate(); } diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index 2406806..020721f 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -249,9 +249,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, // abandoned value indefinitely instead of rolling back. const bool transient = self->drag_ == DragKind::kScrollThumb || (self->drag_ == DragKind::kDeckKnob && - (self->dragParamId_ == -2 || - self->dragParamId_ == static_cast(ParamControl::kVoiceCount) || - self->dragParamId_ == static_cast(ParamControl::kMasterGain))); + ReaSamplerEditor::deckKnobIsProcessorSide(self->dragParamId_)); if (!transient) { self->params_ = self->dragStartParams_; // A live drag already reached the voices AND the processor's own @@ -263,6 +261,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, self->commitLive(); } } + // Peer of onMouseUp's bracket close, and after the rollback for the same + // reason: the rollback's own performEdit belongs inside the bracket the grab + // opened, and an abandoned drag must not leave the host's edit open. + if (self->processor_) self->processor_->endParamGesture(); self->drag_ = DragKind::kNone; self->dragParamId_ = -1; self->dragInnerCellId_ = -1; // inner-dial drag state (peer reset) diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index bca7a84..699058e 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -213,6 +213,12 @@ void ReaSamplerEditor::commitLive() { processor_->publishLiveParams(); } +bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) { + return paramId == -2 || + paramId == static_cast(ParamControl::kVoiceCount) || + paramId == static_cast(ParamControl::kMasterGain); +} + bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const { // The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind // vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than diff --git a/src/shell/instrument/instrument_params.cpp b/src/shell/instrument/instrument_params.cpp new file mode 100644 index 0000000..446a18e --- /dev/null +++ b/src/shell/instrument/instrument_params.cpp @@ -0,0 +1,194 @@ +// instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass +// whose toPlain/toNormalized ARE the taper, the one construction of the unit and parameter +// lists, and the model projection both directions. It DECIDES nothing — the pure module owns +// the frozen table, the laws and the formatter. +// +// The blob stays authoritative. A parameter is a THIRD SURFACE onto InstrumentParams/PlaySeconds +// — a peer of the deck knob and the overlay node, never a second copy of the value. getState +// serializes the model; the controller's own value list is a cache written FROM the model and +// never read as truth. + +#include "shell/instrument/reasampler_processor.h" + +#include "base/source/fstring.h" +#include "pluginterfaces/base/ustring.h" + +#include "core/instrument/engine/master_gain.h" +#include "core/instrument/param/param_format.h" +#include "core/instrument/param/param_id.h" +#include "core/instrument/param/param_units.h" +#include "core/instrument/ui/deck_values.h" + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace reasampler::vst { + +namespace param = instrument::param; +using instrument::ui::DeckParam; + +namespace { + +void assign128(String128 dst, const char* src) { + UString(dst, str16BufferSize(String128)).fromAscii(src); +} + +// One class for all of them: the law is per-control data inside the pure module, so a subclass +// per unit category would model nothing that a DeckParam does not already say. +class DeckParameter : public Parameter { +public: + explicit DeckParameter(const param::ParamRow& row) : deck_(row.deck) { + assign128(info.title, row.title); + assign128(info.shortTitle, row.shortTitle); + assign128(info.units, param::unitStringFor(row.deck)); + info.id = row.id; + info.unitId = row.unit; + // Continuous, every one of them — and structurally so rather than by luck: stepCount > 0 + // is only meaningful for a discrete control, and every discrete control is reload or + // rebuild tier and therefore not exposed at all. The editor's shift-snap is a DRAG + // interaction and must never be published here: stepCount quantizes the parameter + // permanently, for the host's automation too, and freezes into the forever contract. + info.stepCount = 0; + // COMPUTED from the default, never a normalized literal — a hand-written normalized + // default is a second source of truth for it and drifts from the taper silently. + info.defaultNormalizedValue = param::defaultNormalized(row.deck); + // No kIsBypass on anything: the plugin is an instrument and exposes no bypass. + info.flags = ParameterInfo::kCanAutomate; + valueNormalized = info.defaultNormalizedValue; + } + + ParamValue toPlain(ParamValue normalized) const SMTG_OVERRIDE { + return param::toPlain(deck_, normalized); + } + ParamValue toNormalized(ParamValue plain) const SMTG_OVERRIDE { + return param::toNormalized(deck_, plain); + } + void toString(ParamValue normalized, String128 out) const SMTG_OVERRIDE { + char digits[24]; + param::formatPlainFor(deck_, param::toPlain(deck_, normalized), digits, sizeof(digits)); + assign128(out, digits); + } + bool fromString(const TChar* text, ParamValue& normalized) const SMTG_OVERRIDE { + String str(text); + str.toMultiByte(kCP_Utf8); + double plain = 0.0; + if (!param::parsePlain(param::unitKindFor(deck_), str.text8(), plain)) return false; + normalized = param::toNormalized(deck_, plain); + return true; + } + + OBJ_METHODS(DeckParameter, Parameter) + +private: + DeckParam deck_; +}; + +} // namespace + +void ReaSamplerProcessor::buildParameterList() { + // One unit per deck group that carries an exposed parameter, so a host can present the list + // under its group names rather than as one flat run. + struct UnitDesc { UnitID id; const char* name; }; + static const UnitDesc kUnits[] = { + {param::kUnitPitch, "Pitch"}, + {param::kUnitPitchEnv, "Pitch Env"}, + {param::kUnitFilter, "Filter"}, + {param::kUnitFilterEnv, "Filter Env"}, + {param::kUnitAmp, "Amp Env"}, + {param::kUnitMaster, "Master"}, + }; + for (const UnitDesc& u : kUnits) { + String128 name; + assign128(name, u.name); + addUnit(new Unit(name, u.id)); + } + // Ascending id IS the presentation order, which is what makes identity order and + // presentation order agree by construction rather than by maintenance. + for (const param::ParamRow& row : param::exposedParams()) { + parameters.addParameter(new DeckParameter(row)); + } +} + +tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamValue value) { + const param::ParamRow* row = param::exposedRowFor(tag); + if (!row) return kResultFalse; + // Notification is suppressed for the duration: this write CAME from the host, and echoing it + // back through performEdit would let a lane in write mode re-record its own playback. + const bool wasSuppressed = paramNotifySuppressed_; + paramNotifySuppressed_ = true; + // The host's write takes the control's EXISTING commit tier and no other. Nothing here can + // reach reloadInstrument or rebuildVoiceEngine, and that is structural: every reload- and + // rebuild-tier control is omitted from the list, so no id maps to one. + if (row->deck == DeckParam::kMasterGain) { + setMasterGainLinear(instrument::engine::masterGainLinearFromNorm(value)); + } else { + InstrumentParams params = instrumentParams(); + instrument::ui::setDeckParam(row->deck, params.play, value, /*segment=*/0); + setInstrumentParams(params); + publishLiveParams(); + } + paramNotifySuppressed_ = wasSuppressed; + // The container caches what the MODEL took, not what the host sent — a control whose write + // clamped would otherwise read back the out-of-range value the clamp rejected. + return EditControllerEx1::setParamNormalized( + tag, modelParamNormalized(instrumentParams(), row->deck)); +} + +double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params, + DeckParam deck) const { + if (deck == DeckParam::kMasterGain) { + return instrument::engine::masterGainNormFromLinear(masterGainLinear()); + } + return instrument::ui::deckParamNorm(deck, params.play); +} + +void ReaSamplerProcessor::syncParamsFromModel() { + const InstrumentParams params = instrumentParams(); + for (const param::ParamRow& row : param::exposedParams()) { + // EditControllerEx1's own setter, NOT ours: this is the LOAD direction, and the SDK is + // explicit that a controller must never pass a load back to the host through + // IComponentHandler — it updates the GUI element only. + EditControllerEx1::setParamNormalized(row.id, modelParamNormalized(params, row.deck)); + } +} + +void ReaSamplerProcessor::notifyParamsFromModel(const InstrumentParams& before, + const InstrumentParams& after) { + if (paramNotifySuppressed_) return; + for (const param::ParamRow& row : param::exposedParams()) { + if (row.deck == DeckParam::kMasterGain) continue; // its own funnel notifies it + const double now = modelParamNormalized(after, row.deck); + if (now == modelParamNormalized(before, row.deck)) continue; + notifyParamChanged(row.id, now); + } +} + +void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalized) { + EditControllerEx1::setParamNormalized(id, normalized); + if (!componentHandler) return; + // A drag holds its own begin/end across the whole gesture so a host in touch or latch mode + // sees one continuous edit; every other writer — a reset, an envelope-node drag, the bake's + // reset — emits a degenerate one-point gesture, which is what makes the host DISPLAY follow + // it instead of re-imposing the pre-write value on the next touch. + const bool inGesture = openGestureId_ == id; + if (!inGesture) beginEdit(id); + performEdit(id, normalized); + if (!inGesture) endEdit(id); +} + +void ReaSamplerProcessor::beginParamGesture(DeckParam deck) { + const param::ParamId id = param::paramIdFor(deck); + if (id == 0 || !param::isExposed(deck)) return; + endParamGesture(); // a grab while one is open cannot leave the previous unclosed + openGestureId_ = id; + beginEdit(id); +} + +void ReaSamplerProcessor::endParamGesture() { + if (openGestureId_ == 0) return; + const param::ParamId id = openGestureId_; + openGestureId_ = 0; // cleared FIRST: endEdit can re-enter through a host's own callback + endEdit(id); +} + +} // namespace reasampler::vst diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index ba0a098..335e6e1 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -41,6 +41,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { // calls setupProcessing before setState on load), which the legacy v3 payload's // frames->seconds conversion needs. const ComponentState cs = deserializeComponentState(bytes, sampleRate_); + // A LOAD is not an edit: the SDK is explicit that a controller must never pass a restored + // value back to the host through IComponentHandler. The push into the controller happens + // once at the tail instead, through syncParamsFromModel. + paramNotifySuppressed_ = true; setSelectedSampleId(cs.selectionId); setInstrumentParams(cs.params); // Restore the last-consumed assignment generation so a re-open does not re-apply a @@ -86,6 +90,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { // A new blob is new facts — the legacy lift gets one fresh run per restored state. legacyLiftConcluded_.store(false, std::memory_order_relaxed); reloadInstrument(); + paramNotifySuppressed_ = false; + // Every exposed parameter now reads the blob's value. Ordering against the host's first + // parameter block is irrelevant BY CONSTRUCTION rather than by assumption: there is one + // model and one funnel per control, so whichever of the two writes last simply wins, and + // the host's display follows the model either way. + syncParamsFromModel(); // This caller has no editor to flush for it. At the TAIL on purpose: a host that services the // restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads // against the refs above, which are only fully restored once this function has run to here. @@ -153,8 +163,10 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() { } void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { + InstrumentParams before; { std::lock_guard lock(paramsMutex_); + before = params_; params_ = params; } // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — @@ -173,6 +185,10 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { latencyRestartPending_.store( params.limiterEnabled != latencyAnnounced_.load(std::memory_order_relaxed), std::memory_order_release); + // The host-notification obligation, at the same one funnel and for the same reason the + // limiter mirror sits here: an internal write that skipped it would leave the host + // displaying — and, on the next touch, re-imposing — the superseded value. + notifyParamsFromModel(before, params); } void ReaSamplerProcessor::flushLatencyRestart() { @@ -319,7 +335,14 @@ void ReaSamplerProcessor::setMasterGainLinear(double linear) { if (!(linear >= 0.0)) linear = 0.0; // also catches NaN const double maxLin = masterGainMaxLinear(); if (linear > maxLin) linear = maxLin; - masterGain_.store(static_cast(linear), std::memory_order_relaxed); + const float value = static_cast(linear); + const float previous = masterGain_.exchange(value, std::memory_order_relaxed); + // Gain's own notification funnel — it is the one exposed control that does not ride the + // parameter set, so setInstrumentParams' diff cannot see it. Compared for a real change so a + // reload's republish of an unmoved gain writes nothing into a host's automation lane. + if (paramNotifySuppressed_ || previous == value) return; + notifyParamChanged(instrument::param::kParamMasterGain, + instrument::engine::masterGainNormFromLinear(linear)); } void ReaSamplerProcessor::previewNoteOn(int note) { diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index f27d642..31f4d54 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -256,6 +256,13 @@ private: // liveCommitFor (deck_groups.h) for why. bool dragCommitsLive(DragKind kind, int paramId = -1) const; + // The deck knobs whose value lives on the processor or on the editor rather than in the + // parameter set: the preview-velocity sentinel, voice count, master gain. applyDeckKnob + // writes each straight through, so params_ is not a rollback target for them and + // commitLive has nothing of theirs to push. Every drag path that ends a gesture — release, + // reset, capture-lost, per-move — asks this same question. + static bool deckKnobIsProcessorSide(int paramId); + // Commits `id` as the loaded capture. The one parameter set carries over — it governs // whatever is loaded, so a load swaps the sound, not the settings. void loadSelection(const std::string& id); @@ -398,8 +405,12 @@ private: // params edit, no reload). void applyDeckKnob(int id, double norm); - // The knob's live value label shown during hover/drag: milliseconds, percents, Hz, signed - // semitones, a curve exponent, a voice count, or the master-gain dB. + // The knob's value in the REAL unit the host is told about (core/instrument/param). + double deckPlainValue(int id) const; + + // The knob's live value label shown during hover/drag: the ONE per-category formatter's + // digits plus this surface's own static chrome (the unit suffix, a curve dial's caret). + // Empty for a control with no plain-value layer. std::string deckValueLabel(int id) const; ReaSamplerProcessor* processor_ = nullptr; diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index 1ce0c08..d4d7259 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -69,6 +69,8 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { addEventInput(STR16("MIDI In"), 16); addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); + buildParameterList(); + return kResultOk; } diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 3135a3e..92395d3 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -24,6 +24,7 @@ #include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block) #include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume #include "core/instrument/engine/voice_engine.h" +#include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding namespace reasampler::vst { @@ -124,6 +125,31 @@ public: // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; + // A host write of one exposed parameter. Applies it to THE model through that control's + // existing commit tier — live publish, or the master-gain atomic — and never through a + // fourth route. Nothing reachable from here touches reloadInstrument or rebuildVoiceEngine, + // which is structural rather than careful: every reload- and rebuild-tier control is omitted + // from the parameter list, so no id maps to one. + // + // Parameter values reach the audio thread through the SAME block the editor's knobs publish + // into, which the engine observes ONCE per render() — i.e. at BLOCK BOUNDARIES, last write + // wins for that block. Sample-accurate application would put a per-sample "did anything + // change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids. + // process() reads no parameter queue and is unchanged by the parameter surface. + Steinberg::tresult PLUGIN_API setParamNormalized( + Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override; + + // Pushes every exposed parameter's normalized value from the model into the controller + // WITHOUT notifying the host — the load direction, which the SDK forbids reflecting back + // through IComponentHandler. UI/main thread. + void syncParamsFromModel(); + + // A knob drag's host-edit bracket, so a host in touch or latch mode records ONE continuous + // edit rather than a burst of one-point gestures. Idempotent: a grab while one is open + // closes it first, and endParamGesture with none open does nothing. UI thread only. + void beginParamGesture(instrument::ui::DeckParam deck); + void endParamGesture(); + // Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the // inline TCP/MCP embed); all other iids delegate to SingleComponentEffect unchanged. Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, @@ -276,6 +302,23 @@ public: std::string usageInstanceGuid(); private: + // Builds the unit and parameter lists from the derived exposed set. Called once, from + // initialize(). + void buildParameterList(); + + // The normalized value a control reads at, from the model — the projection §6.1 calls a + // third surface. Master gain reads the processor's own atomic; everything else reads the + // parameter set through the deck's binding. + double modelParamNormalized(const InstrumentParams& params, + instrument::ui::DeckParam deck) const; + + // Notifies the host of every exposed control whose value differs between the two parameter + // sets. Called from setInstrumentParams — the ONE funnel every writer already goes through — + // so no internal write can leave the host displaying, and on next touch re-imposing, a + // superseded value. The bake's reset is the first non-gesture writer this covers. + void notifyParamsFromModel(const InstrumentParams& before, const InstrumentParams& after); + void notifyParamChanged(instrument::param::ParamId id, double normalized); + // If process() published that the drain instrument is fully idle, move it into the // graveyard and prune — so an edited-away snapshot stops costing memory as soon as its // tails die. Off the audio thread only (driven by pollBankSync); safe against a racing @@ -333,6 +376,12 @@ private: ReaperBridge bridge_; + // The parameter whose drag bracket is currently open, 0 for none. UI thread only. + instrument::param::ParamId openGestureId_ = 0; + // Set across setState so a LOAD is not reflected back to the host as an edit. Main thread + // only, and non-atomic on purpose: the SDK calls setState there and nowhere else. + bool paramNotifySuppressed_ = false; + // The ONE live-parameter block for this instance, declared ahead of the instrument slots // so it outlives every snapshot that points at it (members destruct in reverse order). // Both live_ and draining_ observe this same block — a block owned by a snapshot would diff --git a/tests/test_deck_groups_state.cpp b/tests/test_deck_groups_state.cpp index 9a7bb62..2703fc0 100644 --- a/tests/test_deck_groups_state.cpp +++ b/tests/test_deck_groups_state.cpp @@ -40,6 +40,10 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve, DeckParam::kFilterEnvReleaseCurve, DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve, + // The one live control that does not ride the live block: a lock-free atomic the audio + // thread applies as a post-sum multiply. The tier answers "does an edit reach the audio + // without a reload", not "which mechanism carries it". + DeckParam::kMasterGain, }; for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live); @@ -60,7 +64,7 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode, DeckParam::kVoiceCount, DeckParam::kVoiceMode, - DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable, + DeckParam::kMonoTrigger, DeckParam::kLimiterEnable, DeckParam::kMasterMeter, DeckParam::kMasterGr, }; for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload); @@ -96,7 +100,10 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() { // it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob). CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched); CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload); - CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload); + // Master gain is Live and reaches the audio BESIDE the live block rather than through it — + // one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would + // claim a gain move re-decodes the WAV, which it never did. + CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Live); CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload); // The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id // are not parameter-set controls, so they must never reach the enum. diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index 65eaef6..faedc2d 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -20,12 +20,6 @@ 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 std::string msLabel(double seconds) { - char buf[24]; - formatEnvTimeMs(seconds, buf, sizeof(buf)); - return std::string(buf); -} - // The stage-time ceiling has TWO names — the overlay's schematic domain and the knob's — and they // must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed. static void testTheTwoCeilingNamesAreOneNumber() { @@ -406,28 +400,6 @@ static void testTheFilterFourKeepTheirIdentityTaper() { } } -// One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a -// decimal rather than reading as a bare zero, and a multi-second one stays in ms rather than -// switching units mid-deck. -static void testTimeConstantsAlwaysReadInMilliseconds() { - CHECK(msLabel(0.0) == "0.0 ms"); - CHECK(msLabel(0.0005) == "0.5 ms"); // sub-millisecond - CHECK(msLabel(0.0094) == "9.4 ms"); - CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading - CHECK(msLabel(0.25) == "250 ms"); - CHECK(msLabel(1.5) == "1500 ms"); // multi-second, still ms - CHECK(msLabel(kEnvTimeMaxSeconds) == "10000 ms"); - // The 10 ms hinge belongs to the integer form, not the decimal one. - CHECK(msLabel(0.01) == "10 ms"); - CHECK(msLabel(0.0099) == "9.9 ms"); - - // Never overruns a short buffer, and always terminates. - char tiny[4]; - std::memset(tiny, 'x', sizeof(tiny)); - formatEnvTimeMs(1.5, tiny, sizeof(tiny)); - CHECK(tiny[3] == '\0'); -} - int main() { testTheTwoCeilingNamesAreOneNumber(); testNormRoundTripsThroughEveryValueDomain(); @@ -442,7 +414,6 @@ int main() { testShiftSnapsToAWholeUnitOfTheDisplayedValue(); testAValueStoredUnderTheOldCeilingIsReadNotRewritten(); testTheFilterFourKeepTheirIdentityTaper(); - testTimeConstantsAlwaysReadInMilliseconds(); if (g_fail) { std::printf("%d FAILURE(S)\n", g_fail); return 1; diff --git a/tests/test_master_gain.cpp b/tests/test_master_gain.cpp index 50b098e..39c1e1b 100644 --- a/tests/test_master_gain.cpp +++ b/tests/test_master_gain.cpp @@ -90,18 +90,6 @@ static void testBelowFloorCollapsesToBottom() { CHECK(masterGainNormFromDb(kMasterGainMinDb + 1e-9) > 0.0); } -static void testLabels() { - char buf[24]; - formatMasterGainLabel(0.0, buf, sizeof(buf)); - CHECK(std::strcmp(buf, "-inf") == 0); - formatMasterGainLabel(1.0, buf, sizeof(buf)); - CHECK(std::strcmp(buf, "+24.0dB") == 0); - formatMasterGainLabel(masterGainNormFromDb(0.0), buf, sizeof(buf)); - CHECK(std::strcmp(buf, "+0.0dB") == 0); - formatMasterGainLabel(masterGainNormFromDb(-12.0), buf, sizeof(buf)); - CHECK(std::strcmp(buf, "-12.0dB") == 0); -} - int main() { testBottomIsTrueSilence(); testEndpoints(); @@ -110,7 +98,6 @@ int main() { testMonotonic(); testNonFiniteLinearClamps(); testBelowFloorCollapsesToBottom(); - testLabels(); if (g_fail) { std::printf("%d FAILURE(S)\n", g_fail); return 1; diff --git a/tests/test_param_format.cpp b/tests/test_param_format.cpp new file mode 100644 index 0000000..2464271 --- /dev/null +++ b/tests/test_param_format.cpp @@ -0,0 +1,174 @@ +// Standalone tests for the ONE formatter per unit category: the digit shapes each category +// prints, and the property that makes the editor's knob label and the host's parameter string +// identical — both call THIS function, over a plain value derived the way each surface derives +// it. No VST3, no REAPER, no framework. + +#include "../src/core/instrument/param/param_format.h" + +#include "../src/core/instrument/engine/master_gain.h" +#include "../src/core/instrument/param/param_id.h" +#include "../src/core/instrument/ui/deck_values.h" + +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::param; +using reasampler::instrument::map::PlaySeconds; +using reasampler::instrument::ui::DeckParam; + +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 std::string digits(UnitKind kind, double plain) { + char buf[24]; + formatPlain(kind, plain, buf, sizeof(buf)); + return std::string(buf); +} + +static void testEachCategoryPrintsItsSpecifiedShape() { + // A time constant never switches to seconds, so the ceiling reads 10000 and not 10. + CHECK(digits(UnitKind::Time, 0.5) == "0.5"); + CHECK(digits(UnitKind::Time, 3.0) == "3.0"); + CHECK(digits(UnitKind::Time, 10.0) == "10"); + CHECK(digits(UnitKind::Time, 104.0) == "104"); + CHECK(digits(UnitKind::Time, 10000.0) == "10000"); + // Semitones are always signed, including at zero — an unsigned "0.0" beside a "+3.5" reads + // as a different kind of quantity. + CHECK(digits(UnitKind::Semitones, 3.5) == "+3.5"); + CHECK(digits(UnitKind::Semitones, -12.0) == "-12.0"); + CHECK(digits(UnitKind::Semitones, 0.0) == "+0.0"); + CHECK(digits(UnitKind::PercentUnipolar, 100.0) == "100"); + CHECK(digits(UnitKind::PercentKeyTrack, 200.0) == "200"); + CHECK(digits(UnitKind::PercentBipolar, -40.0) == "-40"); + CHECK(digits(UnitKind::PercentBipolar, 40.0) == "+40"); + // Rate keeps a decimal: its snap grid is whole semitones, which do not land on integer + // percent, so an integer display would print a snapped position the snap cannot produce. + CHECK(digits(UnitKind::PercentRate, 105.946) == "105.9"); + CHECK(digits(UnitKind::PercentRate, 200.0) == "200.0"); + CHECK(digits(UnitKind::Decibels, 0.0) == "+0.0"); + CHECK(digits(UnitKind::Decibels, -12.0) == "-12.0"); + CHECK(digits(UnitKind::Decibels, -std::numeric_limits::infinity()) == "-inf"); + // Cutoff's "k" abbreviation is retired — one static units string cannot switch with + // magnitude, and keeping "12.8k" on one surface alone is the divergence this file forbids. + CHECK(digits(UnitKind::Hertz, 240.0) == "240"); + CHECK(digits(UnitKind::Hertz, 12800.0) == "12800"); + // The caret on a curve dial is the editor's static cell chrome, never part of the value. + CHECK(digits(UnitKind::Dimensionless, 1.0) == "1.00"); + CHECK(digits(UnitKind::Dimensionless, 0.1) == "0.10"); +} + +static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() { + // The host derives its plain value from the normalized one it holds; the editor derives its + // from the STORED field, through the deck's own read. If those two derivations disagreed at + // any reachable value the two surfaces would print different numbers for one control — this + // is that property, swept over the whole travel of every exposed control. + for (const ParamRow& row : exposedParams()) { + if (row.deck == DeckParam::kMasterGain) continue; // not stored in PlaySeconds + for (int step = 0; step <= 40; ++step) { + const double norm = step / 40.0; + PlaySeconds play; + reasampler::instrument::ui::setDeckParam(row.deck, play, norm, /*segment=*/0); + + char hostBuf[24]; + formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf)); + + const double editorNorm = + reasampler::instrument::ui::deckParamNorm(row.deck, play); + char editorBuf[24]; + formatPlainFor(row.deck, toPlain(row.deck, editorNorm), editorBuf, sizeof(editorBuf)); + + if (storesNormalized(row.deck)) { + // The filter's four store their position as a FLOAT, so a norm the host has sent + // but we have not yet stored differs from the stored one by up to a float ulp. + // At a value landing exactly on a display rounding boundary that is worth one + // digit, so these four are held to the PLAIN value rather than to the string — + // the derivation is still asserted to be one derivation. + const double hostPlain = toPlain(row.deck, norm); + const double editorPlain = toPlain(row.deck, editorNorm); + const double tolerance = std::fabs(hostPlain) * 1e-6 + 1e-9; + if (std::fabs(hostPlain - editorPlain) > tolerance) { + std::printf("FAIL param %u at norm %.4f: host %.9g vs editor %.9g\n", + row.id, norm, hostPlain, editorPlain); + ++g_fail; + } + continue; + } + if (std::strcmp(hostBuf, editorBuf) != 0) { + std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n", + row.id, norm, hostBuf, editorBuf); + ++g_fail; + } + } + } +} + +static void testMasterGainPrintsTheSameDigitsFromEitherSurface() { + using reasampler::instrument::engine::masterGainLinearFromNorm; + using reasampler::instrument::engine::masterGainNormFromLinear; + for (int step = 0; step <= 40; ++step) { + const double norm = step / 40.0; + // The editor reads the processor's stored LINEAR gain back through the taper; the host + // holds the normalized value directly. + const double editorNorm = masterGainNormFromLinear(masterGainLinearFromNorm(norm)); + char hostBuf[24]; + char editorBuf[24]; + formatPlainFor(DeckParam::kMasterGain, toPlain(DeckParam::kMasterGain, norm), + hostBuf, sizeof(hostBuf)); + formatPlainFor(DeckParam::kMasterGain, toPlain(DeckParam::kMasterGain, editorNorm), + editorBuf, sizeof(editorBuf)); + if (std::strcmp(hostBuf, editorBuf) != 0) { + std::printf("FAIL master gain at norm %.4f: host \"%s\" vs editor \"%s\"\n", + norm, hostBuf, editorBuf); + ++g_fail; + } + } +} + +static void testTypingBackADisplayedValueLandsOnIt() { + // getParamValueByString's half: the digits the host just showed must parse to the same + // plain value, with or without the unit a user may retype beside them. + double plain = 0.0; + CHECK(parsePlain(UnitKind::Time, "104", plain) && plain == 104.0); + CHECK(parsePlain(UnitKind::Time, "104 ms", plain) && plain == 104.0); + CHECK(parsePlain(UnitKind::Semitones, "+3.5", plain) && plain == 3.5); + CHECK(parsePlain(UnitKind::Semitones, "-12.0st", plain) && plain == -12.0); + CHECK(parsePlain(UnitKind::Hertz, "12800Hz", plain) && plain == 12800.0); + CHECK(parsePlain(UnitKind::Decibels, "-inf", plain) && !std::isfinite(plain) && plain < 0.0); + CHECK(!parsePlain(UnitKind::Time, "abc", plain)); + CHECK(!parsePlain(UnitKind::Time, nullptr, plain)); +} + +static void testAShortBufferIsNeverOverrunAndAlwaysTerminates() { + for (int kind = 0; kind <= static_cast(UnitKind::Dimensionless); ++kind) { + char tiny[4]; + std::memset(tiny, 'x', sizeof(tiny)); + formatPlain(static_cast(kind), 1500.0, tiny, sizeof(tiny)); + CHECK(tiny[3] == '\0'); + } +} + +static void testEveryExposedParameterHasAFormatterThatWritesSomething() { + for (const ParamRow& row : exposedParams()) { + char buf[24]; + formatPlainFor(row.deck, toPlain(row.deck, 0.5), buf, sizeof(buf)); + if (buf[0] == '\0') { + std::printf("FAIL param %u produced an empty string\n", row.id); + ++g_fail; + } + } +} + +int main() { + testEachCategoryPrintsItsSpecifiedShape(); + testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue(); + testMasterGainPrintsTheSameDigitsFromEitherSurface(); + testTypingBackADisplayedValueLandsOnIt(); + testAShortBufferIsNeverOverrunAndAlwaysTerminates(); + testEveryExposedParameterHasAFormatterThatWritesSomething(); + if (g_fail == 0) std::printf("param_format: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_param_id.cpp b/tests/test_param_id.cpp new file mode 100644 index 0000000..58eefdd --- /dev/null +++ b/tests/test_param_id.cpp @@ -0,0 +1,193 @@ +// Standalone tests for the FOREVER-FROZEN VST3 parameter id table and the exposed set derived +// from the commit predicate — no VST3, no REAPER, no framework. +// +// The asserted numbers are LITERALS on purpose. A test that recomputed them from cellIds, from +// the enum's position, or from the table itself would defeat the freeze it exists to hold: the +// point is that changing any id has to break this file. + +#include "../src/core/instrument/param/param_id.h" + +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::param; +using reasampler::instrument::ui::DeckParam; +using reasampler::instrument::ui::LiveCommit; +using reasampler::instrument::ui::deckParamCommit; + +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 testEveryIdHoldsTheNumberItShippedWith() { + // docs/product/parameter-automation.md 6.2, transcribed. If this list and the table + // disagree, the table moved and every automation lane recorded against it now means + // something else. + struct Expect { ParamId id; DeckParam deck; }; + const Expect kExpected[] = { + {1000, DeckParam::kKeyTrack}, + {1010, DeckParam::kRate}, + {1020, DeckParam::kPitch}, + {1100, DeckParam::kPitchEnvAttack}, + {1101, DeckParam::kPitchEnvAttackCurve}, + {1110, DeckParam::kPitchEnvHold}, + {1120, DeckParam::kPitchEnvDecay}, + {1121, DeckParam::kPitchEnvDecayCurve}, + {1130, DeckParam::kPitchEnvDepth}, + {1200, DeckParam::kFilterMorph}, + {1210, DeckParam::kFilterCutoff}, + {1220, DeckParam::kFilterQ}, + {1230, DeckParam::kFilterDrive}, + {1240, DeckParam::kFilterModAmt}, + {1250, DeckParam::kFilterVel}, + {1260, DeckParam::kFilterKeyTrack}, + {1300, DeckParam::kFilterEnvAttack}, + {1301, DeckParam::kFilterEnvAttackCurve}, + {1310, DeckParam::kFilterEnvHold}, + {1320, DeckParam::kFilterEnvDecay}, + {1321, DeckParam::kFilterEnvDecayCurve}, + {1330, DeckParam::kFilterEnvSustain}, + {1340, DeckParam::kFilterEnvRelease}, + {1341, DeckParam::kFilterEnvReleaseCurve}, + {1350, DeckParam::kFilterTrigAttack}, + {1351, DeckParam::kFilterTrigAttackCurve}, + {1360, DeckParam::kFilterTrigHold}, + {1370, DeckParam::kFilterTrigDecay}, + {1371, DeckParam::kFilterTrigDecayCurve}, + {1400, DeckParam::kAttack}, + {1401, DeckParam::kAttackCurve}, + {1410, DeckParam::kHold}, + {1420, DeckParam::kDecay}, + {1421, DeckParam::kDecayCurve}, + {1430, DeckParam::kSustain}, + {1440, DeckParam::kRelease}, + {1441, DeckParam::kReleaseCurve}, + {1450, DeckParam::kTrigLength}, + {1460, DeckParam::kTrigAttack}, + {1461, DeckParam::kTrigAttackCurve}, + {1470, DeckParam::kTrigHold}, + {1480, DeckParam::kTrigDecay}, + {1481, DeckParam::kTrigDecayCurve}, + {1700, DeckParam::kMasterGain}, + }; + const std::size_t expectedCount = sizeof(kExpected) / sizeof(kExpected[0]); + CHECK(expectedCount == 44); + CHECK(paramTable().size() == expectedCount); + if (paramTable().size() != expectedCount) return; + for (std::size_t i = 0; i < expectedCount; ++i) { + CHECK(paramTable()[i].id == kExpected[i].id); + CHECK(paramTable()[i].deck == kExpected[i].deck); + CHECK(paramIdFor(kExpected[i].deck) == kExpected[i].id); + } +} + +static void testIdsAreUniqueAscendingInBlockAndOnStep() { + std::set seen; + ParamId previous = 0; + for (const ParamRow& row : paramTable()) { + CHECK(seen.insert(row.id).second); // unique + CHECK(row.id > previous); // ascending == presentation order + previous = row.id; + CHECK(row.id >= 1000); // 0 is a plausible accident, never an id + const ParamId withinBlock = row.id % 100; + // On the step, or one past it — a curve dial takes its outer knob's id + 1, and only a + // curve dial may. + const bool onStep = withinBlock % 10 == 0; + const bool innerDial = withinBlock % 10 == 1; + CHECK(onStep || innerDial); + } +} + +static void testAnInnerDialSitsBesideTheKnobItShapes() { + for (const ParamRow& row : paramTable()) { + if (row.id % 10 != 1) continue; + // Its outer knob is the row numbered one below it, and curveParamFor must agree that the + // dial belongs to that knob. + const ParamRow* outer = nullptr; + for (const ParamRow& candidate : paramTable()) { + if (candidate.id == row.id - 1) outer = &candidate; + } + CHECK(outer != nullptr); + if (outer) CHECK(reasampler::instrument::ui::curveParamFor(outer->deck) == row.deck); + } +} + +static void testABlockCarriesOnlyItsOwnGroup() { + for (const ParamRow& row : paramTable()) { + const ParamId block = row.id / 100 * 100; + switch (block) { + case 1000: CHECK(row.unit == kUnitPitch); break; + case 1100: CHECK(row.unit == kUnitPitchEnv); break; + case 1200: CHECK(row.unit == kUnitFilter); break; + case 1300: CHECK(row.unit == kUnitFilterEnv); break; + case 1400: CHECK(row.unit == kUnitAmp); break; + case 1700: CHECK(row.unit == kUnitMaster); break; + default: CHECK(false); break; // 1500/1600 are reserved and must stay empty + } + } +} + +static void testTheExposedSetIsExactlyThePredicateAnswer() { + // Asserted against deckParamCommit, never against a literal count: the list follows the + // predicate, and the predicate is never bent to fill the list. + for (const ParamRow& row : paramTable()) { + const bool live = deckParamCommit(row.deck) != LiveCommit::Reload; + CHECK(isExposed(row.deck) == live); + const bool listed = exposedRowFor(row.id) != nullptr; + CHECK(listed == live); + } + for (const ParamRow& row : exposedParams()) { + CHECK(deckParamCommit(row.deck) != LiveCommit::Reload); + } + // Every control the predicate calls live must HAVE a row — a live control with no number is + // a parameter the host can never be told about. + for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { + const auto deck = static_cast(i); + if (deckParamCommit(deck) == LiveCommit::Reload) continue; + CHECK(paramIdFor(deck) != 0); + } +} + +static void testAReservedRowIsNumberedButNotIssued() { + // Key-track (pitch) and Trigger length are note-on-latch candidates that still route through + // the reload tier, so they are NOT issued to the host today. Their numbers stay reserved + // rather than retired: nothing shipped under them, so a later promotion issues the same id + // and no other id moves. + CHECK(paramIdFor(DeckParam::kKeyTrack) == 1000); + CHECK(paramIdFor(DeckParam::kTrigLength) == 1450); + CHECK(!isExposed(DeckParam::kKeyTrack)); + CHECK(!isExposed(DeckParam::kTrigLength)); + CHECK(exposedRowFor(1000) == nullptr); + CHECK(exposedRowFor(1450) == nullptr); + // Master gain IS issued: one atomic store the audio thread picks up next block is the live + // tier by that tier's own definition. + CHECK(isExposed(DeckParam::kMasterGain)); + CHECK(exposedRowFor(1700) != nullptr); + CHECK(exposedParams().size() == paramTable().size() - 2); +} + +static void testEveryRowCarriesADistinctTitleAndShortTitle() { + std::set titles; + std::set shortTitles; + for (const ParamRow& row : paramTable()) { + CHECK(row.title && row.title[0] != '\0'); + CHECK(row.shortTitle && row.shortTitle[0] != '\0'); + CHECK(std::string(row.title) != std::string(row.shortTitle)); + CHECK(titles.insert(row.title).second); + CHECK(shortTitles.insert(row.shortTitle).second); + } +} + +int main() { + testEveryIdHoldsTheNumberItShippedWith(); + testIdsAreUniqueAscendingInBlockAndOnStep(); + testAnInnerDialSitsBesideTheKnobItShapes(); + testABlockCarriesOnlyItsOwnGroup(); + testTheExposedSetIsExactlyThePredicateAnswer(); + testAReservedRowIsNumberedButNotIssued(); + testEveryRowCarriesADistinctTitleAndShortTitle(); + if (g_fail == 0) std::printf("param_id: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_param_units.cpp b/tests/test_param_units.cpp new file mode 100644 index 0000000..7ca378d --- /dev/null +++ b/tests/test_param_units.cpp @@ -0,0 +1,191 @@ +// Standalone tests for the plain-value layer: unit strings, plain ranges, monotonicity, the +// exact-preimage requirement at every default, and the read-side-only property of the filter's +// four — no VST3, no REAPER, no framework. + +#include "../src/core/instrument/param/param_units.h" + +#include "../src/core/instrument/param/param_id.h" +#include "../src/core/instrument/engine/filter/filter_params.h" +#include "../src/core/instrument/map/play_seconds.h" +#include "../src/core/instrument/ui/deck_values.h" + +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::param; +using reasampler::instrument::map::PlaySeconds; +using reasampler::instrument::ui::DeckParam; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) +#define CHECK_ID(cond, id) do { if(!(cond)) { \ + std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0) + +static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() { + struct Expect { ParamId id; const char* units; double min; double max; }; + // docs/product/parameter-automation.md 6.7.1, per parameter rather than per category, so a + // control silently reclassified into the wrong category fails here. + const Expect kExpected[] = { + {kParamRate, "%", 50.0, 200.0}, + {kParamPitchOffset, "st", -24.0, 24.0}, + {kParamPitchEnvAttack, "ms", 0.0, 10000.0}, + {kParamPitchEnvAttackCurve, "", 0.1, 10.0}, + {kParamPitchEnvHold, "%", 0.0, 100.0}, + {kParamPitchEnvDecay, "ms", 0.0, 10000.0}, + {kParamPitchEnvDecayCurve, "", 0.1, 10.0}, + {kParamPitchEnvDepth, "st", -24.0, 24.0}, + {kParamFilterMorph, "%", 0.0, 100.0}, + {kParamFilterCutoff, "Hz", 20.0, 20000.0}, + {kParamFilterQ, "", 0.1, 10.0}, + {kParamFilterDrive, "", 0.0, 4.0}, + {kParamFilterModAmount, "%", -100.0, 100.0}, + {kParamFilterVelAmount, "%", -100.0, 100.0}, + {kParamKeyTrackFilter, "%", 0.0, 200.0}, + {kParamFilterEnvAttack, "ms", 0.0, 10000.0}, + {kParamFilterEnvAttackCurve, "", 0.1, 10.0}, + {kParamFilterEnvHold, "ms", 0.0, 10000.0}, + {kParamFilterEnvDecay, "ms", 0.0, 10000.0}, + {kParamFilterEnvDecayCurve, "", 0.1, 10.0}, + {kParamFilterEnvSustain, "%", 0.0, 100.0}, + {kParamFilterEnvRelease, "ms", 0.0, 10000.0}, + {kParamFilterEnvReleaseCurve, "", 0.1, 10.0}, + {kParamFilterTrigAttack, "ms", 0.0, 10000.0}, + {kParamFilterTrigAttackCurve, "", 0.1, 10.0}, + {kParamFilterTrigHold, "%", 0.0, 100.0}, + {kParamFilterTrigDecay, "ms", 0.0, 10000.0}, + {kParamFilterTrigDecayCurve, "", 0.1, 10.0}, + {kParamAmpAttack, "ms", 0.0, 10000.0}, + {kParamAmpAttackCurve, "", 0.1, 10.0}, + {kParamAmpHold, "ms", 0.0, 10000.0}, + {kParamAmpDecay, "ms", 0.0, 10000.0}, + {kParamAmpDecayCurve, "", 0.1, 10.0}, + {kParamAmpSustain, "%", 0.0, 100.0}, + {kParamAmpRelease, "ms", 0.0, 10000.0}, + {kParamAmpReleaseCurve, "", 0.1, 10.0}, + {kParamAmpTrigAttack, "ms", 0.0, 10000.0}, + {kParamAmpTrigAttackCurve, "", 0.1, 10.0}, + {kParamAmpTrigHold, "%", 0.0, 100.0}, + {kParamAmpTrigDecay, "ms", 0.0, 10000.0}, + {kParamAmpTrigDecayCurve, "", 0.1, 10.0}, + {kParamMasterGain, "dB", -60.0, 24.0}, + }; + const std::size_t count = sizeof(kExpected) / sizeof(kExpected[0]); + // Every exposed parameter is covered, and nothing else is listed. + CHECK(count == exposedParams().size()); + for (const Expect& e : kExpected) { + const ParamRow* row = exposedRowFor(e.id); + CHECK_ID(row != nullptr, e.id); + if (!row) continue; + CHECK_ID(std::strcmp(unitStringFor(row->deck), e.units) == 0, e.id); + const PlainRange range = plainRangeFor(row->deck); + // Relative rather than exact: the filter's three endpoints are float constants, so + // 0.1f widened is not the double 0.1. A wrong RANGE — 0.2, or 20 — still fails. + CHECK_ID(std::fabs(range.min - e.min) <= std::fabs(e.min) * 1e-6 + 1e-12, e.id); + CHECK_ID(std::fabs(range.max - e.max) <= std::fabs(e.max) * 1e-6 + 1e-12, e.id); + } +} + +static void testEveryDefaultHasAnExactNormalizedPreimage() { + // A host's reset-to-default arrives as toPlain(defaultNormalizedValue) and there is no + // editor-side taper bypass available to it. Exactly equal, not near: a gain landing a hair + // off unity is an audible error, and a stage time landing a hair off its default is a value + // the user never dialled. + for (const ParamRow& row : exposedParams()) { + const double norm = defaultNormalized(row.deck); + CHECK_ID(norm >= 0.0 && norm <= 1.0, row.id); + CHECK_ID(toPlain(row.deck, norm) == defaultPlain(row.deck), row.id); + } +} + +static void testTheFiltersFourTakeTheirStoredNormVerbatim() { + // Their stored value IS the normalized one, so no taper may participate in their default: + // this fails the moment someone routes them through toNormalized(toPlain(x)). + PlaySeconds defaults; + const DeckParam kStoredNorm[] = {DeckParam::kFilterMorph, DeckParam::kFilterCutoff, + DeckParam::kFilterQ, DeckParam::kFilterDrive}; + for (DeckParam deck : kStoredNorm) { + CHECK(storesNormalized(deck)); + const float* stored = reasampler::instrument::ui::deckFloatField(deck, defaults); + CHECK(stored != nullptr); + if (stored) CHECK(defaultNormalized(deck) == static_cast(*stored)); + } + // And nothing else claims to store its norm — a control wrongly in that set would silently + // skip the taper on the reset path. + for (const ParamRow& row : exposedParams()) { + const bool listed = row.deck == DeckParam::kFilterMorph || + row.deck == DeckParam::kFilterCutoff || + row.deck == DeckParam::kFilterQ || + row.deck == DeckParam::kFilterDrive; + CHECK_ID(storesNormalized(row.deck) == listed, row.id); + } +} + +static void testToPlainIsMonotoneAcrossTheWholeTravel() { + // Monotonicity is required everywhere; round-trip exactness at an arbitrary norm is required + // NOWHERE and is deliberately not asserted — no log map delivers it in double, and demanding + // it would rule out the taper the range needs. + for (const ParamRow& row : exposedParams()) { + double previous = toPlain(row.deck, 0.0); + for (int step = 1; step <= 200; ++step) { + const double plain = toPlain(row.deck, step / 200.0); + CHECK_ID(plain >= previous, row.id); + previous = plain; + } + } +} + +static void testTheEndpointsAreTheDeclaredPlainRange() { + for (const ParamRow& row : exposedParams()) { + const PlainRange range = plainRangeFor(row.deck); + const double top = toPlain(row.deck, 1.0); + CHECK_ID(std::fabs(top - range.max) <= std::fabs(range.max) * 1e-6 + 1e-9, row.id); + if (row.deck == DeckParam::kMasterGain) { + // Norm 0 is TRUE silence, not the -60 dB floor — the one plain value outside the + // declared range, and the reason the dB formatter has an -inf case at all. + CHECK(!std::isfinite(toPlain(row.deck, 0.0))); + continue; + } + const double bottom = toPlain(row.deck, 0.0); + CHECK_ID(std::fabs(bottom - range.min) <= std::fabs(range.min) * 1e-6 + 1e-9, row.id); + } +} + +static void testNoExposedControlIsDiscrete() { + // This is what makes "stepCount = 0 on all of them" structural rather than lucky: stepCount + // is only meaningful for a discrete control, and every discrete control is reload or rebuild + // tier and therefore never reaches the list. UnitCategory::None is the deck's own name for + // "no continuous unit" — toggles, radios, the curve-popup cells, the integer voice count. + for (const ParamRow& row : exposedParams()) { + CHECK_ID(reasampler::instrument::ui::deckParamUnit(row.deck) != + reasampler::instrument::ui::UnitCategory::None, + row.id); + } +} + +static void testTheAddedDriveInverseUndoesTheFrozenLaw() { + using reasampler::instrument::engine::filter::filterDriveDepthFromNorm; + using reasampler::instrument::engine::filter::filterNormFromDriveDepth; + for (int step = 0; step <= 100; ++step) { + const float norm = static_cast(step) / 100.0f; + const float back = filterNormFromDriveDepth(filterDriveDepthFromNorm(norm)); + CHECK(std::fabs(back - norm) < 1e-6f); + } + CHECK(filterNormFromDriveDepth(0.0f) == 0.0f); + CHECK(filterNormFromDriveDepth(-1.0f) == 0.0f); + CHECK(filterNormFromDriveDepth(1000.0f) == 1.0f); +} + +int main() { + testEveryUnitStringAndRangeMatchesTheSpecifiedTable(); + testEveryDefaultHasAnExactNormalizedPreimage(); + testTheFiltersFourTakeTheirStoredNormVerbatim(); + testToPlainIsMonotoneAcrossTheWholeTravel(); + testTheEndpointsAreTheDeclaredPlainRange(); + testNoExposedControlIsDiscrete(); + testTheAddedDriveInverseUndoesTheFrozenLaw(); + if (g_fail == 0) std::printf("param_units: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} From de5654fb6fb2675705b3c55f25ef3a5960616221 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 16:22:17 -0400 Subject: [PATCH 48/56] Service both VST3 parameter channels, and promote pitch key-track and Trigger length so all 44 ids issue The SDK's own single-component sample drains inputParameterChanges in process() and implements setParamNormalized; automation was reading the GUI channel alone. The audio thread now patches a block it solely owns. --- docs/PLAN.md | 43 +++-- docs/TODO.md | 17 ++ docs/product/parameter-automation.md | 31 ++- src/core/instrument/CLAUDE.md | 4 +- .../instrument/engine/filter/CMakeLists.txt | 10 +- src/core/instrument/engine/live_params.cpp | 7 +- src/core/instrument/engine/live_params.h | 23 ++- src/core/instrument/engine/master_gain.cpp | 7 + src/core/instrument/engine/play_params.h | 7 +- src/core/instrument/engine/voice.cpp | 10 +- src/core/instrument/engine/voice.h | 19 +- src/core/instrument/engine/voice_engine.cpp | 9 +- src/core/instrument/map/play_seconds.h | 13 ++ src/core/instrument/map/sample_map.cpp | 6 +- src/core/instrument/map/sample_map.h | 4 +- src/core/instrument/param/CLAUDE.md | 54 ++++-- src/core/instrument/param/CMakeLists.txt | 27 ++- src/core/instrument/param/param_live.cpp | 105 +++++++++++ src/core/instrument/param/param_live.h | 29 +++ src/core/instrument/param/param_units.cpp | 63 ++++++- src/core/instrument/param/param_units.h | 11 ++ src/core/instrument/ui/deck_groups.cpp | 6 +- src/core/instrument/ui/deck_values.cpp | 131 ++++++------- src/core/instrument/ui/deck_values.h | 14 ++ src/shell/instrument/CLAUDE.md | 47 +++-- src/shell/instrument/CMakeLists.txt | 2 +- src/shell/instrument/editor_controls.cpp | 20 +- .../instrument/editor_input_waveform.cpp | 4 + src/shell/instrument/editor_platform.cpp | 5 + src/shell/instrument/editor_session.cpp | 4 + src/shell/instrument/instrument_bake.cpp | 3 + src/shell/instrument/instrument_params.cpp | 177 ++++++++++++++---- src/shell/instrument/processor_reload.cpp | 26 ++- src/shell/instrument/processor_state.cpp | 46 +++-- src/shell/instrument/reasampler_processor.cpp | 29 +++ src/shell/instrument/reasampler_processor.h | 105 ++++++++--- tests/test_bake_render.cpp | 2 +- tests/test_deck_groups_state.cpp | 13 +- tests/test_live_delivery.cpp | 36 ++-- tests/test_live_params.cpp | 20 +- tests/test_param_format.cpp | 106 ++++++++--- tests/test_param_id.cpp | 23 +-- tests/test_param_live.cpp | 152 +++++++++++++++ tests/test_param_units.cpp | 37 ++++ 44 files changed, 1205 insertions(+), 302 deletions(-) create mode 100644 src/core/instrument/param/param_live.cpp create mode 100644 src/core/instrument/param/param_live.h create mode 100644 tests/test_param_live.cpp diff --git a/docs/PLAN.md b/docs/PLAN.md index 72dcffe..6d8a291 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1277,7 +1277,12 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). sound intact; and a project with automation drawn, saved and reopened, replays against the same plain values. - **`process()` takes no new indirection and no new per-sample work** — the parameter read is - a block-boundary act, on the existing live-publish path. + a block-boundary act. **Correction to the original wording ("on the existing live-publish + path"):** it cannot be, and the SDK is what decides that. `IParameterChanges` is delivered ON + the audio thread, and the model's publish path allocates (`resolvePlay` copies velocity curves + and spline contours), so the drain lands in `process()` and patches the live block in place. + The block-boundary rule is unchanged and the per-sample path is untouched; what moved is which + thread performs the fold. - **The bake's reset notifies the host**, verified by the host's displayed value following it rather than snapping back on next touch. - **The double-processing limitation is documented, not discovered** — a bake whose @@ -1297,11 +1302,17 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). - **No [Daniel] questions. Γ-F7 is RULED — signal flow** (2026-08-01, *"signal flow order."*), and Ruling 3 (real units) arrived specified rather than forked. **There is no unanswered [Daniel]-class question in this track or anywhere in this plan.** -- **[verify, FIRST]** that REAPER calls `setState` (not `setComponentState`) on a - single-component plug-in, and the ordering of `setState` against the first - `IParameterChanges` block after a project load. §6.1 is built on the SDK's own - name-collapse; **verify it in the DAW before wiring, and do not build on the paragraph - alone.** +- **CLOSED from the SDK, not the DAW.** Two things were bundled here and they separate. + (a) The DELIVERY CHANNEL: `ivsteditcontroller.h` documents `setParamNormalized` as the + GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own + `SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains + `ProcessData::inputParameterChanges` in `process()` while also implementing + `setParamNormalized`. **Both are serviced.** This was never a DAW question — the headers + answer it, and building on the paragraph alone is exactly what the first pass did. + (b) The ORDERING of `setState` against the first parameter block: no longer a question. An + automation point held by the audio thread is re-applied over every merge, so a written lane + outranks the restore whichever way round the two arrive — VST3's own rule. What survives as + DAW work is recorded in `docs/TODO.md`, and none of it can change the frozen contract. - **[verify]** whether REAPER renders `ParameterInfo::units` beside the string `getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own convention** — digits in the string, unit carried separately, which is what @@ -1309,16 +1320,16 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). REAPER shows no unit at all, the fallback is to append the unit **inside the one formatter**: a one-line change in one place, touching neither the frozen id table nor the editor, because display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping. -- **[propose at review]** promoting **key-track** and **Trigger length** from `Reload` to - `NoteOnLatched` (§7.4). Both are excluded from the live set *for the note-on-latch reason* - in the predicate's own words, so the promotion aligns routing with documented semantics — - and it is what makes them automatable at all. **If either promotion is refused, that - control simply drops out of the parameter list.** The list follows the predicate; the - predicate is never bent to fill the list. **Consequence for the frozen table:** a refusal - drops ids 1000 and 1260 (key-track) or 1450 (Trigger length) and the count falls below 44. - Those slots are then simply **never issued** — not retired, since nothing shipped under - them — and remain available to the same control if it is promoted later. No other id moves; - that is what the block-and-step scheme buys. +- **RULED (Daniel, 2026-08-02): promote both.** **Key-track** and **Trigger length** move from + `Reload` to `NoteOnLatched` (§7.4) and are exposed; ids **1000** and **1450** issue and the + count is **44 of 44**. **The promotion is NOT the predicate-only change this bullet originally + advertised** — the predicate flip is the smallest part of it. Key-track lives on + `InstrumentParams`, not `PlaySeconds`, so the host's write path (`setDeckParam`/`deckParamNorm`) + structurally could not see it and id 1000 would have no-oped in both directions with no + compile-time guard; both controls also had to reach the engine, which meant widening + `LiveValues` and `foldLive`'s input and handing `Voice::start` the two latched values as + arguments beside the rate. The guard that closes the class is `param::valueHomeFor`, asserted + over the exposed set. - **[propose at review]** whether to ship a default `IMidiMapping` CC table here or leave MIDI control to REAPER's host-side learn. Either is defensible; **skipping it silently is not.** - **[propose at review]** whether this track spends the reserved payload rung. §6.1 says diff --git a/docs/TODO.md b/docs/TODO.md index bd0f01f..6deb626 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -819,3 +819,20 @@ doc-keeper edit. **Done looks like.** The enumeration distinguishes "in the project's state" from "on disk in the `.rpp`", and does not gain a second home for the distinction. + +## The VST3 parameter surface's DAW-verifiable claims + +**Context (what shipped — Γ-W4-T1).** The instrument reports 44 automatable parameters under the frozen id table, services both delivery channels (the controller's `setParamNormalized` and the audio thread's `IParameterChanges` drain), and folds automated values back into the blob on the UI thread. + +**What is settled without a DAW.** The channel question itself is answered by the vendored SDK, not by observation: `ivsteditcontroller.h` documents `setParamNormalized` as the GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own `SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains `ProcessData::inputParameterChanges` in `process()` while also implementing `setParamNormalized`. Servicing both is what the SDK's own precedent does; it needs no verification, only exercise. + +**What genuinely needs a running REAPER, and why none of it can change the design.** Each item below is a host BEHAVIOUR, not a contract — the plug-in is correct under either answer, so discovering the answer costs a display fix at worst: + +1. **Whether REAPER renders `ParameterInfo::units` beside the string `getParamStringByValue` returns, or shows the string alone.** We ship the SDK's own convention (digits in the string, unit carried separately). If REAPER shows no unit at all, the fallback is to append the unit inside the one formatter — one line in one place, touching neither the frozen id table nor the editor, because display strings are explicitly not frozen. +2. **Whether REAPER's own per-parameter MIDI learn covers what a shipped `IMidiMapping` CC table would have.** The decision to ship no default map rests on it; if learn does not reach these parameters, a CC table is additive and frozen by nothing. +3. **That the three migration round trips hold**: a pre-parameter project opens with every parameter reading the blob's value and sounds identical; a project saved by this build restores fully in an older binary; a project with automation drawn, saved and reopened, replays against the same plain values. +4. **That an offline render replays automation** — the sharpest case for the audio-side drain, because the host drives `process()` and may never touch the controller. + +**Priority / risk.** Low. Nothing here is load-bearing on the frozen contract: the id table, the plain ranges and the norm↔plain laws are all decided and tested without a host. + +**Done looks like.** Each of the four exercised once in REAPER, with the unit-rendering answer recorded and, if it went the other way, the one-line formatter change made. diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index 0fed5e7..2b46bb9 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -336,10 +336,18 @@ invariant Θ-W1-T1 was run to establish. > automation and is not a defect to design away — but it has one sharp consequence for the > resample bake, and that is §9. -**[verify] at the track, before wiring:** that REAPER calls `setState` (not -`setComponentState`) on a single-component plug-in, and the ordering of `setState` against -the first `IParameterChanges` block after a project load. Verify against the vendored SDK -and in the DAW — do not build on the paragraph above without it. +**SETTLED at the track, from the vendored SDK.** The delivery question the `[verify]` here +bundled is answered by the headers rather than by the DAW: `setParamNormalized` is documented as +the GUI-update channel (*"should update the according GUI element(s) only"*, +`ivsteditcontroller.h`), and `ProcessData::inputParameterChanges` is the audio-side one — the +SDK's own `SingleComponentEffect` sample services BOTH +(`public.sdk/samples/vst/again/source/againsimple.cpp`), and so do we. The `setState` ordering +half dissolves with it: an automation point held by the audio thread is re-applied over every +merge, so a written lane outranks the restore whichever way round the two arrive, which is +VST3's own authority rule rather than a race. **The audio thread cannot run the model path** +(`resolvePlay` copies velocity curves and spline contours, so it allocates), so the drain patches +the live block in place through one pure RT-safe function whose routing is pinned by an +exhaustive equivalence test against the model path. ### 6.2 The ID space: hand-assigned constants in one frozen table @@ -897,11 +905,16 @@ state fits them exactly: - **Trigger length** — *"resolves `playEnd_`, a fact about the note, not a setting of it."* Same shape. -**[propose at review, Γ-W4-T1]** promote both. The promotion aligns the routing with the -predicate's own stated semantics — and it is what makes them automatable, since today they -would re-decode a WAV per automation point. **If either promotion is refused, that control -simply drops out of the parameter list.** The list follows the predicate; the predicate is -never bent to fill the list. +**RULED (Daniel, 2026-08-02): promote both.** Ids 1000 and 1450 issue; the count is 44 of 44. +The promotion aligns the routing with the predicate's own stated semantics — and it is what +makes them automatable, since otherwise they would re-decode a WAV per automation point. + +**It was not the predicate-only change this section implied.** Key-track lives on +`InstrumentParams`, not `PlaySeconds`, so the host's write path could not reach it and id 1000 +would have no-oped in both directions with nothing failing to compile; both controls also had to +reach the engine, which widened `LiveValues` and `foldLive`'s input and gave `Voice::start` the +two latched values as arguments beside the rate. `param::valueHomeFor`, asserted over the +exposed set, is what makes the next promotion of this shape a test failure instead of a silence. **Not promoted, and not proposed for promotion: Rate to Live.** §3.5 records the cost; that paragraph is the first thing to read if it is ever proposed. diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 64f35ef..5563d48 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -1,8 +1,8 @@ -# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui) +# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / param / ui) ## Scope -The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five +The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in six subdirectories: - **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting, diff --git a/src/core/instrument/engine/filter/CMakeLists.txt b/src/core/instrument/engine/filter/CMakeLists.txt index 6765ad0..934fe66 100644 --- a/src/core/instrument/engine/filter/CMakeLists.txt +++ b/src/core/instrument/engine/filter/CMakeLists.txt @@ -1,10 +1,16 @@ # Control mapping, SVF coefficients, morph weights, and the filter type each get their own # TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site. +# The frozen control-position laws are their own target: they are the filter's PARAMETER +# surface, and the VST3 parameter layer reports Hz/Q/drive through them. Kept separable so +# that consumer does not take a link edge onto the per-voice kernel — the extension's link +# graph must never be able to reach the voice DSP (root CLAUDE.md, the bake invariant). +reasampler_pure_library(filter_params SOURCES filter_params.cpp) + reasampler_pure_library(filter SOURCES - filter_params.cpp filter_coeffs.cpp filter_morph.cpp - voice_filter.cpp) + voice_filter.cpp + LINK PUBLIC filter_params) # Four test targets along the module's own seams so each asserts one domain. filter_tests # alone owns the analytic reference and the steady-state gain measurement — a forked copy of diff --git a/src/core/instrument/engine/live_params.cpp b/src/core/instrument/engine/live_params.cpp index 2153782..6dbb219 100644 --- a/src/core/instrument/engine/live_params.cpp +++ b/src/core/instrument/engine/live_params.cpp @@ -5,8 +5,13 @@ namespace reasampler::instrument::engine { -LiveValues foldLive(const PlayParams& params) { +LiveValues foldLive(const PlayParams& params, double keyTrack) { LiveValues v; + v.keyTrack = keyTrack; + // Folded here, not at the voice: Voice::start reads the block's value directly, so the + // spline rule has to be applied on the way in or the two would answer differently. + v.splineActive = splineActive(params); + v.lengthFraction = effectiveLengthFraction(params); v.filterSettings = params.filter.settings; v.filterModAmount = params.filter.modAmount; v.filterVelAmount = params.filter.velAmount; diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index 7874011..23fbab3 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -52,6 +52,19 @@ struct LiveValues { // ordinarily live. double playRate = 1.0; double pitchOffsetSemitones = 0.0; + // Two more members of playRate's note-on-latched class, here for the same reason it is: + // both resolve a fact the voice fixes at note-on (the pitch ratio, and playEnd_), so live + // delivery would retune or re-span a note already struck. Voice::start receives them as + // arguments; applyLive never touches either. + double keyTrack = kKeyTrackDefault; + // ALREADY spline-folded (effectiveLengthFraction) — a drawn contour is a pure time function + // over the whole sample, so the stored knob is inert while one is active and the block must + // carry what the voice will actually play, not the stored value. + double lengthFraction = 1.0; + // The drawn-EG state the fold above reads. A mode flip travels by reload like the contours + // themselves, so this is not a control; it rides here only so a block-boundary write of + // Trigger length (a host automation point) can apply the SAME fold rather than un-doing it. + bool splineActive = false; }; // The seqlock copies the block as raw bytes, which is only defensible for a plain value type. @@ -59,8 +72,10 @@ static_assert(std::is_trivially_copyable_v, "the live block is copied under a seqlock — it must stay a plain value"); // The ONE derivation of the live block from the parameter set. Every publisher goes through -// here so there is a single site to keep in step with PlayParams. -LiveValues foldLive(const PlayParams& params); +// here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in +// because it belongs to the capture/instrument scalar beside the play bundle, not to +// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit. +LiveValues foldLive(const PlayParams& params, double keyTrack); // Single-writer / single-reader seqlock. The writer publishes a whole block between an odd // and an even generation; the reader copies the block and re-checks the generation, retrying @@ -96,6 +111,10 @@ public: seq_.store(next, std::memory_order_release); // even: complete and coherent } + // The last generation published, without copying the block — one relaxed load, so a reader + // that only needs "has anything moved" pays nothing for asking on a block where nothing has. + std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); } + // Copies the block into `out` and returns the generation actually observed, or 0 when // nothing has been published yet or the retry budget ran out (in which case `out` may hold // a torn copy and MUST be discarded — compare the return against 0 before using it). diff --git a/src/core/instrument/engine/master_gain.cpp b/src/core/instrument/engine/master_gain.cpp index b48ba58..806a917 100644 --- a/src/core/instrument/engine/master_gain.cpp +++ b/src/core/instrument/engine/master_gain.cpp @@ -17,6 +17,13 @@ double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); } double masterGainDbFromNorm(double norm) { norm = clamp01(norm); if (norm <= 0.0) return -std::numeric_limits::infinity(); + // UNITY IS EXACT, and the argument is arithmetic rather than structural — a host's + // reset-to-default arrives here as toPlain(defaultNormalized) and must land on 0.0 dB, not a + // hair off it. fl(60/84) differs from 60/84 by δ ≈ 1.6e-17; 84·δ ≈ 1.33e-15 sits under the + // half-ulp of 60 (3.55e-15), so -60 + fl(60/84)·84 rounds to exactly 60 and the sum to 0. + // PRECONDITION: no FP contraction. Fused into a single FMA the residue survives as 1.33e-15. + // Safe on the shipped MSVC/x64 default (no FMA without /arch:AVX2); a build that enables + // contraction here breaks the exactness test in test_param_units, which is where it surfaces. return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb); } diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index 8851210..c4229e9 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -148,6 +148,11 @@ struct FilterParams { // with the pitch envelope's own depth throw so the two pitch modulators speak one range. inline constexpr double kVelocityPitchRangeSemitones = 24.0; +// Standard 12-tone-ET tracking, and the ONE home for that number: the capture's own scalar, the +// instrument's stored scalar and the live block all default from here, so a blob predating the +// field and a block published before the first note can never disagree about it. +inline constexpr double kKeyTrackDefault = 1.0; + // Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR, // Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely // on this; the Preserve product default is layered on at (de)serialization, see @@ -267,7 +272,7 @@ struct SampleData { // How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no // tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone // offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_. - double keyTrack = 1.0; + double keyTrack = kKeyTrackDefault; // Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start // (never per frame). Default flat y=1 — every velocity plays at unity. diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 73d12f8..a8b4e11 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -19,7 +19,7 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) { } void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover, - double stretchRate) { + double stretchRate, double keyTrack, double lengthFraction) { // Before any state reset, record the pre-cut reference (last rendered output) and mark // the compensation pending iff this start is a takeover/steal of a sounding voice and the // caller opted in. The ramp is seeded on the first frame rendered after the restart, from @@ -72,6 +72,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick // the control outright — the predicate is spelled the same way advanceFrame spells it. preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured(); rateRatio_ = preserveRead_ ? 1.0 : stretchRate_; + keyTrack_ = (keyTrack < 0.0) ? sample.keyTrack : keyTrack; recomputeBaseRatio(); // pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at // Pitch 0 it is a division by exactly 1.0. @@ -120,10 +121,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick } else { // Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)) — // map/trigger_seam.h's formula, evaluated inline because the engine does not depend on - // map/. The spline fold is effectiveLengthFraction (play_params.h); a second copy of it - // here is what let a stored-but-inert %-knob shorten the bake while the voice played - // the whole take. - double frac = effectiveLengthFraction(p); + // map/. The caller's value is ALREADY spline-folded (foldLive does it); the snapshot + // fallback folds here, because a stored-but-inert %-knob must not shorten the span. + double frac = (lengthFraction < 0.0) ? effectiveLengthFraction(p) : lengthFraction; if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately if (frac > 1.0) frac = 1.0; std::int64_t playLen = static_cast( diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index e104eb8..7a135e6 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -45,6 +45,9 @@ inline double pitchRatio(int note, int rootNote) { // ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same // std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate. // At the root note the offset is 0 regardless of keyTrack. +// "Not supplied" for Voice::start's two snapshot-defaulted note-on latches; see start(). +inline constexpr double kLatchFromSnapshot = -1.0; + inline double keyTrackedRatio(int note, int rootNote, double keyTrack) { const double semis = static_cast(note - rootNote) * keyTrack; return std::pow(2.0, semis / 12.0); @@ -124,8 +127,16 @@ public: // to consult gets exactly that; VoiceEngine::startVoice is what resolves the real value — // sample.play.playRate is NOT read here, because the published block outranks the snapshot's // possibly-stale copy of it. + // + // `keyTrack` and `lengthFraction` are the other two members of stretchRate's note-on-latched + // class and arrive the same way, for the same structural reason. Negative = not supplied, + // which reads the snapshot's own value (sample.keyTrack, effectiveLengthFraction(play)) — + // both are non-negative by domain, so the sentinel can never collide with a real one. + // VoiceEngine::startVoice always supplies them, resolved from the published block when there + // is one; the sentinel is for a caller that has no block to consult. void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false, - double stretchRate = 1.0); + double stretchRate = 1.0, double keyTrack = kLatchFromSnapshot, + double lengthFraction = kLatchFromSnapshot); // Mono legato takeover: re-pitch this active voice to `note` without touching the // amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both @@ -201,7 +212,7 @@ private: // shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame. void recomputeBaseRatio() { if (sample_ == nullptr) return; - baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, sample_->keyTrack) * + baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) * velPitchRatio_ * pitchOffsetRatio_ * rateRatio_; } @@ -710,6 +721,10 @@ private: double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it double pitchOffsetRatio_ = 1.0; // the Pitch knob's factor — LIVE, re-applied by applyLive double rateRatio_ = 1.0; // Rate's factor of the read increment; start() owns when it is 1 + // Key-track, LATCHED at note-on beside the rate. Held here rather than re-read off the + // snapshot so a legato retune and a live block re-apply the note's own value; a published + // move reaches the next note only. + double keyTrack_ = kKeyTrackDefault; // Whether this note is ACTUALLY taking the Preserve read — a Preserve voice whose shifters // were never sized falls back to the varispeed one, and the two domains differ. Latched at // note-on beside rateRatio_, which start() resolves from the same predicate. diff --git a/src/core/instrument/engine/voice_engine.cpp b/src/core/instrument/engine/voice_engine.cpp index 2417d57..f7ab3cf 100644 --- a/src/core/instrument/engine/voice_engine.cpp +++ b/src/core/instrument/engine/voice_engine.cpp @@ -56,9 +56,14 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) { refreshLive(); // THE read of the note-on-latched commit class, and the only one: a published block outranks // the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below - // never touches the rate — so a Rate move reaches the next note and no sounding one. + // touches none of these three — so a move reaches the next note and no sounding one. const double rate = haveLive_ ? live_.playRate : sample_.play.playRate; - voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate); + const double keyTrack = haveLive_ ? live_.keyTrack : sample_.keyTrack; + // Already spline-folded in the block; the snapshot branch folds here so the two agree. + const double lengthFraction = + haveLive_ ? live_.lengthFraction : effectiveLengthFraction(sample_.play); + voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack, + lengthFraction); if (haveLive_) voice.applyLive(live_, /*snap=*/true); voice.setStartOrder(nextStartOrder_++); } diff --git a/src/core/instrument/map/play_seconds.h b/src/core/instrument/map/play_seconds.h index 61e9dcb..e570b07 100644 --- a/src/core/instrument/map/play_seconds.h +++ b/src/core/instrument/map/play_seconds.h @@ -5,12 +5,25 @@ // not link the bank model and the WAV codec to reach one value struct. `resolvePlay`, which // turns them into the engine's frame domain, stays in sample_map with the rest of the mapping. +#include + #include "core/instrument/engine/play_params.h" // PlayMode / TriggerParams / SplineEnv / … namespace reasampler::instrument::map { using instrument::engine::VelocityCurve; +// THE seconds -> frames fold, and the one home for its rounding: resolvePlay resolves the whole +// bundle through it, and the audio thread's live patch (param/param_live) resolves one stage +// time through it, so a stage time can never land on a different frame depending on the writer. +// A non-positive rate yields 0 rather than inventing one; a negative time floors at 0. +inline std::int64_t secondsToFrames(double seconds, double sampleRate) { + if (!(sampleRate > 0.0)) return 0; + double f = seconds * sampleRate; + if (!(f > 0.0)) return 0; // also catches NaN + return static_cast(f + 0.5); +} + // Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The // instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as // SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 25fe0c5..6e912e8 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -215,11 +215,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) { // carries through untouched, already a fraction. assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); const double sr = sampleRate > 0 ? static_cast(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first - const auto secToFrames = [sr](double sec) { - double f = sec * sr; - if (f < 0.0) f = 0.0; - return static_cast(f + 0.5); - }; + const auto secToFrames = [sr](double sec) { return secondsToFrames(sec, sr); }; // The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free. const auto resolveAhd = [&secToFrames](const AhdSeconds& s) { AhdParams a; diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 0dd6172..ae1be19 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -180,7 +180,7 @@ struct InstrumentParams { // exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every // key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch // engines. - double keyTrack = 1.0; + double keyTrack = kKeyTrackDefault; // Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain, // replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved): @@ -215,7 +215,7 @@ struct InstrumentParams { struct ResolvedCapture { std::string relativePath; // project-relative; the shell resolves + decodes it int rootNote = 60; // effective: override, else bank intrinsic, else 60 - double keyTrack = 1.0; + double keyTrack = kKeyTrackDefault; VelocityCurve velocityCurve = VelocityCurve::flat(); SampleLoop loop; // effective: loopOverride, else bank intrinsic std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat diff --git a/src/core/instrument/param/CLAUDE.md b/src/core/instrument/param/CLAUDE.md index c65fe4b..f6635d9 100644 --- a/src/core/instrument/param/CLAUDE.md +++ b/src/core/instrument/param/CLAUDE.md @@ -11,6 +11,12 @@ these onto `Steinberg::Vst::Parameter`; it decides nothing. A sixth peer of `engine/` / `map/` / `note/` / `bake/` / `ui/`, and it sits ABOVE `ui/`: the parameter list is a function of `deckParamCommit` and the value binding, never the reverse. +**Where an exposed control's value lives is `valueHomeFor`'s answer, and the exposed set is +asserted against it.** Two controls sit beside the parameter set rather than in it — master gain +(the processor's atomic) and pitch key-track (`InstrumentParams::keyTrack`) — and a promotion +whose control has no home would no-op silently in both directions on the host path with nothing +to catch it at compile time. That is exactly what happened to id 1000 before the guard existed. + ## Invariants ### The id table is FOREVER-FROZEN @@ -57,6 +63,13 @@ no longer exist. - `param_units` — `UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized` pair, and the defaults read off a default-constructed `PlaySeconds`. - `param_format` — the eight formatters and the digits parser behind `getParamValueByString`. +- `param_live` — the AUDIO-THREAD half: one exposed control patched into the live block in + place, allocation-free and lock-free, for the host's `IParameterChanges` queue. It exists + because the model layer cannot run there (`PlaySeconds` carries velocity curves and spline + contours, so `resolvePlay` allocates) while the queue is delivered there. Every law is called — + `ui::storedFromNorm` and `map::secondsToFrames` are the same two the model path uses; what is + new is the ROUTING, and that is pinned by an exhaustive equivalence test against the model path + over every exposed control rather than by two tables that happen to agree. ## Gotchas @@ -68,18 +81,33 @@ no longer exist. - **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.** No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere. -- **A curve exponent inside the knob detent but not exactly neutral reads `1.00` to the host.** - The detent lives in `curve_law`'s norm↔exponent map and the host's only handle is the norm, so - the host cannot see an off-detent near-neutral exponent — reachable only by an overlay knot - drag, which writes through `curveFromLevelAt` rather than the knob law. The editor's own label - deliberately reads the stored field directly and still shows the true value; that divergence is - structural to VST3, not a formatter defect. +- **A curve exponent inside the knob detent but not exactly neutral is NEUTRALIZED by any host + touch — a VALUE consequence, not a display one.** The detent lives in `curve_law`'s + norm↔exponent map and the host's only handle is the norm, so the host reads such an exponent + back as `1.00` (the editor's own label reads the stored field and still shows the true value). + The sharp half is the WRITE: a host write of that norm reaches `ui::setDeckParam` → + `storedFromNorm` → `util::curveFromKnobNorm`, whose ±0.01 detent rewrites the stored exponent + to exactly `1.0`. So an off-detent near-neutral exponent set by an overlay knot drag is + silently flattened by any host touch or lane pass over that parameter. + **Assessed and ACCEPTED, not merely documented:** the alternative is to widen the exposed + parameter's law so the detent band is addressable, and §6.3 freezes that law on the first + shipped build — a permanent change to twelve parameters' normalization, to preserve a + difference the user cannot see on the knob (the detent exists precisely because a drag cannot + land on the identity reliably) and cannot hear (the band is ±0.047 of the exponent). Removing + the detent from the WRITE path alone would leave the knob unable to reach the identity, which + is the defect it was added for. The residual is confined to knot-drawn near-neutral curves. - **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared −60…+24 range on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. -- **The filter's four store their position as a `float`, so a not-yet-stored norm can display - one digit differently.** A host previewing a value it has sent but that has not round-tripped - through the stored float differs from the editor by up to a float ulp; at a value landing - exactly on a display rounding boundary that is worth one integer percent on morph. Both - surfaces read the MODEL in every settled state, so this is a transient of the write itself, - not a standing divergence — `test_param_format` holds those four to the plain value rather - than to the string for exactly this reason. +- **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four + store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float` + *inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those + three are held to digit-for-digit string equality like everything else. Morph's path is + full-double (`clamp01(n) * 100`), so the float the model stores and the double the host holds + are genuinely different inputs — worth one integer percent at a value landing on a display + rounding boundary. Both surfaces read the MODEL in every settled state, so it is a transient of + the write itself, not a standing divergence; `test_param_format` holds morph alone to the plain + value rather than to the string. +- **A host write the MODEL clamps is not a settled state either.** Trigger length's stored + domain is `(0,1]`, so a host norm of 0 comes back as 0.01. `setParamNormalized` caches what the + model took, so the host never holds the rejected value — the sweep skips the clamped steps for + that reason rather than loosening its comparison. diff --git a/src/core/instrument/param/CMakeLists.txt b/src/core/instrument/param/CMakeLists.txt index 97c95eb..ada0656 100644 --- a/src/core/instrument/param/CMakeLists.txt +++ b/src/core/instrument/param/CMakeLists.txt @@ -5,13 +5,28 @@ reasampler_test(param_id LINK param_id) # The norm <-> plain layer. deck_values carries the tapers' full scales and the two field # resolvers the defaults are read through; filter_params and master_gain are the frozen laws the -# filter's four and the gain report through, CALLED rather than restated. +# filter's four and the gain report through, CALLED rather than restated. filter_params rather +# than the whole `filter` target: this is the parameter surface, and a link edge from it onto the +# per-voice filter KERNEL would put the voice DSP in reach of any future extension-side consumer +# of param_format — which root CLAUDE.md's bake invariant forbids. reasampler_pure_library(param_units SOURCES param_units.cpp - LINK PUBLIC deck_values param_taper curve_law master_gain filter) -reasampler_test(param_units LINK param_units param_id) + LINK PUBLIC deck_values param_taper curve_law master_gain filter_params) +# sample_map for the test alone: the host-vs-editor default agreement reads the two instance +# scalars where they LIVE, and one of them is a field of InstrumentParams. +reasampler_test(param_units LINK param_units param_id sample_map) reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_units) -# param_id is linked for the test only: the one-formatter-two-consumers assertion sweeps the -# exposed set, which is identity's answer rather than this module's. -reasampler_test(param_format LINK param_format param_id) +# param_id and sample_map are linked for the test only: the one-formatter-two-consumers assertion +# sweeps the exposed set (identity's answer, not this module's) and reads pitch key-track where it +# lives, on InstrumentParams. +reasampler_test(param_format LINK param_format param_id sample_map) + +# The audio thread's half: the live block plus the two laws it patches through. No engine — +# the block is a value, not a thing the voice owns. +reasampler_pure_library(param_live + SOURCES param_live.cpp + LINK PUBLIC deck_values live_params) +# sample_map for the test alone: the equivalence assertion drives the MODEL path +# (setDeckParam -> resolvePlay -> foldLive) as its reference. +reasampler_test(param_live LINK param_live param_id param_units sample_map) diff --git a/src/core/instrument/param/param_live.cpp b/src/core/instrument/param/param_live.cpp new file mode 100644 index 0000000..37c044c --- /dev/null +++ b/src/core/instrument/param/param_live.cpp @@ -0,0 +1,105 @@ +// param_live.cpp — see param_live.h. Three field resolvers plus one dispatch; every law is +// called, none is restated. + +#include "core/instrument/param/param_live.h" + +#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold) +#include "core/instrument/ui/deck_values.h" // storedFromNorm (setDeckParam's own map) + +namespace reasampler::instrument::param { + +namespace { + +using engine::LiveValues; + +// The block member a control names, in the same shape deck_values' two field resolvers take: +// LOCATION only, no law. Null for a control the block does not carry. +std::int64_t* frameField(LiveValues& v, DeckParam deck) { + switch (deck) { + case DeckParam::kAttack: return &v.adsr.attackFrames; + case DeckParam::kHold: return &v.adsr.holdFrames; + case DeckParam::kDecay: return &v.adsr.decayFrames; + case DeckParam::kRelease: return &v.adsr.releaseFrames; + case DeckParam::kTrigAttack: return &v.ampAhd.attackFrames; + case DeckParam::kTrigDecay: return &v.ampAhd.decayFrames; + case DeckParam::kPitchEnvAttack: return &v.pitchEnv.shape.attackFrames; + case DeckParam::kPitchEnvDecay: return &v.pitchEnv.shape.decayFrames; + case DeckParam::kFilterEnvAttack: return &v.filterEnv.attackFrames; + case DeckParam::kFilterEnvHold: return &v.filterEnv.holdFrames; + case DeckParam::kFilterEnvDecay: return &v.filterEnv.decayFrames; + case DeckParam::kFilterEnvRelease: return &v.filterEnv.releaseFrames; + case DeckParam::kFilterTrigAttack: return &v.filterAhd.attackFrames; + case DeckParam::kFilterTrigDecay: return &v.filterAhd.decayFrames; + default: return nullptr; + } +} + +// The filter's four, which store their normalized position as float in the block exactly as the +// parameter set stores it. +float* normField(LiveValues& v, DeckParam deck) { + switch (deck) { + case DeckParam::kFilterMorph: return &v.filterSettings.morphNorm; + case DeckParam::kFilterCutoff: return &v.filterSettings.cutoffNorm; + case DeckParam::kFilterQ: return &v.filterSettings.resonanceNorm; + case DeckParam::kFilterDrive: return &v.filterSettings.driveNorm; + default: return nullptr; + } +} + +double* doubleField(LiveValues& v, DeckParam deck) { + switch (deck) { + case DeckParam::kSustain: return &v.adsr.sustainLevel; + case DeckParam::kAttackCurve: return &v.adsr.attackCurve; + case DeckParam::kDecayCurve: return &v.adsr.decayCurve; + case DeckParam::kReleaseCurve: return &v.adsr.releaseCurve; + case DeckParam::kTrigHold: return &v.ampAhd.holdFraction; + case DeckParam::kTrigAttackCurve: return &v.ampAhd.attackCurve; + case DeckParam::kTrigDecayCurve: return &v.ampAhd.decayCurve; + case DeckParam::kPitchEnvHold: return &v.pitchEnv.shape.holdFraction; + case DeckParam::kPitchEnvAttackCurve: return &v.pitchEnv.shape.attackCurve; + case DeckParam::kPitchEnvDecayCurve: return &v.pitchEnv.shape.decayCurve; + case DeckParam::kPitchEnvDepth: return &v.pitchEnv.peakSemitones; + case DeckParam::kFilterEnvSustain: return &v.filterEnv.sustainLevel; + case DeckParam::kFilterEnvAttackCurve: return &v.filterEnv.attackCurve; + case DeckParam::kFilterEnvDecayCurve: return &v.filterEnv.decayCurve; + case DeckParam::kFilterEnvReleaseCurve: return &v.filterEnv.releaseCurve; + case DeckParam::kFilterTrigHold: return &v.filterAhd.holdFraction; + case DeckParam::kFilterTrigAttackCurve: return &v.filterAhd.attackCurve; + case DeckParam::kFilterTrigDecayCurve: return &v.filterAhd.decayCurve; + case DeckParam::kFilterModAmt: return &v.filterModAmount; + case DeckParam::kFilterVel: return &v.filterVelAmount; + case DeckParam::kFilterKeyTrack: return &v.filterKeyTrack; + case DeckParam::kRate: return &v.playRate; + case DeckParam::kPitch: return &v.pitchOffsetSemitones; + case DeckParam::kKeyTrack: return &v.keyTrack; + default: return nullptr; + } +} + +} // namespace + +bool applyLiveParam(LiveValues& block, DeckParam deck, double normalized, int sampleRate) { + // Trigger length is the one control the block does not carry verbatim: what it publishes is + // the SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for + // the same reason the knob is inert in the editor. + if (deck == DeckParam::kTrigLength) { + if (!block.splineActive) block.lengthFraction = ui::storedFromNorm(deck, normalized); + return true; + } + if (std::int64_t* f = frameField(block, deck)) { + *f = map::secondsToFrames(ui::storedFromNorm(deck, normalized), + static_cast(sampleRate)); + return true; + } + if (float* f = normField(block, deck)) { + *f = static_cast(ui::storedFromNorm(deck, normalized)); + return true; + } + if (double* f = doubleField(block, deck)) { + *f = ui::storedFromNorm(deck, normalized); + return true; + } + return false; +} + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_live.h b/src/core/instrument/param/param_live.h new file mode 100644 index 0000000..1c20bf7 --- /dev/null +++ b/src/core/instrument/param/param_live.h @@ -0,0 +1,29 @@ +// param_live.h — the AUDIO-THREAD half of a host parameter write: one exposed control patched +// into the live block, in place, with no allocation and no lock. It exists because the model +// layer cannot run on the audio thread (PlaySeconds carries velocity curves and spline contours, +// so resolvePlay allocates), while `IParameterChanges` is delivered there. + +#pragma once + +#include "core/instrument/engine/live_params.h" +#include "core/instrument/ui/deck_groups.h" // DeckParam + +namespace reasampler::instrument::param { + +using ui::DeckParam; + +// Writes `normalized` for `deck` into `block`. RT-SAFE: no allocation, no lock, no transcendental +// beyond the taper's own. Returns false for a control this block does not carry — master gain, +// which reaches the audio as the processor's own atomic, and anything unexposed. +// +// The value laws are NOT restated here: `ui::storedFromNorm` is the same norm -> stored map +// `setDeckParam` writes with, and `map::secondsToFrames` the same fold `resolvePlay` uses. What +// IS new is the routing — which member of the block a control names — and that is pinned by an +// exhaustive equivalence test against the model path over every exposed control, rather than by +// two tables that happen to agree. +// +// `sampleRate` is the rate the loaded capture was BUILT at (the processor's builtSampleRate_), +// so a patched stage time lands on exactly the frames the build would have resolved. +bool applyLiveParam(engine::LiveValues& block, DeckParam deck, double normalized, int sampleRate); + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_units.cpp b/src/core/instrument/param/param_units.cpp index ffd659b..bcd20bc 100644 --- a/src/core/instrument/param/param_units.cpp +++ b/src/core/instrument/param/param_units.cpp @@ -66,10 +66,49 @@ UnitKind unitKindFor(DeckParam deck) { return UnitKind::Decibels; case DeckParam::kFilterCutoff: return UnitKind::Hertz; - default: - // The twelve curve exponents, Q and drive. Everything else has no row at all. + // The twelve curve exponents and the filter's two dimensionless tone controls. Listed + // rather than defaulted, and everything with no parameter row at all is listed with + // them: a `default:` here would let a control promoted later inherit Dimensionless + // silently, and §6.3 freezes an exposed parameter's normalization on the first shipped + // build — so the wrong answer would be permanent rather than correctable. + case DeckParam::kFilterQ: + case DeckParam::kFilterDrive: + case DeckParam::kAttackCurve: + case DeckParam::kDecayCurve: + case DeckParam::kReleaseCurve: + case DeckParam::kTrigAttackCurve: + case DeckParam::kTrigDecayCurve: + case DeckParam::kPitchEnvAttackCurve: + case DeckParam::kPitchEnvDecayCurve: + case DeckParam::kFilterEnvAttackCurve: + case DeckParam::kFilterEnvDecayCurve: + case DeckParam::kFilterEnvReleaseCurve: + case DeckParam::kFilterTrigAttackCurve: + case DeckParam::kFilterTrigDecayCurve: + case DeckParam::kPlayMode: + case DeckParam::kPitchEngine: + case DeckParam::kPitchEnvEnable: + case DeckParam::kFilterEnable: + case DeckParam::kFilterLaw: + case DeckParam::kAmpVelCurve: + case DeckParam::kPitchVelCurve: + case DeckParam::kFilterVelCurve: + case DeckParam::kAmpEnvSelect: + case DeckParam::kPitchEnvSelect: + case DeckParam::kFilterEnvSelect: + case DeckParam::kAmpEnvMode: + case DeckParam::kPitchEnvMode: + case DeckParam::kFilterEnvMode: + case DeckParam::kVoiceCount: + case DeckParam::kVoiceMode: + case DeckParam::kMonoTrigger: + case DeckParam::kLimiterEnable: + case DeckParam::kMasterMeter: + case DeckParam::kMasterGr: + case DeckParam::kCount: return UnitKind::Dimensionless; } + return UnitKind::Dimensionless; // unreachable for a valid enumerator; silences a warning. } const char* unitStringFor(DeckParam deck) { @@ -192,9 +231,18 @@ double toNormalized(DeckParam deck, double plain) { return plain; } -bool storesNormalized(DeckParam deck) { +ValueHome valueHomeFor(DeckParam deck) { PlaySeconds defaults; - return ui::deckFloatField(deck, defaults) != nullptr; + if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm; + if (ui::deckDoubleField(deck, defaults)) return ValueHome::ParamSet; + if (deck == DeckParam::kMasterGain || deck == DeckParam::kKeyTrack) { + return ValueHome::InstanceScalar; + } + return ValueHome::None; +} + +bool storesNormalized(DeckParam deck) { + return valueHomeFor(deck) == ValueHome::ParamSetNorm; } double defaultPlain(DeckParam deck) { @@ -204,10 +252,17 @@ double defaultPlain(DeckParam deck) { if (const float* stored = ui::deckFloatField(deck, defaults)) { return toPlain(deck, static_cast(*stored)); } + // The two instance scalars, whose default is not a field of PlaySeconds. if (deck == DeckParam::kMasterGain) return 0.0; // unity, and the sharpest exactness case + if (deck == DeckParam::kKeyTrack) { + return kKeyTrackDefault * kPercentFullScale; + } const double* field = ui::deckDoubleField(deck, defaults); if (!field) return 0.0; switch (unitKindFor(deck)) { + // Time converts seconds -> ms here and ms -> seconds in toNormalized, so its exactness + // additionally rests on x*1000/1000 == x — param_taper guarantees its quantum in SECONDS, + // not in ms. It holds for today's three Time defaults; a new one is a case to re-check. case UnitKind::Time: return *field * 1000.0; // stored seconds case UnitKind::PercentUnipolar: return *field * kPercentFullScale; case UnitKind::PercentKeyTrack: return *field * kPercentFullScale; // stored 0..2 diff --git a/src/core/instrument/param/param_units.h b/src/core/instrument/param/param_units.h index 58b3c88..bbe2ce5 100644 --- a/src/core/instrument/param/param_units.h +++ b/src/core/instrument/param/param_units.h @@ -48,6 +48,17 @@ PlainRange plainRangeFor(DeckParam deck); double toPlain(DeckParam deck, double normalized); double toNormalized(DeckParam deck, double plain); +// WHERE a control's value actually lives. The host's read and write paths branch on this, and +// the exposed set is asserted against it: a control promoted into the list with no home would +// otherwise no-op silently in BOTH directions, with nothing to catch it at compile time. +enum class ValueHome { + None, // not a scalar control at all — a toggle, a radio, a curve-popup cell + ParamSetNorm, // the filter's four: the stored double IS the normalized position + ParamSet, // every other knob the parameter set carries + InstanceScalar, // beside the parameter set: master gain, and the pitch key-track scalar +}; +ValueHome valueHomeFor(DeckParam deck); + // The filter's four tone controls STORE their normalized position (payload v9), so their default // normalized value is that stored double verbatim and no taper participates in a host's // reset-to-default. Reporting Hz / Q / drive depth for them means CALLING their frozen laws, not diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 8988a5a..4f51259 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -201,8 +201,10 @@ DeckParam curveParamFor(DeckParam knob) { LiveCommit deckParamCommit(DeckParam id) { switch (id) { - // The one note-on-latched control; the header owns why. + // The note-on-latched controls; the header owns why each one latches. case DeckParam::kRate: + case DeckParam::kKeyTrack: + case DeckParam::kTrigLength: return LiveCommit::NoteOnLatched; // Live by the tier's own definition — one atomic store the audio thread picks up at the // next block, no bridge read and no re-decode. It reaches the audio beside the live @@ -256,9 +258,7 @@ LiveCommit deckParamCommit(DeckParam id) { // to non-live. Reasons live in the header. case DeckParam::kPlayMode: case DeckParam::kPitchEngine: - case DeckParam::kTrigLength: case DeckParam::kPitchEnvEnable: - case DeckParam::kKeyTrack: case DeckParam::kFilterEnable: case DeckParam::kAmpVelCurve: case DeckParam::kPitchVelCurve: diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index f2d61d6..66e4b49 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -89,6 +89,49 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) { } } +double storedFromNorm(DeckParam id, double norm) { + switch (id) { + // The filter's four STORE their normalized position (payload v9), so the identity IS + // their law — deckFloatField's four, and the reason it is a separate resolver. + case DeckParam::kFilterMorph: + case DeckParam::kFilterCutoff: + case DeckParam::kFilterQ: + case DeckParam::kFilterDrive: + return clamp01(norm); + case DeckParam::kRate: + return rateRatioFromNorm(norm, kRateMinRatio, kRateMaxRatio); + case DeckParam::kPitch: + case DeckParam::kPitchEnvDepth: + return depthSemitonesFromNorm(norm, kPitchDepthMaxSemis); + case DeckParam::kFilterModAmt: + case DeckParam::kFilterVel: + return deckBipolarFromNorm(norm); + case DeckParam::kKeyTrack: + case DeckParam::kFilterKeyTrack: + return keyTrackFromNorm(norm); + case DeckParam::kTrigLength: + // lengthFraction is (0,1]; a small floor so a zero-length trigger never plays + // nothing. + return (std::max)(0.01, clamp01(norm)); + default: + break; + } + // The rest are decided by the display unit alone, which is what makes the fourteen stage + // times and the twelve curve dials one line each rather than twenty-six. + switch (deckParamUnit(id)) { + case UnitCategory::Milliseconds: return timeSecondsFromNorm(norm); + case UnitCategory::Exponent: return util::curveFromKnobNorm(norm); + case UnitCategory::Percent: return clamp01(norm); // hold fractions, sustain levels + // Decibels is master gain, whose stored value is a LINEAR gain the processor owns + // rather than a field of the parameter set; the two enums above are handled by id. + case UnitCategory::Semitones: + case UnitCategory::Decibels: + case UnitCategory::None: + break; + } + return norm; +} + void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { switch (id) { case DeckParam::kPlayMode: @@ -112,88 +155,24 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) { case DeckParam::kPitchEngine: play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; break; - case DeckParam::kRate: - play.playRate = rateRatioFromNorm(value, kRateMinRatio, kRateMaxRatio); break; - case DeckParam::kPitch: - play.pitchOffsetSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); break; - case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break; - case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break; - case DeckParam::kRelease: play.adsr.releaseSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kTrigLength: - // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays - // nothing. - play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); - break; - case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break; - case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = timeSecondsFromNorm(value); break; - case DeckParam::kTrigAttackCurve: - play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kTrigDecayCurve: - play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break; case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break; - case DeckParam::kPitchEnvAttack: - play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kPitchEnvHold: - play.pitchEnv.shape.holdFraction = clamp01(value); break; - case DeckParam::kPitchEnvDecay: - play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break; - case DeckParam::kPitchEnvAttackCurve: - play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kPitchEnvDecayCurve: - play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kPitchEnvDepth: - play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis); - break; case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break; case DeckParam::kFilterLaw: play.filter.settings.morphLaw = (segment == 1) ? MorphLaw::HighNotchLow : MorphLaw::HighBandLow; break; - case DeckParam::kFilterMorph: - play.filter.settings.morphNorm = static_cast(clamp01(value)); break; - case DeckParam::kFilterCutoff: - play.filter.settings.cutoffNorm = static_cast(clamp01(value)); break; - case DeckParam::kFilterQ: - play.filter.settings.resonanceNorm = static_cast(clamp01(value)); break; - case DeckParam::kFilterDrive: - play.filter.settings.driveNorm = static_cast(clamp01(value)); break; - case DeckParam::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break; - case DeckParam::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break; - case DeckParam::kFilterKeyTrack: - play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break; - case DeckParam::kFilterEnvAttack: - play.filter.env.attackSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kFilterEnvHold: - play.filter.env.holdSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kFilterEnvDecay: - play.filter.env.decaySeconds = timeSecondsFromNorm(value); break; - case DeckParam::kFilterEnvSustain: - play.filter.env.sustainLevel = clamp01(value); break; - case DeckParam::kFilterEnvRelease: - play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kFilterEnvAttackCurve: - play.filter.env.attackCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kFilterEnvDecayCurve: - play.filter.env.decayCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kFilterEnvReleaseCurve: - play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kFilterTrigAttack: - play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break; - case DeckParam::kFilterTrigHold: - play.filter.trigEnv.holdFraction = clamp01(value); break; - case DeckParam::kFilterTrigDecay: - play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break; - case DeckParam::kFilterTrigAttackCurve: - play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break; - case DeckParam::kFilterTrigDecayCurve: - play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break; - default: break; + default: + // Every knob: the one norm -> stored law, into the one field the control names. + // Both halves are shared with the audio thread's live patch (param/param_live), so + // a control cannot take a different taper or land in a different field depending on + // which surface wrote it. A toggle or a value living outside PlaySeconds resolves to + // neither field and falls through untouched. + if (float* f = deckFloatField(id, play)) { + *f = static_cast(storedFromNorm(id, value)); + } else if (double* d = deckDoubleField(id, play)) { + *d = storedFromNorm(id, value); + } + break; } // ONE normalization point for every control that can flip splineActive — a mode toggle // (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index 1ddd276..4303ac9 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -11,6 +11,7 @@ #include "core/instrument/ui/deck_groups.h" // DeckParam #include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds #include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers +#include "core/util/clamp01.h" namespace reasampler::instrument::ui { @@ -28,6 +29,13 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones; // Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls. inline constexpr double kKeyTrackMax = 2.0; +// The pitch key-track scalar lives beside the play bundle (on InstrumentParams / SampleData), +// so its two conversions cannot ride the PlaySeconds binding below. One home for them anyway: +// the editor knob, the host's write path and the live fold would otherwise each spell the +// division out. +inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; } +inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); } + // Rate's range: ALIASES of the stretcher's own measured ratio bounds, so the knob's ends are the // engine's clamp rather than a second opinion of it. The taper takes them as arguments for the // same reason the depth taper takes its throw — engine/time_stretch.h owns the numbers. @@ -41,6 +49,12 @@ inline constexpr double kRateMaxRatio = engine::kStretchRateMax; // shell reads those from the processor. double deckParamNorm(DeckParam id, const PlaySeconds& play); +// The STORED value a knob's normalized position maps to — the norm -> value half of the binding +// on its own, because the audio thread needs it without a PlaySeconds to write into +// (`param/param_live`). setDeckParam IS this composed with the field lookup below, so the two +// cannot carry different tapers. Answers `norm` unchanged for a control with no stored scalar. +double storedFromNorm(DeckParam id, double norm); + // Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1). // Mutates `play` in place, touching exactly the one field the control names. void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment); diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index cc8a260..698829c 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -98,22 +98,34 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f next touch re-imposing, a superseded value. Master gain has its own funnel (`setMasterGainLinear`) because it is the one exposed control that does not ride the parameter set. -- **`setState` ordering against the host's first parameter block is irrelevant by - construction.** There is one model and one funnel per control, so whichever writes last wins - and the host's display follows the model either way — the ordering is not assumed, it is - removed as a question. -- **`process()` reads no parameter queue and is unchanged by the parameter surface.** A host - write arrives on the UI/main thread and reaches the audio thread through the SAME live block - the editor's knobs publish into, observed once per `render()` — block boundaries, last write - wins. `[verify — DAW]` that REAPER delivers automation to a single-component plug-in through - `IEditController::setParamNormalized` and not through `ProcessData::inputParameterChanges` - alone; if it is the latter only, an RT-safe drain is required and `process()` is where it - would have to land. +- **BOTH delivery channels are serviced, and the audio-side one is the normative one.** + `IEditController::setParamNormalized` is the CONTROLLER channel — the SDK says a controller + "should update the according GUI element(s) only" there, so nothing about the audio may depend + on a host calling it. `ProcessData::inputParameterChanges` is the AUDIO channel, and the SDK's + own single-component sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains it + in `process()` while also implementing `setParamNormalized`. We do both, for the same reason. +- **The audio thread is the sole writer of the block the ENGINE reads.** Two `LiveParams` + blocks: the model's publishers (editor commits, reload, `setState`) write `liveParams_` off + the audio thread and may allocate on the way; `process()` merges that block with the host's + automation points into `automationLive_`, which is what `SampleData::live` points at. Two + blocks rather than one because the seqlock's single-writer contract is load-bearing and the two + writers genuinely differ in thread. The merge republishes ONLY when either side moved, so a + block carrying neither costs one relaxed load and the engine's read shape is unchanged. +- **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`, + called from `getState`, the editor's sync tick, and the bake's reload tail). The blob is + authoritative, so a value that never came back would be lost on save. The fold is suppressed + from notifying the host — the values came FROM it, and echoing them would let a lane in write + mode re-record its own playback. +- **`setState` does not need an ordering guarantee against the host's first parameter block.** + An automation point held by the audio thread is re-applied over every merge, so a written lane + outranks the restore whichever way round the two arrive — which is VST3's own rule, not a race + we lost. - **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what is exposed, an invented map would hijack CCs the user's controller already sends, and - REAPER's own per-parameter MIDI learn covers the case without freezing anything. - `IParameterFunctionName` and `IAutomationState` are assessed and not implemented; the reasons - are in the product spec and are not re-surveyed here. + `[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without + freezing anything. `IParameterFunctionName` and `IAutomationState` are assessed and not + implemented — `bake/CLAUDE.md` owns the `IAutomationState` reasoning, at its one consequence + site. **Non-goals / guardrails.** - The instrument never captures and never inserts into the arrange. Playback is a @@ -151,6 +163,13 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f ## Gotchas +- **`reasampler_processor.h` is a documented ~600-line-ceiling exception** (root `CLAUDE.md`, + structural heuristic 1), on the same footing as `voice.h`'s: it is ONE class declaration, so + the seam the heuristic asks for does not exist — a split would be an arbitrary bisection, and + the implementation is already split across three TUs on its real seams. Its bulk is the + drain-slot proof, the RT-discipline constraints and the two-block automation contract, all of + which the comment conventions name as keep-worthy. Not silent overshoot. + - **The bake click only ARMS; the editor's sync tick runs it.** Calling `Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing nested inside a mouse handler with `SetCapture` held, while the invoked action re-points diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index 13994fd..0b43c81 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -90,7 +90,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") waveform_view loop_marks bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage - param_id param_units param_format + param_id param_units param_format param_live limiter meter_accumulate meter_ballistics master_meter bake_hold file_bytes curve_law stroke_aa curve_tessellate diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 93b057b..43fb78f 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -1,13 +1,14 @@ // editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout // resolve every paint/hit-test path shares, the shell's half of the control-value binding (the // per-instance controls the parameter set does not carry — key-track, voice count, master gain, -// preview velocity — plus the value labels), and the node-drag clamp bounds. The parameter-set -// half is the pure `deck_values` module. The orthogonal half — which stored struct each editor -// selection names — is editor_models. Value logic only: no painting, no window plumbing. +// preview velocity — plus each knob's plain value and its label), and the node-drag clamp +// bounds. The parameter-set half is the pure `deck_values` module. The orthogonal half — which +// stored struct each editor selection names — is editor_models. Value logic only. #include "shell/instrument/reasampler_editor.h" #include +#include // isfinite (the gain's -inf label) #include #include // snprintf (deck value labels) #include @@ -241,11 +242,20 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const { // The digits come from the ONE formatter; everything the editor adds around them is static // chrome — a constant prefix or suffix cannot diverge from what the host shows. + const double plain = deckPlainValue(id); char digits[24]; - instrument::param::formatPlainFor(deck, deckPlainValue(id), digits, sizeof(digits)); + instrument::param::formatPlainFor(deck, plain, digits, sizeof(digits)); + const auto kind = instrument::param::unitKindFor(deck); const char* caret = instrument::ui::deckParamUnit(deck) == instrument::ui::UnitCategory::Exponent ? "^" : ""; - return caret + std::string(digits) + instrument::param::unitStringFor(deck); + // The gain at true silence reads "-inf", not "-infdB": there is no decibel value there. + if (kind == instrument::param::UnitKind::Decibels && !std::isfinite(plain)) { + return std::string(digits); + } + // The stage times are the one category that carries a space before its unit, and always did — + // this surface's own typography, not the host's (ParameterInfo::units is the bare string). + const char* gap = kind == instrument::param::UnitKind::Time ? " " : ""; + return caret + std::string(digits) + gap + instrument::param::unitStringFor(deck); } EnvClampBounds ReaSamplerEditor::envClampBounds() const { diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 64cfcee..7b2860b 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -119,6 +119,10 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { dragStartEnv_ = env; dragSampleFrames_ = frames; dragStartParams_ = params_; + // Peer of mouseDownDeck's bracket. LATCHING with no id named, because a node or + // knot drag can move more than one exposed parameter and the grab cannot know + // which; the release and capture-lost paths close it generically. + if (processor_) processor_->beginParamGestureLatch(); return true; // node moves once the cursor drags } return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false); diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index 020721f..0500fcf 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -120,6 +120,11 @@ void ReaSamplerEditor::attachedToParent() { } void ReaSamplerEditor::removedFromParent() { + // The processor outlives this view, so a bracket left open here would leave the host holding + // an edit forever and every later internal write to that parameter would emit a bare + // performEdit. The capture-lost path normally closes it; this does not rely on Windows + // delivering WM_CAPTURECHANGED before the window goes away. + if (processor_) processor_->endParamGesture(); if (childHwnd_) { KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away DestroyWindow(childHwnd_); diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 699058e..e7f7335 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -141,6 +141,10 @@ void ReaSamplerEditor::onSyncTimer() { // edit surface exactly as a reload would. Unconditional: it self-cancels when nothing is // armed, so no commit site has to remember to ask for it. processor_->flushLatencyRestart(); + // Peers of it: both deliver work the originating thread could not do where it stood — a host + // callback from inside reloadMutex_, and a model write from the audio thread. + processor_->flushGainNotify(); + processor_->drainAutomationToModel(); // Resolve the bake affordance's availability on the SAME tick that paints it, so it // can never be enabled on one tick and refuse on the next. diff --git a/src/shell/instrument/instrument_bake.cpp b/src/shell/instrument/instrument_bake.cpp index 3c2e206..1a0c967 100644 --- a/src/shell/instrument/instrument_bake.cpp +++ b/src/shell/instrument/instrument_bake.cpp @@ -120,6 +120,9 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { const std::optional tempo = Tempo::fromBpm(bridge.projectTempoBpm()); if (!tempo) return fail("the project tempo could not be read"); + // Fold anything the host's automation wrote into the model FIRST: the bake renders the sound + // the user approved, and an automated value the model has not picked up yet is part of it. + processor.drainAutomationToModel(); const InstrumentParams dialed = processor.instrumentParams(); const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote; diff --git a/src/shell/instrument/instrument_params.cpp b/src/shell/instrument/instrument_params.cpp index 446a18e..882ba19 100644 --- a/src/shell/instrument/instrument_params.cpp +++ b/src/shell/instrument/instrument_params.cpp @@ -1,17 +1,14 @@ // instrument_params.cpp — the VST3 adapter over core/instrument/param: the Parameter subclass -// whose toPlain/toNormalized ARE the taper, the one construction of the unit and parameter -// lists, and the model projection both directions. It DECIDES nothing — the pure module owns -// the frozen table, the laws and the formatter. -// -// The blob stays authoritative. A parameter is a THIRD SURFACE onto InstrumentParams/PlaySeconds -// — a peer of the deck knob and the overlay node, never a second copy of the value. getState -// serializes the model; the controller's own value list is a cache written FROM the model and -// never read as truth. +// whose toPlain/toNormalized ARE the taper, the construction of the unit and parameter lists, +// the model projection both directions, and both delivery channels (the controller's write and +// the audio thread's queue drain). It DECIDES nothing — the pure module owns the frozen table, +// the laws and the formatter. #include "shell/instrument/reasampler_processor.h" #include "base/source/fstring.h" #include "pluginterfaces/base/ustring.h" +#include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue #include "core/instrument/engine/master_gain.h" #include "core/instrument/param/param_format.h" @@ -123,7 +120,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal setMasterGainLinear(instrument::engine::masterGainLinearFromNorm(value)); } else { InstrumentParams params = instrumentParams(); - instrument::ui::setDeckParam(row->deck, params.play, value, /*segment=*/0); + writeDeckParamToModel(params, row->deck, value); setInstrumentParams(params); publishLiveParams(); } @@ -134,11 +131,25 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal tag, modelParamNormalized(instrumentParams(), row->deck)); } +void ReaSamplerProcessor::writeDeckParamToModel(InstrumentParams& params, DeckParam deck, + double normalized) { + // The two homes a control's value can have, and the ONE place the host path knows the + // difference — the editor draws the same split at applyParamControl. param::valueHomeFor is + // the predicate; a promotion whose control has neither home fails param_units' own test + // rather than no-oping silently here. + if (deck == DeckParam::kKeyTrack) { + params.keyTrack = instrument::ui::keyTrackFromNorm(normalized); + return; + } + instrument::ui::setDeckParam(deck, params.play, normalized, /*segment=*/0); +} + double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params, DeckParam deck) const { if (deck == DeckParam::kMasterGain) { return instrument::engine::masterGainNormFromLinear(masterGainLinear()); } + if (deck == DeckParam::kKeyTrack) return instrument::ui::keyTrackNormFrom(params.keyTrack); return instrument::ui::deckParamNorm(deck, params.play); } @@ -152,43 +163,145 @@ void ReaSamplerProcessor::syncParamsFromModel() { } } -void ReaSamplerProcessor::notifyParamsFromModel(const InstrumentParams& before, +void ReaSamplerProcessor::notifyParamsFromModel(const double* beforeNorms, const InstrumentParams& after) { if (paramNotifySuppressed_) return; + // The bake's reset moves ~40 values at once. Grouping them tells the host they are ONE act, + // which is what an undo stack and an automation lane both want; the SDK provides exactly + // this for exactly this case (ivsteditcontroller.h, IComponentHandler2). + const bool group = componentHandler2 && !gestureLatching_; + if (group) componentHandler2->startGroupEdit(); for (const param::ParamRow& row : param::exposedParams()) { if (row.deck == DeckParam::kMasterGain) continue; // its own funnel notifies it const double now = modelParamNormalized(after, row.deck); - if (now == modelParamNormalized(before, row.deck)) continue; + if (now == beforeNorms[static_cast(row.deck)]) continue; notifyParamChanged(row.id, now); } + if (group) componentHandler2->finishGroupEdit(); +} + +bool ReaSamplerProcessor::gestureIsOpen(param::ParamId id) const { + for (std::size_t i = 0; i < openGestureCount_; ++i) { + if (openGestureIds_[i] == id) return true; + } + return false; } void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalized) { EditControllerEx1::setParamNormalized(id, normalized); if (!componentHandler) return; - // A drag holds its own begin/end across the whole gesture so a host in touch or latch mode - // sees one continuous edit; every other writer — a reset, an envelope-node drag, the bake's - // reset — emits a degenerate one-point gesture, which is what makes the host DISPLAY follow - // it instead of re-imposing the pre-write value on the next touch. - const bool inGesture = openGestureId_ == id; - if (!inGesture) beginEdit(id); - performEdit(id, normalized); - if (!inGesture) endEdit(id); -} - -void ReaSamplerProcessor::beginParamGesture(DeckParam deck) { - const param::ParamId id = param::paramIdFor(deck); - if (id == 0 || !param::isExposed(deck)) return; - endParamGesture(); // a grab while one is open cannot leave the previous unclosed - openGestureId_ = id; + if (gestureIsOpen(id)) { + performEdit(id, normalized); + return; + } + if (gestureLatching_ && openGestureCount_ < kMaxOpenGestures) { + // First move this drag has made on this parameter: open its bracket and hold it, so the + // whole drag is one edit rather than a run of one-point ones. + openGestureIds_[openGestureCount_++] = id; + beginEdit(id); + performEdit(id, normalized); + return; + } + // Every non-drag writer — a reset, the bake's reset — emits a degenerate one-point gesture, + // which is what makes the host DISPLAY follow it instead of re-imposing the pre-write value + // on the next touch. beginEdit(id); -} - -void ReaSamplerProcessor::endParamGesture() { - if (openGestureId_ == 0) return; - const param::ParamId id = openGestureId_; - openGestureId_ = 0; // cleared FIRST: endEdit can re-enter through a host's own callback + performEdit(id, normalized); endEdit(id); } +void ReaSamplerProcessor::beginParamGestureLatch() { + endParamGesture(); // a grab while one is open cannot leave the previous unclosed + gestureLatching_ = true; +} + +void ReaSamplerProcessor::beginParamGesture(DeckParam deck) { + beginParamGestureLatch(); + const param::ParamId id = param::paramIdFor(deck); + if (id == 0 || !param::isExposed(deck)) return; + openGestureIds_[openGestureCount_++] = id; + beginEdit(id); +} + +bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes) { + if (!changes) return false; + // RT-SAFE, and the one non-obvious part of that: exposedRowFor walks exposedParams(), whose + // backing vector is a function-local static built on FIRST CALL. buildParameterList() calls + // it from initialize(), which the SDK guarantees precedes any process() — so the allocation + // has already happened by the time the audio thread gets here. + bool landed = false; + const int32 queues = changes->getParameterCount(); + for (int32 q = 0; q < queues; ++q) { + IParamValueQueue* queue = changes->getParameterData(q); + if (!queue) continue; + const int32 points = queue->getPointCount(); + if (points <= 0) continue; + // The LAST point of the queue wins for the block. Applying every point at its sample + // offset would put a "did anything change" question on the per-voice-per-sample path, + // which the phase-wide guardrail forbids. + int32 offset = 0; + ParamValue value = 0.0; + if (queue->getPoint(points - 1, offset, value) != kResultTrue) continue; + const param::ParamRow* row = param::exposedRowFor(queue->getParameterId()); + if (!row) continue; + landed = true; + const auto slot = static_cast(row->deck); + automationNorm_[slot] = value; + automationHeld_[slot] = true; + // Master gain reaches the audio beside the block rather than through it, so its + // automation write is the same one relaxed store the knob makes. + if (row->deck == DeckParam::kMasterGain) { + masterGain_.store( + static_cast(instrument::engine::masterGainLinearFromNorm(value)), + std::memory_order_relaxed); + } + // Publish to the UI thread, which folds it back into the model — the blob stays + // authoritative, so a value that never came back would be lost on save. + automationPublished_[slot].store(value, std::memory_order_relaxed); + automationPending_[slot].store(true, std::memory_order_release); + } + if (landed) automationAny_.store(true, std::memory_order_release); + return landed; +} + +void ReaSamplerProcessor::drainAutomationToModel() { + if (!automationAny_.exchange(false, std::memory_order_acquire)) return; + InstrumentParams params = instrumentParams(); + bool moved = false; + for (const param::ParamRow& row : param::exposedParams()) { + const auto slot = static_cast(row.deck); + if (!automationPending_[slot].exchange(false, std::memory_order_acquire)) continue; + const double value = automationPublished_[slot].load(std::memory_order_relaxed); + // Master gain's model IS the atomic the audio thread already wrote; there is nothing to + // fold, only the controller cache to refresh below. + if (row.deck != DeckParam::kMasterGain) { + writeDeckParamToModel(params, row.deck, value); + moved = true; + } + } + // Suppressed for the whole fold: these values CAME from the host, and echoing them back + // through performEdit would let a lane in write mode re-record its own playback. The + // controller cache is still refreshed, so the host's display and the editor follow. + const bool wasSuppressed = paramNotifySuppressed_; + paramNotifySuppressed_ = true; + if (moved) { + setInstrumentParams(params); + publishLiveParams(); + } + syncParamsFromModel(); + paramNotifySuppressed_ = wasSuppressed; +} + +void ReaSamplerProcessor::endParamGesture() { + gestureLatching_ = false; + if (openGestureCount_ == 0) return; + // Latched into a local and the state cleared FIRST: endEdit can re-enter through a host's + // own callback, and must not find a bracket this call is in the middle of closing. + param::ParamId closing[kMaxOpenGestures]; + const std::size_t count = openGestureCount_; + for (std::size_t i = 0; i < count; ++i) closing[i] = openGestureIds_[i]; + openGestureCount_ = 0; + for (std::size_t i = 0; i < count; ++i) endEdit(closing[i]); +} + } // namespace reasampler::vst diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 865db9a..3e5b05a 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -146,16 +146,18 @@ std::string ReaSamplerProcessor::reloadInstrument() { buildFromRef(*sel, params, projectDir, mode)) { sample = std::move(*decoded); havePlayable = true; - // Point the built snapshot at the instance's ONE live block and seed it from - // the very PlayParams the voices latch, so an untouched knob folds to the same - // frames the build resolved and a note-on with a live block sounds identical - // to one without. - sample.live = &liveParams_; + // Point the built snapshot at the instance's ONE engine-facing block and seed the + // MODEL block from the very PlayParams the voices latch, so an untouched knob folds + // to the same frames the build resolved and a note-on with a live block sounds + // identical to one without. process() merges the seed into automationLive_ at the + // top of the first block after this, which is where the swap below becomes visible + // too — so the new snapshot's first note reads it. + sample.live = &automationLive_; { // reloadMutex_ (held for this whole function) nests livePublishMutex_ here; // publishLiveParams never holds reloadMutex_, so this is the only nesting. std::lock_guard lp(livePublishMutex_); - liveParams_.publish(instrument::engine::foldLive(sample.play)); + liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack)); } builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed); resolvedId = selId; // the concrete pick that resolved @@ -226,6 +228,8 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry, // bake chain only ever runs from that tick, so the arm would be drained on the next one // anyway. At the tail for the same reason setState's is (see there). flushLatencyRestart(); + // The reset's gain notification, armed under reloadMutex_ inside that reload. + flushGainNotify(); } void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, @@ -289,8 +293,16 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr b // gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see // builtSampleRate_). if (gainAtNextPublish_) { - setMasterGainLinear(*gainAtNextPublish_); + // The MIRROR is inline (that is the sound this publish belongs to); the host + // notification is ARMED and delivered after reloadMutex_ is released. performEdit + // reaches the host handler, a host may re-enter this object synchronously from it, and + // setActive(false) takes this same non-recursive mutex — the identical hazard the + // latency restart is deferred for. + const bool moved = publishMasterGainLinear(*gainAtNextPublish_); gainAtNextPublish_.reset(); + // Armed only on a real change, so a reset that lands on the gain already set writes + // nothing into a host's automation lane — the same compare setMasterGainLinear makes. + if (moved) gainNotifyPending_.store(true, std::memory_order_release); } LoadedInstrument* evicted = draining_.exchange(prev); if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 335e6e1..30d63ba 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -91,20 +91,26 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { legacyLiftConcluded_.store(false, std::memory_order_relaxed); reloadInstrument(); paramNotifySuppressed_ = false; - // Every exposed parameter now reads the blob's value. Ordering against the host's first - // parameter block is irrelevant BY CONSTRUCTION rather than by assumption: there is one - // model and one funnel per control, so whichever of the two writes last simply wins, and - // the host's display follows the model either way. + // Every exposed parameter now reads the blob's value. Its ordering against the host's first + // parameter block does not need to be known: an automation point held by the audio thread is + // re-applied over every merge, so a lane outranks this restore whichever way round the two + // arrive. That is VST3's own rule — a written lane outranks anything the plug-in sets — not + // a race we lost. syncParamsFromModel(); // This caller has no editor to flush for it. At the TAIL on purpose: a host that services the // restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads // against the refs above, which are only fully restored once this function has run to here. flushLatencyRestart(); + flushGainNotify(); return kResultOk; } tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { if (!state) return kResultFalse; + // Before the snapshot, not after: the blob is authoritative, so anything the host's + // automation wrote must be in the model by the time it is serialised. This is the one drain + // site that is not an optimisation — a save with no editor open still has to see it. + drainAutomationToModel(); // Persists the full instance state — never written to the "reasampler" bank ext-state. // No pick serializes to {"", default params}, restoring as silence (never auto-playing // sample #1). @@ -163,10 +169,15 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() { } void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { - InstrumentParams before; + // The exposed values alone, not the whole set: this funnel fires per mouse move on every live + // knob and node drag, and InstrumentParams owns seven vectors — copying all of them to diff + // 44 doubles is the cost, and the diff is what the notification actually needs. + double before[kDeckParamSlots]; { std::lock_guard lock(paramsMutex_); - before = params_; + for (const instrument::param::ParamRow& row : instrument::param::exposedParams()) { + before[static_cast(row.deck)] = modelParamNormalized(params_, row.deck); + } params_ = params; } // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — @@ -250,8 +261,9 @@ void ReaSamplerProcessor::clearMasterBusClip() { void ReaSamplerProcessor::publishLiveParams() { const int rate = builtSampleRate_.load(std::memory_order_relaxed); if (rate <= 0) return; + const InstrumentParams params = instrumentParams(); const instrument::engine::LiveValues block = - instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate)); + instrument::engine::foldLive(resolvePlay(params.play, rate), params.keyTrack); // livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against // reloadInstrument's publish — held for the publish call only, not the fold above. std::lock_guard lock(livePublishMutex_); @@ -329,20 +341,32 @@ void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { rebuildVoiceEngine(); } -void ReaSamplerProcessor::setMasterGainLinear(double linear) { +bool ReaSamplerProcessor::publishMasterGainLinear(double linear) { // Clamp to the master_gain taper (0 = silence, cap = +24 dB). One relaxed atomic // store — no rebuild, no lock (a post-sum trim is not a keymap fact). if (!(linear >= 0.0)) linear = 0.0; // also catches NaN const double maxLin = masterGainMaxLinear(); if (linear > maxLin) linear = maxLin; const float value = static_cast(linear); - const float previous = masterGain_.exchange(value, std::memory_order_relaxed); + return masterGain_.exchange(value, std::memory_order_relaxed) != value; +} + +void ReaSamplerProcessor::setMasterGainLinear(double linear) { + const bool moved = publishMasterGainLinear(linear); // Gain's own notification funnel — it is the one exposed control that does not ride the // parameter set, so setInstrumentParams' diff cannot see it. Compared for a real change so a // reload's republish of an unmoved gain writes nothing into a host's automation lane. - if (paramNotifySuppressed_ || previous == value) return; + if (paramNotifySuppressed_ || !moved) return; notifyParamChanged(instrument::param::kParamMasterGain, - instrument::engine::masterGainNormFromLinear(linear)); + instrument::engine::masterGainNormFromLinear(masterGainLinear())); +} + +void ReaSamplerProcessor::flushGainNotify() { + if (!componentHandler) return; // an arm raised before the handler connected waits + if (!gainNotifyPending_.exchange(false, std::memory_order_acquire)) return; + if (paramNotifySuppressed_) return; + notifyParamChanged(instrument::param::kParamMasterGain, + instrument::engine::masterGainNormFromLinear(masterGainLinear())); } void ReaSamplerProcessor::previewNoteOn(int note) { diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index d4d7259..1934dd5 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -16,6 +16,8 @@ #include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) #include "pluginterfaces/vst/vstspeaker.h" +#include "core/instrument/engine/master_gain.h" // the automation write of the gain's own atomic +#include "core/instrument/param/param_live.h" // the RT-safe patch of one control into the block #include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor #include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there) @@ -200,6 +202,33 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { (drain && drain->fullyIdle()) ? drain->installedAt : 0, std::memory_order_relaxed); + // Host automation, merged into the engine-facing block BEFORE the note marshalling below, so + // a note-on in this block latches this block's values. See automationLive_ (header) for why + // the merge is here rather than in the model's own publisher. + { + const bool dirty = drainInputParameterChanges(data.inputParameterChanges); + const std::uint32_t modelGen = liveParams_.generation(); + if (dirty || modelGen != seenModelGeneration_) { + // Declared INSIDE the branch: LiveValues carries default member initializers, so a + // block where nothing moved must not pay to construct one. + instrument::engine::LiveValues merged; + // Re-read the model's fold and re-apply every held automation value over it: without + // the re-apply, any knob move would revert an automated parameter until its lane's + // next point. + if (liveParams_.read(merged) != 0) { + seenModelGeneration_ = modelGen; + const int builtRate = builtSampleRate_.load(std::memory_order_relaxed); + for (std::size_t i = 0; i < kDeckParamSlots; ++i) { + if (!automationHeld_[i]) continue; + instrument::param::applyLiveParam( + merged, static_cast(i), automationNorm_[i], + builtRate); + } + automationLive_.publish(merged); + } + } + } + // Marshal MIDI note-on/off at block granularity (no per-event sample-offset split; // sample-accurate scheduling is a later tier). Note-offs also route to the drain // engine so a note held across a reload releases its old-snapshot voice too. diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 92395d3..adea0f4 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -125,17 +125,12 @@ public: // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; - // A host write of one exposed parameter. Applies it to THE model through that control's - // existing commit tier — live publish, or the master-gain atomic — and never through a - // fourth route. Nothing reachable from here touches reloadInstrument or rebuildVoiceEngine, - // which is structural rather than careful: every reload- and rebuild-tier control is omitted - // from the parameter list, so no id maps to one. - // - // Parameter values reach the audio thread through the SAME block the editor's knobs publish - // into, which the engine observes ONCE per render() — i.e. at BLOCK BOUNDARIES, last write - // wins for that block. Sample-accurate application would put a per-sample "did anything - // change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids. - // process() reads no parameter queue and is unchanged by the parameter surface. + // The CONTROLLER-side write — a host GUI gesture on the generic panel, and whatever a host + // mirrors here for display. Applies it to THE model through that control's existing commit + // tier and never through a fourth route. Nothing reachable from here touches reloadInstrument + // or rebuildVoiceEngine, which is structural rather than careful: every reload- and + // rebuild-tier control is omitted from the parameter list, so no id maps to one. + // NOT the automation channel; this directory's CLAUDE.md owns which channel is which. Steinberg::tresult PLUGIN_API setParamNormalized( Steinberg::Vst::ParamID tag, Steinberg::Vst::ParamValue value) override; @@ -144,10 +139,20 @@ public: // through IComponentHandler. UI/main thread. void syncParamsFromModel(); - // A knob drag's host-edit bracket, so a host in touch or latch mode records ONE continuous - // edit rather than a burst of one-point gestures. Idempotent: a grab while one is open - // closes it first, and endParamGesture with none open does nothing. UI thread only. + // Folds what the audio thread took from the host's parameter queue back into the model and + // the controller cache, suppressing the host notification (those values came FROM it). + // UI/main thread; a no-op when nothing was automated. Called wherever the model is about to + // be READ as authoritative — getState, the bake, the editor's tick. + void drainAutomationToModel(); + + // A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit + // per parameter rather than a burst of one-point ones. Every parameter the drag notifies + // opens its bracket on first touch and holds it until endParamGesture — LATCHING rather than + // declared up front, because an envelope-node drag moves a set the grab cannot name. The + // deck-knob form additionally opens its own id at once, which is the id the host sees a touch + // on even if the drag produces no move. Idempotent. UI thread only. void beginParamGesture(instrument::ui::DeckParam deck); + void beginParamGestureLatch(); void endParamGesture(); // Additionally exposes REAPER's IReaperUIEmbedInterface (queried by REAPER to drive the @@ -264,6 +269,9 @@ public: return static_cast(masterGain_.load(std::memory_order_relaxed)); } void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()] + // The mirror alone, with no host notification: for the one writer that runs under + // reloadMutex_ and must arm rather than emit. True when the value actually moved. + bool publishMasterGainLinear(double linear); // The master-bus limiter's single enable (persisted in the parameter set). UI thread only: // a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the @@ -284,6 +292,11 @@ public: // same tick, so its arm would drain on the next one regardless). void flushLatencyRestart(); + // Delivers the master-gain host notification a reload deferred (the bake's reset gain lands + // under reloadMutex_, and performEdit may re-enter this object). Drained beside the latency + // restart, from the same sites and for the same reason. + void flushGainNotify(); + // Fires a one-shot preview note-on/off through the live VoiceEngine — the same // noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice // count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio @@ -306,18 +319,33 @@ private: // initialize(). void buildParameterList(); - // The normalized value a control reads at, from the model — the projection §6.1 calls a - // third surface. Master gain reads the processor's own atomic; everything else reads the - // parameter set through the deck's binding. + // THE automation read, on the audio thread, at the BLOCK BOUNDARY: the last point of each + // queue wins. RT-safe — relaxed atomic stores only. Sample-accurate application would put a + // per-sample "did anything change" question on the per-voice-per-sample path, which the + // phase-wide guardrail forbids. True when at least one point landed, which is what makes the + // merge below it conditional. + bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes); + + // The normalized value a control reads at, from the model — a projection of it, never a + // cached shadow. Master gain reads the processor's own atomic, pitch key-track the scalar + // beside the play bundle; everything else reads the parameter set through the deck's binding. double modelParamNormalized(const InstrumentParams& params, instrument::ui::DeckParam deck) const; - // Notifies the host of every exposed control whose value differs between the two parameter - // sets. Called from setInstrumentParams — the ONE funnel every writer already goes through — - // so no internal write can leave the host displaying, and on next touch re-imposing, a - // superseded value. The bake's reset is the first non-gesture writer this covers. - void notifyParamsFromModel(const InstrumentParams& before, const InstrumentParams& after); + // The write peer of that read, and the ONE host-side write of a control's value: both the + // controller's setParamNormalized and the audio thread's automation fold go through it, so + // neither can miss a control whose value lives outside the parameter set. + static void writeDeckParamToModel(InstrumentParams& params, instrument::ui::DeckParam deck, + double normalized); + + // Notifies the host of every exposed control whose normalized value moved. `beforeNorms` is + // indexed by DeckParam ordinal. Called from setInstrumentParams — the ONE funnel every writer + // already goes through — so no internal write can leave the host displaying, and on next + // touch re-imposing, a superseded value. The bake's reset is the first non-gesture writer + // this covers, and the reason the emission is grouped. + void notifyParamsFromModel(const double* beforeNorms, const InstrumentParams& after); void notifyParamChanged(instrument::param::ParamId id, double normalized); + bool gestureIsOpen(instrument::param::ParamId id) const; // If process() published that the drain instrument is fully idle, move it into the // graveyard and prune — so an edited-away snapshot stops costing memory as soon as its @@ -376,8 +404,13 @@ private: ReaperBridge bridge_; - // The parameter whose drag bracket is currently open, 0 for none. UI thread only. - instrument::param::ParamId openGestureId_ = 0; + // The parameters whose drag bracket is currently open, and whether a drag is in flight at + // all. UI thread only. Past the cap a write degrades to a one-point gesture rather than + // dropping — the pre-bracket behaviour, not a new failure mode. + static constexpr std::size_t kMaxOpenGestures = 8; + instrument::param::ParamId openGestureIds_[kMaxOpenGestures] = {}; + std::size_t openGestureCount_ = 0; + bool gestureLatching_ = false; // Set across setState so a LOAD is not reflected back to the host as an edit. Main thread // only, and non-atomic on purpose: the SDK calls setState there and nowhere else. bool paramNotifySuppressed_ = false; @@ -387,6 +420,26 @@ private: // Both live_ and draining_ observe this same block — a block owned by a snapshot would // leave the drain's still-sounding voices deaf to the knob under them. instrument::engine::LiveParams liveParams_; + // The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by + // the audio thread, which merges liveParams_ with the host's automation points once per + // block and republishes ONLY when either side moved — so a block carrying neither costs one + // relaxed load and the engine's own read shape is unchanged. Two blocks because the seqlock's + // single-writer contract is load-bearing and the two writers differ in thread; this + // directory's CLAUDE.md owns the argument. + instrument::engine::LiveParams automationLive_; + // Audio thread only. The last liveParams_ generation merged, and the sticky automation values + // re-applied over every merge — without them a model republish (any knob move) would revert + // an automated parameter until its lane's next point. + std::uint32_t seenModelGeneration_ = 0; + static constexpr std::size_t kDeckParamSlots = + static_cast(instrument::ui::DeckParam::kCount); + double automationNorm_[kDeckParamSlots] = {}; + bool automationHeld_[kDeckParamSlots] = {}; + // The audio thread's publication of those values to the UI thread's fold. automationAny_ + // makes the idle drain a single load. + std::atomic automationPublished_[kDeckParamSlots] = {}; + std::atomic automationPending_[kDeckParamSlots] = {}; + std::atomic automationAny_{false}; // Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams) // only — separate from reloadMutex_ so a knob drag's publish never blocks behind a // reload's WAV decode. The audio thread never takes this; process() only reads via @@ -435,6 +488,10 @@ private: // the bake's reset, which the old capture would be the wrong thing to apply it to. // Guarded by reloadMutex_, consumed by publishBuiltLocked. std::optional gainAtNextPublish_; + // Armed when that deferred gain lands, delivered by flushGainNotify once reloadMutex_ is + // released. Same shape and same reason as latencyRestartPending_ (see it): a host handler + // callback must never run inside this mutex. + std::atomic gainNotifyPending_{false}; // The decoded PCM parked across a deactivate, so an activation cycle costs no disk read // and no WAV decode: activation is "the audio thread may run", not "the sample is diff --git a/tests/test_bake_render.cpp b/tests/test_bake_render.cpp index 4be2fc1..915d3e4 100644 --- a/tests/test_bake_render.cpp +++ b/tests/test_bake_render.cpp @@ -210,7 +210,7 @@ int main() { { PlayParams slow = s.play; slow.trigAhd.attackFrames = 900; - block.publish(instrument::engine::foldLive(slow)); + block.publish(instrument::engine::foldLive(slow, s.keyTrack)); } const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); diff --git a/tests/test_deck_groups_state.cpp b/tests/test_deck_groups_state.cpp index 2703fc0..0fda436 100644 --- a/tests/test_deck_groups_state.cpp +++ b/tests/test_deck_groups_state.cpp @@ -49,9 +49,10 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { // The note-on-latched tier: published like a live control, read only at note-on. Asserted as // its OWN state rather than as "not Reload" — the whole point of widening the predicate is - // that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification - // to decide what it exposes to the host. - const DeckParam latched[] = {DeckParam::kRate}; + // that none of these falls back into either neighbour, and the VST3 parameter surface reads + // this classification to decide what it exposes to the host. + const DeckParam latched[] = {DeckParam::kRate, DeckParam::kKeyTrack, + DeckParam::kTrigLength}; for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched); // Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion @@ -60,7 +61,6 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() { DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable, DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve, - DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode, DeckParam::kVoiceCount, DeckParam::kVoiceMode, @@ -99,7 +99,10 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() { // Rate keeps its own tier through the drag site: it must not arrive as Live (which would let // it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob). CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched); - CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload); + // Trigger length resolves playEnd_ and key-track the pitch ratio — both facts a voice fixes + // at note-on, which is the latched tier's own definition rather than the reload tier's. + CHECK(knob(DeckParam::kTrigLength) == LiveCommit::NoteOnLatched); + CHECK(knob(DeckParam::kKeyTrack) == LiveCommit::NoteOnLatched); // Master gain is Live and reaches the audio BESIDE the live block rather than through it — // one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would // claim a gain move re-decodes the WAV, which it never did. diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp index a580aa0..28875e9 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -96,7 +96,7 @@ static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames int changeAfter, const LiveValues* changed, int noteOffBlock = -1, int velocity = 100) { sample.live = block; - if (block) block->publish(foldLive(sample.play)); + if (block) block->publish(foldLive(sample.play, sample.keyTrack)); VoiceEngine engine(1, sample); engine.noteOn(kTestNote, velocity); Run r; @@ -320,7 +320,7 @@ static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() { SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0 LiveParams block; s.live = █ - LiveValues dialled = foldLive(s.play); + LiveValues dialled = foldLive(s.play, s.keyTrack); dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note block.publish(dialled); VoiceEngine engine(1, s); @@ -345,7 +345,7 @@ static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() { slow.play.adsr.attackFrames = 24000; LiveParams block2; slow.live = &block2; - LiveValues snappy = foldLive(slow.play); + LiveValues snappy = foldLive(slow.play, slow.keyTrack); snappy.adsr.attackFrames = 0; block2.publish(snappy); VoiceEngine fast(1, slow); @@ -368,7 +368,7 @@ static void assertLiveFieldMovesTheSoundingNote(const char* name, void (*rig)(Sa rig(still); rig(moved); LiveParams blockA, blockB; - LiveValues target = foldLive(moved.play); + LiveValues target = foldLive(moved.play, moved.keyTrack); mutate(target); const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock); @@ -616,7 +616,7 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() { SampleData still = filteredSine(); SampleData moved = filteredSine(); LiveParams blockA, blockB; - LiveValues target = foldLive(moved.play); + LiveValues target = foldLive(moved.play, moved.keyTrack); c.mutate(target); const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr); @@ -688,7 +688,7 @@ static void testOneBlockServesTwoIndependentObservers() { LiveParams block; liveSnapshot.live = █ drainSnapshot.live = █ - block.publish(foldLive(liveSnapshot.play)); + block.publish(foldLive(liveSnapshot.play, liveSnapshot.keyTrack)); VoiceEngine liveEngine(1, liveSnapshot); VoiceEngine drainEngine(1, drainSnapshot); @@ -696,7 +696,7 @@ static void testOneBlockServesTwoIndependentObservers() { drainEngine.noteOn(60, 100); std::vector a, b; - LiveValues moved = foldLive(liveSnapshot.play); + LiveValues moved = foldLive(liveSnapshot.play, liveSnapshot.keyTrack); moved.filterSettings.cutoffNorm = 0.2f; for (int blk = 0; blk < 24; ++blk) { if (blk == 8) block.publish(moved); @@ -746,7 +746,7 @@ static std::vector renderPreserveCapable(SampleData& s, LiveParams& const LiveValues* changed, int changeAfter, int note) { s.live = █ - block.publish(foldLive(s.play)); + block.publish(foldLive(s.play, s.keyTrack)); VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); engine.noteOn(note, 100); std::vector out; @@ -765,7 +765,7 @@ static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() { moved.play.pitchEngine = eng; LiveParams blockA, blockB; - LiveValues halfRate = foldLive(moved.play); + LiveValues halfRate = foldLive(moved.play, moved.keyTrack); halfRate.playRate = 0.5; // At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the @@ -795,7 +795,7 @@ static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() { fresh.play.pitchEngine = eng; LiveParams block; fresh.live = █ - LiveValues published = foldLive(fresh.play); + LiveValues published = foldLive(fresh.play, fresh.keyTrack); published.playRate = rate; block.publish(published); VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048); @@ -827,7 +827,7 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() { moved.play.pitchEngine = eng; LiveParams blockA, blockB; - LiveValues target = foldLive(moved.play); + LiveValues target = foldLive(moved.play, moved.keyTrack); target.pitchOffsetSemitones = -12.0; const std::vector baseline = @@ -859,7 +859,7 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() { static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis, std::size_t capFrames) { LiveParams block; - LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy + LiveValues v = foldLive(s.play, s.keyTrack); // s.play keeps its own (zero) offset: the stale copy v.pitchOffsetSemitones = offsetSemis; block.publish(v); s.live = █ @@ -891,7 +891,7 @@ static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() { s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral}; LiveParams block; - LiveValues v = foldLive(s.play); + LiveValues v = foldLive(s.play, s.keyTrack); v.pitchOffsetSemitones = semis; block.publish(v); s.live = █ @@ -960,12 +960,12 @@ static void testPitchRatioAndVelocityGainStayLatched() { LiveParams block; s.live = █ - block.publish(foldLive(s.play)); + block.publish(foldLive(s.play, s.keyTrack)); VoiceEngine engine(1, s); engine.noteOn(72, 64); // an octave up: ratio 2.0 std::vector out; - LiveValues hostile = foldLive(s.play); + LiveValues hostile = foldLive(s.play, s.keyTrack); // Everything the block CAN carry, moved as far as it goes. None of it names velocity, the // note, the pitch ratio, or the PCM — that is the property under test. hostile.filterKeyTrack = 2.0; @@ -998,11 +998,11 @@ static void testPitchRatioAndVelocityGainStayLatched() { SampleData s2 = s; LiveParams block2; s2.live = &block2; - block2.publish(foldLive(s2.play)); + block2.publish(foldLive(s2.play, s2.keyTrack)); VoiceEngine engine2(1, s2); engine2.noteOn(72, 64); std::vector out2; - LiveValues quieter = foldLive(s2.play); + LiveValues quieter = foldLive(s2.play, s2.keyTrack); quieter.adsr.sustainLevel = 0.25; for (int blk = 0; blk < 8; ++blk) { if (blk == 2) block2.publish(quieter); @@ -1024,7 +1024,7 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() { rig.play.pitchEnv.shape.decayFrames = 24000; rig.play.pitchEnv.peakSemitones = 3.0; - LiveValues hostile = foldLive(rig.play); + LiveValues hostile = foldLive(rig.play, rig.keyTrack); hostile.filterKeyTrack = 2.0; hostile.filterSettings.cutoffNorm = 0.9f; hostile.filterModAmount = -1.0; diff --git a/tests/test_live_params.cpp b/tests/test_live_params.cpp index e9b1011..5bf556d 100644 --- a/tests/test_live_params.cpp +++ b/tests/test_live_params.cpp @@ -41,7 +41,9 @@ static void testFoldCarriesEveryContinuousControl() { p.pitchEnv.shape = AhdParams{7, 9, 0.4, 1.5, 0.75}; p.pitchEnv.peakSemitones = -3.5; - const LiveValues v = foldLive(p); + // Distinct from filter.keyTrack below on purpose: the two are different controls and a fold + // that crossed them would pass under a shared value. + const LiveValues v = foldLive(p, /*keyTrack=*/0.8); CHECK(v.adsr.attackFrames == 11); CHECK(v.adsr.holdFrames == 22); CHECK(v.adsr.decayFrames == 33); @@ -72,6 +74,21 @@ static void testFoldCarriesEveryContinuousControl() { CHECK(v.pitchEnv.shape.attackCurve == 1.5); CHECK(v.pitchEnv.shape.decayCurve == 0.75); CHECK(v.pitchEnv.peakSemitones == -3.5); + CHECK(v.keyTrack == 0.8); + // Spline-folded on the way in, so the block carries what the voice will actually play. + CHECK(!v.splineActive); + CHECK(v.lengthFraction == p.trigger.lengthFraction); +} + +// The fold, not the voice, is where a drawn contour pins the Trigger span — so the block a +// note-on latches already carries the folded value. +static void testADrawnEnvelopePinsTheFoldedTriggerLength() { + PlayParams p; + p.trigger.lengthFraction = 0.25; + p.ampSpline.mode = EnvMode::Spline; + const LiveValues v = foldLive(p, kKeyTrackDefault); + CHECK(v.splineActive); + CHECK(v.lengthFraction == 1.0); } static void testUnpublishedBlockReadsAsNothing() { @@ -170,6 +187,7 @@ static void testRampStepIsRateDerived() { int main() { testFoldCarriesEveryContinuousControl(); + testADrawnEnvelopePinsTheFoldedTriggerLength(); testUnpublishedBlockReadsAsNothing(); testConcurrentReaderNeverSeesAHalfAppliedEdit(); testRampTerminatesExactlyOnTheTarget(); diff --git a/tests/test_param_format.cpp b/tests/test_param_format.cpp index 2464271..7748ed6 100644 --- a/tests/test_param_format.cpp +++ b/tests/test_param_format.cpp @@ -1,17 +1,21 @@ // Standalone tests for the ONE formatter per unit category: the digit shapes each category // prints, and the property that makes the editor's knob label and the host's parameter string -// identical — both call THIS function, over a plain value derived the way each surface derives -// it. No VST3, no REAPER, no framework. +// identical — both call THIS function, each over the plain value ITS OWN surface derives (the +// editor's exponent read goes to the stored field, not through the knob law). No VST3, no +// REAPER, no framework. #include "../src/core/instrument/param/param_format.h" #include "../src/core/instrument/engine/master_gain.h" +#include "../src/core/instrument/map/sample_map.h" #include "../src/core/instrument/param/param_id.h" +#include "../src/core/instrument/param/param_units.h" #include "../src/core/instrument/ui/deck_values.h" #include #include #include +#include #include using namespace reasampler; @@ -61,34 +65,40 @@ static void testEachCategoryPrintsItsSpecifiedShape() { CHECK(digits(UnitKind::Dimensionless, 0.1) == "0.10"); } +// The EDITOR's derivation, spelled the way ReaSamplerEditor::deckPlainValue spells it — a curve +// exponent is read off its stored field, never round-tripped through the knob law. Driving the +// sweep through this rather than through toPlain(deckParamNorm(...)) is what makes the exponent +// half of it a real assertion instead of a round trip on both sides. +static double editorPlainValue(DeckParam deck, PlaySeconds& play) { + using reasampler::instrument::ui::UnitCategory; + if (reasampler::instrument::ui::deckParamUnit(deck) == UnitCategory::Exponent) { + const double* stored = reasampler::instrument::ui::deckDoubleField(deck, play); + return stored ? *stored : 0.0; + } + return toPlain(deck, reasampler::instrument::ui::deckParamNorm(deck, play)); +} + static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() { // The host derives its plain value from the normalized one it holds; the editor derives its - // from the STORED field, through the deck's own read. If those two derivations disagreed at - // any reachable value the two surfaces would print different numbers for one control — this - // is that property, swept over the whole travel of every exposed control. + // from the STORED field. If those two derivations disagreed at any reachable value the two + // surfaces would print different numbers for one control — this is that property, swept over + // the whole travel of every exposed control the parameter set carries. for (const ParamRow& row : exposedParams()) { - if (row.deck == DeckParam::kMasterGain) continue; // not stored in PlaySeconds + if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue; // own tests below for (int step = 0; step <= 40; ++step) { const double norm = step / 40.0; PlaySeconds play; reasampler::instrument::ui::setDeckParam(row.deck, play, norm, /*segment=*/0); + const double storedNorm = reasampler::instrument::ui::deckParamNorm(row.deck, play); - char hostBuf[24]; - formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf)); - - const double editorNorm = - reasampler::instrument::ui::deckParamNorm(row.deck, play); - char editorBuf[24]; - formatPlainFor(row.deck, toPlain(row.deck, editorNorm), editorBuf, sizeof(editorBuf)); - - if (storesNormalized(row.deck)) { - // The filter's four store their position as a FLOAT, so a norm the host has sent - // but we have not yet stored differs from the stored one by up to a float ulp. - // At a value landing exactly on a display rounding boundary that is worth one - // digit, so these four are held to the PLAIN value rather than to the string — - // the derivation is still asserted to be one derivation. + if (row.deck == DeckParam::kFilterMorph) { + // MORPH ALONE: its toPlain is full-double (`clamp01(n) * 100`), so the float the + // model stores and the double the host holds are genuinely different inputs, and + // at a value landing on a display rounding boundary that is worth one integer + // percent. Cutoff/Q/drive cast to float INSIDE toPlain, so they are bit-identical + // either way and are held to the string below like everything else. const double hostPlain = toPlain(row.deck, norm); - const double editorPlain = toPlain(row.deck, editorNorm); + const double editorPlain = toPlain(row.deck, storedNorm); const double tolerance = std::fabs(hostPlain) * 1e-6 + 1e-9; if (std::fabs(hostPlain - editorPlain) > tolerance) { std::printf("FAIL param %u at norm %.4f: host %.9g vs editor %.9g\n", @@ -97,6 +107,16 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() { } continue; } + // A write the model CLAMPED (Trigger length's (0,1] floor) is not a settled state: + // setParamNormalized caches what the model TOOK, so the host never holds the rejected + // value. Only the four float-stored positions differ by a ulp rather than a clamp. + if (!storesNormalized(row.deck) && storedNorm != norm) continue; + + char hostBuf[24]; + char editorBuf[24]; + formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf)); + formatPlainFor(row.deck, editorPlainValue(row.deck, play), editorBuf, + sizeof(editorBuf)); if (std::strcmp(hostBuf, editorBuf) != 0) { std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n", row.id, norm, hostBuf, editorBuf); @@ -106,6 +126,48 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() { } } +// The one place the two surfaces GENUINELY diverge, asserted so it stays a known property rather +// than a surprise: an exponent inside curve_law's centre detent but not exactly neutral is +// reachable only through an overlay knot drag, and the host — which holds the norm and nothing +// else — reads it back as the neutral the knob law snaps to. +static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() { + PlaySeconds play; + // The detent is +/-0.01 in NORM, which is a ~+/-0.047 band in the exponent — so 1.04 is + // inside it and still prints as a distinct number. + play.adsr.attackCurve = 1.04; + char editorBuf[24]; + formatPlainFor(DeckParam::kAttackCurve, editorPlainValue(DeckParam::kAttackCurve, play), + editorBuf, sizeof(editorBuf)); + CHECK(std::string(editorBuf) == "1.04"); + const double hostNorm = + reasampler::instrument::ui::deckParamNorm(DeckParam::kAttackCurve, play); + char hostBuf[24]; + formatPlainFor(DeckParam::kAttackCurve, toPlain(DeckParam::kAttackCurve, hostNorm), hostBuf, + sizeof(hostBuf)); + CHECK(std::string(hostBuf) == "1.00"); +} + +static void testKeyTrackPrintsTheSameDigitsFromEitherSurface() { + using reasampler::instrument::map::InstrumentParams; + for (int step = 0; step <= 40; ++step) { + const double norm = step / 40.0; + InstrumentParams params; + params.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm); + const double editorNorm = reasampler::instrument::ui::keyTrackNormFrom(params.keyTrack); + char hostBuf[24]; + char editorBuf[24]; + formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, norm), hostBuf, + sizeof(hostBuf)); + formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, editorNorm), editorBuf, + sizeof(editorBuf)); + if (std::strcmp(hostBuf, editorBuf) != 0) { + std::printf("FAIL key-track at norm %.4f: host \"%s\" vs editor \"%s\"\n", + norm, hostBuf, editorBuf); + ++g_fail; + } + } +} + static void testMasterGainPrintsTheSameDigitsFromEitherSurface() { using reasampler::instrument::engine::masterGainLinearFromNorm; using reasampler::instrument::engine::masterGainNormFromLinear; @@ -165,7 +227,9 @@ static void testEveryExposedParameterHasAFormatterThatWritesSomething() { int main() { testEachCategoryPrintsItsSpecifiedShape(); testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue(); + testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor(); testMasterGainPrintsTheSameDigitsFromEitherSurface(); + testKeyTrackPrintsTheSameDigitsFromEitherSurface(); testTypingBackADisplayedValueLandsOnIt(); testAShortBufferIsNeverOverrunAndAlwaysTerminates(); testEveryExposedParameterHasAFormatterThatWritesSomething(); diff --git a/tests/test_param_id.cpp b/tests/test_param_id.cpp index 58eefdd..200a70d 100644 --- a/tests/test_param_id.cpp +++ b/tests/test_param_id.cpp @@ -150,22 +150,23 @@ static void testTheExposedSetIsExactlyThePredicateAnswer() { } } -static void testAReservedRowIsNumberedButNotIssued() { - // Key-track (pitch) and Trigger length are note-on-latch candidates that still route through - // the reload tier, so they are NOT issued to the host today. Their numbers stay reserved - // rather than retired: nothing shipped under them, so a later promotion issues the same id - // and no other id moves. +static void testEveryNumberedRowIsIssued() { + // Key-track (pitch) and Trigger length were reserved-but-unissued while they routed through + // the reload tier; both are note-on-latched now, so both are issued under the numbers that + // were held for them and no other id moved — which is what the block-and-step scheme bought. CHECK(paramIdFor(DeckParam::kKeyTrack) == 1000); CHECK(paramIdFor(DeckParam::kTrigLength) == 1450); - CHECK(!isExposed(DeckParam::kKeyTrack)); - CHECK(!isExposed(DeckParam::kTrigLength)); - CHECK(exposedRowFor(1000) == nullptr); - CHECK(exposedRowFor(1450) == nullptr); + CHECK(isExposed(DeckParam::kKeyTrack)); + CHECK(isExposed(DeckParam::kTrigLength)); + CHECK(exposedRowFor(1000) != nullptr); + CHECK(exposedRowFor(1450) != nullptr); // Master gain IS issued: one atomic store the audio thread picks up next block is the live // tier by that tier's own definition. CHECK(isExposed(DeckParam::kMasterGain)); CHECK(exposedRowFor(1700) != nullptr); - CHECK(exposedParams().size() == paramTable().size() - 2); + // The whole table is issued today — 44 of 44. + CHECK(exposedParams().size() == paramTable().size()); + CHECK(exposedParams().size() == 44); } static void testEveryRowCarriesADistinctTitleAndShortTitle() { @@ -186,7 +187,7 @@ int main() { testAnInnerDialSitsBesideTheKnobItShapes(); testABlockCarriesOnlyItsOwnGroup(); testTheExposedSetIsExactlyThePredicateAnswer(); - testAReservedRowIsNumberedButNotIssued(); + testEveryNumberedRowIsIssued(); testEveryRowCarriesADistinctTitleAndShortTitle(); if (g_fail == 0) std::printf("param_id: all tests passed\n"); return g_fail == 0 ? 0 : 1; diff --git a/tests/test_param_live.cpp b/tests/test_param_live.cpp new file mode 100644 index 0000000..427608b --- /dev/null +++ b/tests/test_param_live.cpp @@ -0,0 +1,152 @@ +// Standalone tests for the audio thread's parameter patch. The load-bearing one is the +// EQUIVALENCE assertion: patching a control into the live block must produce, bit for bit, the +// block the model path would have folded — which is what makes a second routing table safe. + +#include "../src/core/instrument/param/param_live.h" + +#include "../src/core/instrument/param/param_id.h" +#include "../src/core/instrument/param/param_units.h" +#include "../src/core/instrument/map/sample_map.h" +#include "../src/core/instrument/ui/deck_values.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::param; +using reasampler::instrument::engine::LiveValues; +using reasampler::instrument::engine::foldLive; +using reasampler::instrument::map::InstrumentParams; +using reasampler::instrument::map::PlaySeconds; +using reasampler::instrument::map::resolvePlay; +using reasampler::instrument::ui::DeckParam; + +static int g_fail = 0; +#define CHECK_ID(cond, id) do { if(!(cond)) { \ + std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0) +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +namespace { + +constexpr int kRate = 48000; + +// The MODEL path, verbatim: the write the editor makes, resolved and folded the way every +// publisher folds it. This is the reference the patch is measured against. +LiveValues modelBlock(const InstrumentParams& params) { + return foldLive(resolvePlay(params.play, kRate), params.keyTrack); +} + +// A parameter set deliberately away from its defaults, so an equivalence that only holds at the +// default cannot pass. Not every field — just enough that a mis-routed patch lands on a value +// that differs from the one it should have written. +InstrumentParams dialledParams() { + InstrumentParams p; + p.keyTrack = 1.5; + p.play.adsr.attackSeconds = 0.31; + p.play.adsr.holdSeconds = 0.07; + p.play.adsr.decaySeconds = 0.44; + p.play.adsr.sustainLevel = 0.62; + p.play.adsr.releaseSeconds = 0.9; + p.play.adsr.attackCurve = 2.5; + p.play.trigger.lengthFraction = 0.4; + p.play.trigAhd.attackSeconds = 0.12; + p.play.trigAhd.holdFraction = 0.3; + p.play.playRate = 1.2; + p.play.pitchOffsetSemitones = -5.0; + p.play.pitchEnv.enabled = true; + p.play.pitchEnv.peakSemitones = 7.0; + p.play.pitchEnv.shape.attackSeconds = 0.02; + p.play.filter.enabled = true; + p.play.filter.settings.cutoffNorm = 0.42f; + p.play.filter.settings.resonanceNorm = 0.66f; + p.play.filter.modAmount = -0.4; + p.play.filter.velAmount = 0.25; + p.play.filter.keyTrack = 0.75; + p.play.filter.env.attackSeconds = 0.05; + p.play.filter.trigEnv.decaySeconds = 0.6; + return p; +} + +} // namespace + +// THE assertion this module exists for. For every exposed control and several normalized +// positions: writing it through the model and folding must equal patching it into the folded +// block. Bytes, not fields — a member the patch forgot to route is caught as surely as one it +// routed to the wrong place. +static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() { + const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0}; + for (const ParamRow& row : exposedParams()) { + // Master gain is not carried by the block at all — the processor's own atomic is its + // route to the audio, and the patch reports that by refusing it. + if (row.deck == DeckParam::kMasterGain) { + LiveValues block = modelBlock(dialledParams()); + const LiveValues before = block; + CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id); + CHECK_ID(std::memcmp(&before, &block, sizeof(LiveValues)) == 0, row.id); + continue; + } + for (double norm : kPositions) { + InstrumentParams written = dialledParams(); + if (row.deck == DeckParam::kKeyTrack) { + written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm); + } else { + reasampler::instrument::ui::setDeckParam(row.deck, written.play, norm, + /*segment=*/0); + } + const LiveValues expected = modelBlock(written); + + LiveValues patched = modelBlock(dialledParams()); + CHECK_ID(applyLiveParam(patched, row.deck, norm, kRate), row.id); + CHECK_ID(std::memcmp(&expected, &patched, sizeof(LiveValues)) == 0, row.id); + } + } +} + +// A drawn contour makes Trigger length inert — the editor's knob goes dead and the fold pins the +// fraction at 1.0. A host lane pointed at it must be equally inert, or automation would re-open a +// control the model says is closed. +static void testTriggerLengthIsInertUnderADrawnEnvelope() { + InstrumentParams p = dialledParams(); + p.play.ampSpline.mode = reasampler::EnvMode::Spline; + LiveValues block = modelBlock(p); + CHECK(block.splineActive); + CHECK(block.lengthFraction == 1.0); + CHECK(applyLiveParam(block, DeckParam::kTrigLength, 0.2, kRate)); + CHECK(block.lengthFraction == 1.0); +} + +// A control with no parameter row is refused rather than silently landing somewhere. +static void testAnUnexposedControlIsRefused() { + LiveValues block = modelBlock(dialledParams()); + const LiveValues before = block; + CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate)); + CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 1.0, kRate)); + CHECK(std::memcmp(&before, &block, sizeof(LiveValues)) == 0); +} + +// Every exposed control resolves to a home the host's read and write paths actually reach. The +// guard the promotion of pitch key-track needed: id 1000 was issued against a control whose value +// is not in PlaySeconds, and nothing failed to compile. +static void testEveryExposedControlHasAValueHome() { + for (const ParamRow& row : exposedParams()) { + CHECK_ID(valueHomeFor(row.deck) != ValueHome::None, row.id); + } + // And the instance-scalar set is exactly the two the shell branches on by name. + CHECK(valueHomeFor(DeckParam::kMasterGain) == ValueHome::InstanceScalar); + CHECK(valueHomeFor(DeckParam::kKeyTrack) == ValueHome::InstanceScalar); + int instanceScalars = 0; + for (const ParamRow& row : paramTable()) { + if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) ++instanceScalars; + } + CHECK(instanceScalars == 2); +} + +int main() { + testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite(); + testTriggerLengthIsInertUnderADrawnEnvelope(); + testAnUnexposedControlIsRefused(); + testEveryExposedControlHasAValueHome(); + if (g_fail == 0) std::printf("param_live: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_param_units.cpp b/tests/test_param_units.cpp index 7ca378d..d6c6e52 100644 --- a/tests/test_param_units.cpp +++ b/tests/test_param_units.cpp @@ -6,7 +6,9 @@ #include "../src/core/instrument/param/param_id.h" #include "../src/core/instrument/engine/filter/filter_params.h" +#include "../src/core/instrument/engine/master_gain.h" #include "../src/core/instrument/map/play_seconds.h" +#include "../src/core/instrument/map/sample_map.h" #include "../src/core/instrument/ui/deck_values.h" #include @@ -29,6 +31,7 @@ static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() { // docs/product/parameter-automation.md 6.7.1, per parameter rather than per category, so a // control silently reclassified into the wrong category fails here. const Expect kExpected[] = { + {kParamKeyTrackPitch, "%", 0.0, 200.0}, {kParamRate, "%", 50.0, 200.0}, {kParamPitchOffset, "st", -24.0, 24.0}, {kParamPitchEnvAttack, "ms", 0.0, 10000.0}, @@ -65,6 +68,7 @@ static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() { {kParamAmpSustain, "%", 0.0, 100.0}, {kParamAmpRelease, "ms", 0.0, 10000.0}, {kParamAmpReleaseCurve, "", 0.1, 10.0}, + {kParamTriggerLength, "%", 0.0, 100.0}, {kParamAmpTrigAttack, "ms", 0.0, 10000.0}, {kParamAmpTrigAttackCurve, "", 0.1, 10.0}, {kParamAmpTrigHold, "%", 0.0, 100.0}, @@ -100,6 +104,38 @@ static void testEveryDefaultHasAnExactNormalizedPreimage() { } } +static void testTheHostAndTheEditorAgreeOnEveryDefaultPosition() { + // The criterion is that a host's reset-to-default and the editor's double-click land on the + // SAME value — and those are different code paths: ParameterInfo::defaultNormalizedValue + // comes from defaultNormalized (a per-CATEGORY switch), the editor's needle from + // deckParamNorm (a per-ID one). Asserting the param module against itself would not see the + // two disagree, and they carry three independently written full scales to disagree about. + using reasampler::instrument::ui::deckParamNorm; + const PlaySeconds defaults; + for (const ParamRow& row : exposedParams()) { + const double hostNorm = defaultNormalized(row.deck); + // The four that STORE their normalized position take it verbatim on both surfaces, and + // the two instance scalars are not in PlaySeconds at all — each read where it lives. + double editorNorm = 0.0; + switch (valueHomeFor(row.deck)) { + case ValueHome::InstanceScalar: + editorNorm = (row.deck == DeckParam::kMasterGain) + ? reasampler::instrument::engine::masterGainNormFromLinear(1.0) // unity + : reasampler::instrument::ui::keyTrackNormFrom( + reasampler::instrument::map::InstrumentParams{}.keyTrack); + break; + case ValueHome::ParamSetNorm: + case ValueHome::ParamSet: + editorNorm = deckParamNorm(row.deck, defaults); + break; + case ValueHome::None: + CHECK_ID(false, row.id); // an exposed control with no home reads nothing + continue; + } + CHECK_ID(hostNorm == editorNorm, row.id); + } +} + static void testTheFiltersFourTakeTheirStoredNormVerbatim() { // Their stored value IS the normalized one, so no taper may participate in their default: // this fails the moment someone routes them through toNormalized(toPlain(x)). @@ -181,6 +217,7 @@ static void testTheAddedDriveInverseUndoesTheFrozenLaw() { int main() { testEveryUnitStringAndRangeMatchesTheSpecifiedTable(); testEveryDefaultHasAnExactNormalizedPreimage(); + testTheHostAndTheEditorAgreeOnEveryDefaultPosition(); testTheFiltersFourTakeTheirStoredNormVerbatim(); testToPlainIsMonotoneAcrossTheWholeTravel(); testTheEndpointsAreTheDeclaredPlainRange(); From 1fd38bbd57220dfad8cf02e183e2530900134f5d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 17:16:02 -0400 Subject: [PATCH 49/56] Bound the automation hold to the window the model has not caught up on, and make that authority model stated, enforced and tested --- CLAUDE.md | 2 +- docs/PLAN.md | 34 ++- docs/TODO.md | 1 + docs/product/parameter-automation.md | 20 +- src/core/instrument/CLAUDE.md | 2 +- src/core/instrument/engine/live_params.cpp | 48 ++++- src/core/instrument/engine/live_params.h | 10 + src/core/instrument/param/CLAUDE.md | 64 +++--- src/core/instrument/param/CMakeLists.txt | 16 +- src/core/instrument/param/param_live.cpp | 197 ++++++++++-------- src/core/instrument/param/param_live.h | 32 ++- src/core/instrument/param/param_merge.cpp | 25 +++ src/core/instrument/param/param_merge.h | 50 +++++ src/core/instrument/param/param_units.cpp | 19 +- src/core/instrument/param/param_units.h | 10 + src/core/instrument/ui/deck_groups.h | 13 +- src/core/instrument/ui/deck_values.cpp | 6 +- src/core/instrument/ui/deck_values.h | 8 +- src/core/util/curve_law.h | 11 +- src/shell/instrument/CLAUDE.md | 88 ++++++-- src/shell/instrument/CMakeLists.txt | 2 +- src/shell/instrument/automation_channel.h | 95 +++++++++ src/shell/instrument/editor_controls.cpp | 8 +- src/shell/instrument/editor_session.cpp | 17 +- src/shell/instrument/instrument_params.cpp | 72 ++++--- src/shell/instrument/processor_reload.cpp | 7 +- src/shell/instrument/processor_snapshot.h | 57 +++++ src/shell/instrument/processor_state.cpp | 14 ++ src/shell/instrument/reasampler_editor.h | 6 +- src/shell/instrument/reasampler_processor.cpp | 37 ++-- src/shell/instrument/reasampler_processor.h | 105 ++++------ tests/test_live_delivery.cpp | 110 +++++++++- tests/test_param_live.cpp | 63 ++++-- tests/test_param_merge.cpp | 185 ++++++++++++++++ tests/test_param_units.cpp | 23 ++ 35 files changed, 1155 insertions(+), 302 deletions(-) create mode 100644 src/core/instrument/param/param_merge.cpp create mode 100644 src/core/instrument/param/param_merge.h create mode 100644 src/shell/instrument/automation_channel.h create mode 100644 src/shell/instrument/processor_snapshot.h create mode 100644 tests/test_param_merge.cpp diff --git a/CLAUDE.md b/CLAUDE.md index debab73..46f3912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on | `src/app/` | REAPER extension entry point | | `src/core/audio/` | pure audio-data math | | `src/core/capture/` | pure logic behind the capture pillar | -| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / ui) | +| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / param / ui) | | `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset | | `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages | | `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets | diff --git a/docs/PLAN.md b/docs/PLAN.md index 6d8a291..03fc2fd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1270,7 +1270,13 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). `stepCount` is asserted **zero on all 44**. - **A host automation lane moving a Live parameter moves a sounding note; a lane moving a NoteOnLatched parameter takes effect on the next note and does NOT trigger a reload or an - engine rebuild** — assert the tier, not just the sound. + engine rebuild** — assert the tier, not just the sound. All three NoteOnLatched controls now + have a delivery test in `tests/test_live_delivery.cpp` (rate, key-track, Trigger length), each + asserting BOTH halves: the sounding note byte-identical, the next note taking the value. +- **A host automation point's AUTHORITY IS BOUNDED**, and its release is where both review passes + went wrong: it outranks the model only until the UI thread has folded it in and republished. + `shell/instrument/CLAUDE.md`'s Authority section states the model, `param_merge` enforces it, + `test_param_merge` fails if a hold is never released or is released too early. - **No automation path reaches `reloadInstrument` or `rebuildVoiceEngine`.** - **A project saved before this change opens with every parameter reading the blob's value and sounds identical**; a project saved by this build opens in an older binary with its @@ -1309,10 +1315,23 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). `ProcessData::inputParameterChanges` in `process()` while also implementing `setParamNormalized`. **Both are serviced.** This was never a DAW question — the headers answer it, and building on the paragraph alone is exactly what the first pass did. - (b) The ORDERING of `setState` against the first parameter block: no longer a question. An - automation point held by the audio thread is re-applied over every merge, so a written lane - outranks the restore whichever way round the two arrive — VST3's own rule. What survives as - DAW work is recorded in `docs/TODO.md`, and none of it can change the frozen contract. + (b) The ORDERING of `setState` against the first parameter block: no longer a question, but not + for the reason the second pass gave. A point held by the audio thread is re-applied over every + merge only until the UI thread has folded it into the model — the hold is bounded, and a lane + that is genuinely DRIVING re-sends and so keeps outranking the restore, while a lane that sent + one point and had it folded does not. That is the correct reading of the rule "a lane in + read/write mode outranks a plug-in-side set" (stated in `docs/product/parameter-automation.md` + §7 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell it + out). The second pass's unbounded latch made the claim true by making every later writer + permanently deaf; `shell/instrument/CLAUDE.md`'s Authority section is the model now. + (c) **STILL OPEN, and it is the half the bundled `[verify, FIRST]` originally asked**: that + REAPER calls `setState` (not `setComponentState`) on a single-component plug-in — a state + ENTRY-POINT question, not a delivery-channel one. Recorded in `docs/TODO.md` rather than closed: + `vstsinglecomponenteffect.h:41-47` does collapse the names as §6.1 claims, our overrides land on + the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`, + and the blob has shipped through payload v1…v16, so the behaviour is very likely fine — but that + is inference, not observation. The rest of the DAW work is in `docs/TODO.md` too, and none of it + can change the frozen contract. - **[verify]** whether REAPER renders `ParameterInfo::units` beside the string `getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own convention** — digits in the string, unit carried separately, which is what @@ -1328,8 +1347,9 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). structurally could not see it and id 1000 would have no-oped in both directions with no compile-time guard; both controls also had to reach the engine, which meant widening `LiveValues` and `foldLive`'s input and handing `Voice::start` the two latched values as - arguments beside the rate. The guard that closes the class is `param::valueHomeFor`, asserted - over the exposed set. + arguments beside the rate. The guard that closes the class is `param::valueHomeFor` — asserted + over the exposed set (every control has a home, the instance-scalar set has exactly two + members) AND branched on by the shell's own read and write paths, so the three cannot drift. - **[propose at review]** whether to ship a default `IMidiMapping` CC table here or leave MIDI control to REAPER's host-side learn. Either is defensible; **skipping it silently is not.** - **[propose at review]** whether this track spends the reserved payload rung. §6.1 says diff --git a/docs/TODO.md b/docs/TODO.md index 6deb626..c1fa7b4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -832,6 +832,7 @@ in the `.rpp`", and does not gain a second home for the distinction. 2. **Whether REAPER's own per-parameter MIDI learn covers what a shipped `IMidiMapping` CC table would have.** The decision to ship no default map rests on it; if learn does not reach these parameters, a CC table is additive and frozen by nothing. 3. **That the three migration round trips hold**: a pre-parameter project opens with every parameter reading the blob's value and sounds identical; a project saved by this build restores fully in an older binary; a project with automation drawn, saved and reopened, replays against the same plain values. 4. **That an offline render replays automation** — the sharpest case for the audio-side drain, because the host drives `process()` and may never touch the controller. +5. **That REAPER restores instance state through `setState`, not `setComponentState`.** This is the ENTRY-POINT half of the original bundled `[verify, FIRST]`; the pass that closed that item closed only its delivery-channel half, which is a different question. The evidence short of a DAW is strong but is inference: `vstsinglecomponenteffect.h:41-47` collapses the two names on a single-component plug-in, our `setState`/`getState` overrides land on the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`, and the blob has round-tripped through payload v1…v16 in real projects. Exercising it costs one save/reopen. **Priority / risk.** Low. Nothing here is load-bearing on the frozen contract: the id table, the plain ranges and the norm↔plain laws are all decided and tested without a host. diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index 2b46bb9..1d75518 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -342,9 +342,15 @@ the GUI-update channel (*"should update the according GUI element(s) only"*, `ivsteditcontroller.h`), and `ProcessData::inputParameterChanges` is the audio-side one — the SDK's own `SingleComponentEffect` sample services BOTH (`public.sdk/samples/vst/again/source/againsimple.cpp`), and so do we. The `setState` ordering -half dissolves with it: an automation point held by the audio thread is re-applied over every -merge, so a written lane outranks the restore whichever way round the two arrive, which is -VST3's own authority rule rather than a race. **The audio thread cannot run the model path** +half dissolves with it, but only because the hold is BOUNDED: an automation point held by the +audio thread is re-applied over every merge until the UI thread folds it into the model, so a +lane that is genuinely driving outranks the restore whichever way round the two arrive, while a +lane that sent one point and had it folded does not. That is the authority rule read correctly — +and note it is reasoning from the host's replay behaviour, not a header quote: the SDK does not +state it. An unbounded hold makes the ordering claim true by making every later writer +permanently deaf, which is not the same property. `shell/instrument/CLAUDE.md`'s Authority +section is the model, and `core/instrument/param/param_merge` is where it is enforced. +**The audio thread cannot run the model path** (`resolvePlay` copies velocity curves and spline contours, so it allocates), so the drain patches the live block in place through one pure RT-safe function whose routing is pinned by an exhaustive equivalence test against the model path. @@ -913,8 +919,12 @@ makes them automatable, since otherwise they would re-decode a WAV per automatio `InstrumentParams`, not `PlaySeconds`, so the host's write path could not reach it and id 1000 would have no-oped in both directions with nothing failing to compile; both controls also had to reach the engine, which widened `LiveValues` and `foldLive`'s input and gave `Voice::start` the -two latched values as arguments beside the rate. `param::valueHomeFor`, asserted over the -exposed set, is what makes the next promotion of this shape a test failure instead of a silence. +two latched values as arguments beside the rate. `param::valueHomeFor` is what makes the next +promotion of this shape a test failure instead of a silence, and it earns that claim in three +places rather than one: `test_param_live` asserts every exposed control HAS a home and that the +instance-scalar set has exactly two members, and the shell's own read and write paths +(`modelParamNormalized`, `writeDeckParamToModel`) now BRANCH on it rather than on a hardcoded +control id — so a third instance scalar cannot appear without failing that count. **Not promoted, and not proposed for promotion: Rate to Live.** §3.5 records the cost; that paragraph is the first thing to read if it is ever proposed. diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 5563d48..6fa1f27 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -289,7 +289,7 @@ anything for a trigger shape. - The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render: - `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead. - `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead. - - `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns. + - `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns. - `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot. - `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes. - `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read. diff --git a/src/core/instrument/engine/live_params.cpp b/src/core/instrument/engine/live_params.cpp index 6dbb219..2129fae 100644 --- a/src/core/instrument/engine/live_params.cpp +++ b/src/core/instrument/engine/live_params.cpp @@ -6,7 +6,11 @@ namespace reasampler::instrument::engine { LiveValues foldLive(const PlayParams& params, double keyTrack) { - LiveValues v; + // Value-initialized, so the padding is determinate too. Nothing reads it — the block's + // equality is field-wise for exactly that reason — but this is the one construction site + // every publisher goes through, and an object with indeterminate bytes travelling under a + // seqlock is a hazard worth not having. Off the audio thread; the memset costs nothing here. + LiveValues v{}; v.keyTrack = keyTrack; // Folded here, not at the voice: Voice::start reads the block's value directly, so the // spline rule has to be applied on the way in or the two would answer differently. @@ -26,6 +30,48 @@ LiveValues foldLive(const PlayParams& params, double keyTrack) { return v; } +namespace { + +bool sameAdsr(const AdsrParams& a, const AdsrParams& b) { + return a.attackFrames == b.attackFrames && a.holdFrames == b.holdFrames && + a.decayFrames == b.decayFrames && a.sustainLevel == b.sustainLevel && + a.releaseFrames == b.releaseFrames && a.attackCurve == b.attackCurve && + a.decayCurve == b.decayCurve && a.releaseCurve == b.releaseCurve; +} + +bool sameAhd(const AhdParams& a, const AhdParams& b) { + return a.attackFrames == b.attackFrames && a.decayFrames == b.decayFrames && + a.holdFraction == b.holdFraction && a.attackCurve == b.attackCurve && + a.decayCurve == b.decayCurve; +} + +bool sameFilterSettings(const filter::FilterSettings& a, const filter::FilterSettings& b) { + return a.cutoffNorm == b.cutoffNorm && a.resonanceNorm == b.resonanceNorm && + a.morphNorm == b.morphNorm && a.driveNorm == b.driveNorm && + a.morphLaw == b.morphLaw; +} + +} // namespace + +bool operator==(const LiveValues& a, const LiveValues& b) { + return sameFilterSettings(a.filterSettings, b.filterSettings) && + a.filterModAmount == b.filterModAmount && + a.filterVelAmount == b.filterVelAmount && + a.filterKeyTrack == b.filterKeyTrack && + sameAdsr(a.filterEnv, b.filterEnv) && + sameAhd(a.filterAhd, b.filterAhd) && + sameAdsr(a.adsr, b.adsr) && + sameAhd(a.ampAhd, b.ampAhd) && + a.pitchEnv.enabled == b.pitchEnv.enabled && + a.pitchEnv.peakSemitones == b.pitchEnv.peakSemitones && + sameAhd(a.pitchEnv.shape, b.pitchEnv.shape) && + a.playRate == b.playRate && + a.pitchOffsetSemitones == b.pitchOffsetSemitones && + a.keyTrack == b.keyTrack && + a.lengthFraction == b.lengthFraction && + a.splineActive == b.splineActive; +} + double liveRampStep(double sampleRate) { if (!(sampleRate > 0.0)) return 0.0; // also catches NaN return 1.0 / (kLiveRampSeconds * sampleRate); diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index 23fbab3..2afd1d1 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -71,6 +71,16 @@ struct LiveValues { static_assert(std::is_trivially_copyable_v, "the live block is copied under a seqlock — it must stay a plain value"); +// FIELD-wise equality, and it must never be "simplified" into a memcmp. LiveValues carries +// padding, and nothing gives that padding a determinate value across a copy: NRVO is optional +// and the implicit copy/move is specified member-wise, so two blocks folded from the same +// parameter set are NOT reliably byte-equal. A byte compare therefore reports differences that +// do not exist — which is exactly what it did before this existed. Listed member by member, so a +// member added to the block above must be added here as well; this sits directly beneath the +// struct for that reason. +bool operator==(const LiveValues& a, const LiveValues& b); +inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a == b); } + // The ONE derivation of the live block from the parameter set. Every publisher goes through // here so there is a single site to keep in step with PlayParams. `keyTrack` is passed in // because it belongs to the capture/instrument scalar beside the play bundle, not to diff --git a/src/core/instrument/param/CLAUDE.md b/src/core/instrument/param/CLAUDE.md index f6635d9..cf20aad 100644 --- a/src/core/instrument/param/CLAUDE.md +++ b/src/core/instrument/param/CLAUDE.md @@ -4,9 +4,10 @@ What the instrument tells a VST3 host about its automatable parameters, with no VST3 type anywhere: the frozen id table, the exposed set derived from the deck's commit predicate, the -plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), and the one -formatter per unit category. The VST3 shell (`shell/instrument/instrument_params`) adapts -these onto `Steinberg::Vst::Parameter`; it decides nothing. +plain-value layer (unit category, plain range, `toPlain` / `toNormalized`), the one formatter per +unit category, the host's own norm→stored write map, and the audio thread's block-boundary merge +decision. The VST3 shell (`shell/instrument/instrument_params`) adapts these onto +`Steinberg::Vst::Parameter`; it decides nothing. A sixth peer of `engine/` / `map/` / `note/` / `bake/` / `ui/`, and it sits ABOVE `ui/`: the parameter list is a function of `deckParamCommit` and the value binding, never the reverse. @@ -63,13 +64,19 @@ no longer exist. - `param_units` — `UnitKind`, `unitStringFor`, `plainRangeFor`, the `toPlain` / `toNormalized` pair, and the defaults read off a default-constructed `PlaySeconds`. - `param_format` — the eight formatters and the digits parser behind `getParamValueByString`. -- `param_live` — the AUDIO-THREAD half: one exposed control patched into the live block in - place, allocation-free and lock-free, for the host's `IParameterChanges` queue. It exists - because the model layer cannot run there (`PlaySeconds` carries velocity curves and spline - contours, so `resolvePlay` allocates) while the queue is delivered there. Every law is called — - `ui::storedFromNorm` and `map::secondsToFrames` are the same two the model path uses; what is - new is the ROUTING, and that is pinned by an exhaustive equivalence test against the model path - over every exposed control rather than by two tables that happen to agree. +- `param_live` — a host parameter write, BOTH sides of the model/audio split: `applyLiveParam` + patches the live block in place (allocation-free, lock-free, for the `IParameterChanges` queue + the SDK delivers on the audio thread, where the model layer cannot run — `resolvePlay` + allocates), and `writeHostParam` lands the same write in the stored parameter set. One value + map (`param_units`' `hostStoredFromNorm`) serves both, so they cannot disagree; the ROUTING is + pinned by an exhaustive equivalence test between them over every exposed control. The routing + switch carries **no `default:`** — a control promoted into the list without a route fails to + compile, which the call site's discarded return value would otherwise hide. +- `param_merge` — the audio thread's block-boundary merge DECISION, with no atomic and no host + type in it: which held automation points still outrank the model, which the model has caught up + on and are released, and whether an arriving point moves anything at all. It is the testable + half of the AUTHORITY MODEL stated in `shell/instrument/CLAUDE.md`, and the reason both of that + model's failure modes now have a test rather than a reviewer. ## Gotchas @@ -81,23 +88,28 @@ no longer exist. - **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.** No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere. -- **A curve exponent inside the knob detent but not exactly neutral is NEUTRALIZED by any host - touch — a VALUE consequence, not a display one.** The detent lives in `curve_law`'s - norm↔exponent map and the host's only handle is the norm, so the host reads such an exponent - back as `1.00` (the editor's own label reads the stored field and still shows the true value). - The sharp half is the WRITE: a host write of that norm reaches `ui::setDeckParam` → - `storedFromNorm` → `util::curveFromKnobNorm`, whose ±0.01 detent rewrites the stored exponent - to exactly `1.0`. So an off-detent near-neutral exponent set by an overlay knot drag is - silently flattened by any host touch or lane pass over that parameter. - **Assessed and ACCEPTED, not merely documented:** the alternative is to widen the exposed - parameter's law so the detent band is addressable, and §6.3 freezes that law on the first - shipped build — a permanent change to twelve parameters' normalization, to preserve a - difference the user cannot see on the knob (the detent exists precisely because a drag cannot - land on the identity reliably) and cannot hear (the band is ±0.047 of the exponent). Removing - the detent from the WRITE path alone would leave the knob unable to reach the identity, which - is the defect it was added for. The residual is confined to knot-drawn near-neutral curves. +- **A curve exponent inside the knob detent but not exactly neutral READS BACK as `1.00` on the + host, while the stored value keeps its true exponent.** The detent lives in `curve_law`'s + norm↔exponent map, and `toPlain` is that map — so an off-detent near-neutral exponent (an + overlay knot drag can set one) displays as `1.00` in the host. The editor's own label reads the + stored field and shows the true value. + **The WRITE path does NOT have this loss, and that is deliberate.** A host write goes through + `hostStoredFromNorm`, which skips the detent: the detent is a DRAG affordance — a drag grid + delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag + step snaps to it — and a lane has no grid. `curveFromKnobNorm` already answers exactly `1.0` at + norm `0.5`, so skipping the detent costs nothing in reachability from the host, and applying it + would flatten a knot-drawn exponent to `1.0` on any lane pass. `test_param_live`'s + `testTheHostSkipsTheCurveDetentAndNothingElse` pins both halves: the host map is the editor's + everywhere else, and differs exactly inside the band. + **What remains is the DISPLAY divergence above**, which this module already documents as + structural and which no change to the frozen `toPlain`/`toNormalized` pair was made to chase. - **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared −60…+24 range - on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. + on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. The + editor additionally SUPPRESSES its unit suffix at that one value (`editor_controls`, the + `Decibels` + non-finite test) — "-inf" rather than "-infdB", because there is no decibel value + there. The host has no such hook and will render `ParameterInfo::units` beside it, so this is a + deliberate ONE-VALUE break in the "editor digits + chrome == host digits + units" invariant + stated above. - **MORPH ALONE can display one digit differently from a not-yet-stored norm.** The filter's four store their position as a `float`, but cutoff, Q and drive cast the incoming norm to `float` *inside* `toPlain`, so `toPlain(n)` and `toPlain(double(float(n)))` are bit-identical and those diff --git a/src/core/instrument/param/CMakeLists.txt b/src/core/instrument/param/CMakeLists.txt index ada0656..d69c84e 100644 --- a/src/core/instrument/param/CMakeLists.txt +++ b/src/core/instrument/param/CMakeLists.txt @@ -22,11 +22,17 @@ reasampler_pure_library(param_format SOURCES param_format.cpp LINK PUBLIC param_ # lives, on InstrumentParams. reasampler_test(param_format LINK param_format param_id sample_map) -# The audio thread's half: the live block plus the two laws it patches through. No engine — -# the block is a value, not a thing the voice owns. +# The host write, both sides of the model/audio split: the live block patched in place and the +# stored parameter set written, through one value map (param_units'). No engine — the block is a +# value, not a thing the voice owns. reasampler_pure_library(param_live SOURCES param_live.cpp - LINK PUBLIC deck_values live_params) + LINK PUBLIC deck_values live_params param_units) # sample_map for the test alone: the equivalence assertion drives the MODEL path -# (setDeckParam -> resolvePlay -> foldLive) as its reference. -reasampler_test(param_live LINK param_live param_id param_units sample_map) +# (writeHostParam -> resolvePlay -> foldLive) as its reference. +reasampler_test(param_live LINK param_live param_id sample_map) + +# The block-boundary merge decision — the automation hold's authority lifetime, with no atomic +# and no host type in it. +reasampler_pure_library(param_merge SOURCES param_merge.cpp LINK PUBLIC param_live) +reasampler_test(param_merge LINK param_merge param_id param_units sample_map) diff --git a/src/core/instrument/param/param_live.cpp b/src/core/instrument/param/param_live.cpp index 37c044c..6b099a2 100644 --- a/src/core/instrument/param/param_live.cpp +++ b/src/core/instrument/param/param_live.cpp @@ -1,102 +1,123 @@ -// param_live.cpp — see param_live.h. Three field resolvers plus one dispatch; every law is -// called, none is restated. +// param_live.cpp — see param_live.h. ONE exhaustive routing switch and one shared value map; +// every law is called, none is restated. #include "core/instrument/param/param_live.h" -#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold) -#include "core/instrument/ui/deck_values.h" // storedFromNorm (setDeckParam's own map) +#include + +#include "core/instrument/map/play_seconds.h" // secondsToFrames (resolvePlay's own fold) +#include "core/instrument/param/param_units.h" // hostStoredFromNorm (the ONE host value map) +#include "core/instrument/ui/deck_values.h" // the field resolvers setDeckParam writes through namespace reasampler::instrument::param { -namespace { - using engine::LiveValues; -// The block member a control names, in the same shape deck_values' two field resolvers take: -// LOCATION only, no law. Null for a control the block does not carry. -std::int64_t* frameField(LiveValues& v, DeckParam deck) { - switch (deck) { - case DeckParam::kAttack: return &v.adsr.attackFrames; - case DeckParam::kHold: return &v.adsr.holdFrames; - case DeckParam::kDecay: return &v.adsr.decayFrames; - case DeckParam::kRelease: return &v.adsr.releaseFrames; - case DeckParam::kTrigAttack: return &v.ampAhd.attackFrames; - case DeckParam::kTrigDecay: return &v.ampAhd.decayFrames; - case DeckParam::kPitchEnvAttack: return &v.pitchEnv.shape.attackFrames; - case DeckParam::kPitchEnvDecay: return &v.pitchEnv.shape.decayFrames; - case DeckParam::kFilterEnvAttack: return &v.filterEnv.attackFrames; - case DeckParam::kFilterEnvHold: return &v.filterEnv.holdFrames; - case DeckParam::kFilterEnvDecay: return &v.filterEnv.decayFrames; - case DeckParam::kFilterEnvRelease: return &v.filterEnv.releaseFrames; - case DeckParam::kFilterTrigAttack: return &v.filterAhd.attackFrames; - case DeckParam::kFilterTrigDecay: return &v.filterAhd.decayFrames; - default: return nullptr; - } -} - -// The filter's four, which store their normalized position as float in the block exactly as the -// parameter set stores it. -float* normField(LiveValues& v, DeckParam deck) { - switch (deck) { - case DeckParam::kFilterMorph: return &v.filterSettings.morphNorm; - case DeckParam::kFilterCutoff: return &v.filterSettings.cutoffNorm; - case DeckParam::kFilterQ: return &v.filterSettings.resonanceNorm; - case DeckParam::kFilterDrive: return &v.filterSettings.driveNorm; - default: return nullptr; - } -} - -double* doubleField(LiveValues& v, DeckParam deck) { - switch (deck) { - case DeckParam::kSustain: return &v.adsr.sustainLevel; - case DeckParam::kAttackCurve: return &v.adsr.attackCurve; - case DeckParam::kDecayCurve: return &v.adsr.decayCurve; - case DeckParam::kReleaseCurve: return &v.adsr.releaseCurve; - case DeckParam::kTrigHold: return &v.ampAhd.holdFraction; - case DeckParam::kTrigAttackCurve: return &v.ampAhd.attackCurve; - case DeckParam::kTrigDecayCurve: return &v.ampAhd.decayCurve; - case DeckParam::kPitchEnvHold: return &v.pitchEnv.shape.holdFraction; - case DeckParam::kPitchEnvAttackCurve: return &v.pitchEnv.shape.attackCurve; - case DeckParam::kPitchEnvDecayCurve: return &v.pitchEnv.shape.decayCurve; - case DeckParam::kPitchEnvDepth: return &v.pitchEnv.peakSemitones; - case DeckParam::kFilterEnvSustain: return &v.filterEnv.sustainLevel; - case DeckParam::kFilterEnvAttackCurve: return &v.filterEnv.attackCurve; - case DeckParam::kFilterEnvDecayCurve: return &v.filterEnv.decayCurve; - case DeckParam::kFilterEnvReleaseCurve: return &v.filterEnv.releaseCurve; - case DeckParam::kFilterTrigHold: return &v.filterAhd.holdFraction; - case DeckParam::kFilterTrigAttackCurve: return &v.filterAhd.attackCurve; - case DeckParam::kFilterTrigDecayCurve: return &v.filterAhd.decayCurve; - case DeckParam::kFilterModAmt: return &v.filterModAmount; - case DeckParam::kFilterVel: return &v.filterVelAmount; - case DeckParam::kFilterKeyTrack: return &v.filterKeyTrack; - case DeckParam::kRate: return &v.playRate; - case DeckParam::kPitch: return &v.pitchOffsetSemitones; - case DeckParam::kKeyTrack: return &v.keyTrack; - default: return nullptr; - } -} - -} // namespace - bool applyLiveParam(LiveValues& block, DeckParam deck, double normalized, int sampleRate) { - // Trigger length is the one control the block does not carry verbatim: what it publishes is - // the SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for - // the same reason the knob is inert in the editor. - if (deck == DeckParam::kTrigLength) { - if (!block.splineActive) block.lengthFraction = ui::storedFromNorm(deck, normalized); + const double stored = hostStoredFromNorm(deck, normalized); + const auto frames = [&](std::int64_t& dst) { + dst = map::secondsToFrames(stored, static_cast(sampleRate)); + return true; + }; + const auto position = [&](float& dst) { dst = static_cast(stored); return true; }; + const auto value = [&](double& dst) { dst = stored; return true; }; + + // NO `default:` — see the header. A promotion that forgets this file is a compile error. + switch (deck) { + // The fourteen stage times: stored seconds resolved at the BUILT rate. + case DeckParam::kAttack: return frames(block.adsr.attackFrames); + case DeckParam::kHold: return frames(block.adsr.holdFrames); + case DeckParam::kDecay: return frames(block.adsr.decayFrames); + case DeckParam::kRelease: return frames(block.adsr.releaseFrames); + case DeckParam::kTrigAttack: return frames(block.ampAhd.attackFrames); + case DeckParam::kTrigDecay: return frames(block.ampAhd.decayFrames); + case DeckParam::kPitchEnvAttack: return frames(block.pitchEnv.shape.attackFrames); + case DeckParam::kPitchEnvDecay: return frames(block.pitchEnv.shape.decayFrames); + case DeckParam::kFilterEnvAttack: return frames(block.filterEnv.attackFrames); + case DeckParam::kFilterEnvHold: return frames(block.filterEnv.holdFrames); + case DeckParam::kFilterEnvDecay: return frames(block.filterEnv.decayFrames); + case DeckParam::kFilterEnvRelease: return frames(block.filterEnv.releaseFrames); + case DeckParam::kFilterTrigAttack: return frames(block.filterAhd.attackFrames); + case DeckParam::kFilterTrigDecay: return frames(block.filterAhd.decayFrames); + + // The filter's four, which store their normalized position as float exactly as the + // parameter set stores it. + case DeckParam::kFilterMorph: return position(block.filterSettings.morphNorm); + case DeckParam::kFilterCutoff: return position(block.filterSettings.cutoffNorm); + case DeckParam::kFilterQ: return position(block.filterSettings.resonanceNorm); + case DeckParam::kFilterDrive: return position(block.filterSettings.driveNorm); + + // Everything the block carries verbatim as a double. + case DeckParam::kSustain: return value(block.adsr.sustainLevel); + case DeckParam::kAttackCurve: return value(block.adsr.attackCurve); + case DeckParam::kDecayCurve: return value(block.adsr.decayCurve); + case DeckParam::kReleaseCurve: return value(block.adsr.releaseCurve); + case DeckParam::kTrigHold: return value(block.ampAhd.holdFraction); + case DeckParam::kTrigAttackCurve: return value(block.ampAhd.attackCurve); + case DeckParam::kTrigDecayCurve: return value(block.ampAhd.decayCurve); + case DeckParam::kPitchEnvHold: return value(block.pitchEnv.shape.holdFraction); + case DeckParam::kPitchEnvAttackCurve: return value(block.pitchEnv.shape.attackCurve); + case DeckParam::kPitchEnvDecayCurve: return value(block.pitchEnv.shape.decayCurve); + case DeckParam::kPitchEnvDepth: return value(block.pitchEnv.peakSemitones); + case DeckParam::kFilterEnvSustain: return value(block.filterEnv.sustainLevel); + case DeckParam::kFilterEnvAttackCurve: return value(block.filterEnv.attackCurve); + case DeckParam::kFilterEnvDecayCurve: return value(block.filterEnv.decayCurve); + case DeckParam::kFilterEnvReleaseCurve: return value(block.filterEnv.releaseCurve); + case DeckParam::kFilterTrigHold: return value(block.filterAhd.holdFraction); + case DeckParam::kFilterTrigAttackCurve: return value(block.filterAhd.attackCurve); + case DeckParam::kFilterTrigDecayCurve: return value(block.filterAhd.decayCurve); + case DeckParam::kFilterModAmt: return value(block.filterModAmount); + case DeckParam::kFilterVel: return value(block.filterVelAmount); + case DeckParam::kFilterKeyTrack: return value(block.filterKeyTrack); + case DeckParam::kRate: return value(block.playRate); + case DeckParam::kPitch: return value(block.pitchOffsetSemitones); + case DeckParam::kKeyTrack: return value(block.keyTrack); + + // The one control the block does not carry verbatim: what it publishes is the + // SPLINE-FOLDED fraction, so a write while a contour is active must be inert here for the + // same reason the knob is inert in the editor. + case DeckParam::kTrigLength: + if (!block.splineActive) block.lengthFraction = stored; + return true; + + // Not carried. Master gain reaches the audio beside the block, as the processor's own + // atomic; the rest are toggles, radios, curve-popup cells and the deck's processor-side + // controls — all Reload- or rebuild-tier, so none of them is an exposed parameter. + case DeckParam::kMasterGain: + case DeckParam::kPlayMode: + case DeckParam::kPitchEngine: + case DeckParam::kPitchEnvEnable: + case DeckParam::kFilterEnable: + case DeckParam::kFilterLaw: + case DeckParam::kAmpVelCurve: + case DeckParam::kPitchVelCurve: + case DeckParam::kFilterVelCurve: + case DeckParam::kAmpEnvSelect: + case DeckParam::kPitchEnvSelect: + case DeckParam::kFilterEnvSelect: + case DeckParam::kAmpEnvMode: + case DeckParam::kPitchEnvMode: + case DeckParam::kFilterEnvMode: + case DeckParam::kVoiceCount: + case DeckParam::kVoiceMode: + case DeckParam::kMonoTrigger: + case DeckParam::kLimiterEnable: + case DeckParam::kMasterMeter: + case DeckParam::kMasterGr: + case DeckParam::kCount: + return false; + } + return false; // unreachable for a valid enumerator; silences a warning. +} + +bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized) { + const double stored = hostStoredFromNorm(deck, normalized); + if (float* f = ui::deckFloatField(deck, play)) { + *f = static_cast(stored); return true; } - if (std::int64_t* f = frameField(block, deck)) { - *f = map::secondsToFrames(ui::storedFromNorm(deck, normalized), - static_cast(sampleRate)); - return true; - } - if (float* f = normField(block, deck)) { - *f = static_cast(ui::storedFromNorm(deck, normalized)); - return true; - } - if (double* f = doubleField(block, deck)) { - *f = ui::storedFromNorm(deck, normalized); + if (double* d = ui::deckDoubleField(deck, play)) { + *d = stored; return true; } return false; diff --git a/src/core/instrument/param/param_live.h b/src/core/instrument/param/param_live.h index 1c20bf7..56f506a 100644 --- a/src/core/instrument/param/param_live.h +++ b/src/core/instrument/param/param_live.h @@ -1,12 +1,13 @@ -// param_live.h — the AUDIO-THREAD half of a host parameter write: one exposed control patched -// into the live block, in place, with no allocation and no lock. It exists because the model -// layer cannot run on the audio thread (PlaySeconds carries velocity curves and spline contours, -// so resolvePlay allocates), while `IParameterChanges` is delivered there. +// param_live.h — a host parameter write landed on BOTH sides of the model/audio split: into the +// live block in place (RT-safe, for `IParameterChanges`, which the SDK delivers on the audio +// thread where the model path cannot run — `resolvePlay` allocates), and into the stored +// parameter set. One norm -> stored map serves both, so they cannot disagree. #pragma once #include "core/instrument/engine/live_params.h" -#include "core/instrument/ui/deck_groups.h" // DeckParam +#include "core/instrument/map/play_seconds.h" // PlaySeconds (the model-side write target) +#include "core/instrument/ui/deck_groups.h" // DeckParam namespace reasampler::instrument::param { @@ -16,14 +17,25 @@ using ui::DeckParam; // beyond the taper's own. Returns false for a control this block does not carry — master gain, // which reaches the audio as the processor's own atomic, and anything unexposed. // -// The value laws are NOT restated here: `ui::storedFromNorm` is the same norm -> stored map -// `setDeckParam` writes with, and `map::secondsToFrames` the same fold `resolvePlay` uses. What -// IS new is the routing — which member of the block a control names — and that is pinned by an -// exhaustive equivalence test against the model path over every exposed control, rather than by -// two tables that happen to agree. +// The value laws are NOT restated here: `hostStoredFromNorm` is the same norm -> stored map the +// model-side write below takes, and `map::secondsToFrames` the same fold `resolvePlay` uses. What +// IS new is the routing — which member of the block a control names — and its switch carries no +// `default:`, so a control promoted into the parameter list without a route here fails to COMPILE +// rather than dropping its automation silently at a call site that discards the answer. // // `sampleRate` is the rate the loaded capture was BUILT at (the processor's builtSampleRate_), // so a patched stage time lands on exactly the frames the build would have resolved. bool applyLiveParam(engine::LiveValues& block, DeckParam deck, double normalized, int sampleRate); +// The MODEL-side peer: the same host write, landed in the stored parameter set instead. Sharing +// `hostStoredFromNorm` and the field resolvers with the patch above is what makes the equivalence +// test's claim — patch == fold-after-write — a property of one map rather than of two that agree. +// False for a control PlaySeconds does not carry: the two instance scalars (master gain, pitch +// key-track) are written where they live, by the shell. +// +// No `enforceGateUnavailableWhileDrawn` here, unlike `ui::setDeckParam`: every control that can +// flip `splineActive` is a toggle, every toggle is Reload-tier, and no Reload-tier control is +// exposed — so nothing reachable from a host write can open that hole. +bool writeHostParam(DeckParam deck, map::PlaySeconds& play, double normalized); + } // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_merge.cpp b/src/core/instrument/param/param_merge.cpp new file mode 100644 index 0000000..fe64618 --- /dev/null +++ b/src/core/instrument/param/param_merge.cpp @@ -0,0 +1,25 @@ +// param_merge.cpp — see param_merge.h. + +#include "core/instrument/param/param_merge.h" + +#include "core/instrument/param/param_live.h" + +namespace reasampler::instrument::param { + +void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count, + int sampleRate) { + for (std::size_t i = 0; i < count; ++i) { + AutomationSlot& slot = slots[i]; + if (!slot.held) continue; + if (slot.folded) { + // Nothing to patch: `block` was read from the model, and the model is what the fold + // wrote this point into. Dropping the hold here is the whole release. + slot.held = false; + slot.folded = false; + continue; + } + applyLiveParam(block, static_cast(i), slot.norm, sampleRate); + } +} + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_merge.h b/src/core/instrument/param/param_merge.h new file mode 100644 index 0000000..004ad91 --- /dev/null +++ b/src/core/instrument/param/param_merge.h @@ -0,0 +1,50 @@ +// param_merge.h — the audio thread's block-boundary merge DECISION, with no host type and no +// atomic in it: which held automation points still outrank the model, which the model has caught +// up on and are released, and whether the result is worth republishing. The AUTHORITY MODEL it +// implements is stated in `shell/instrument/CLAUDE.md`; this is its testable half. + +#pragma once + +#include + +#include "core/instrument/engine/live_params.h" +#include "core/instrument/ui/deck_groups.h" // DeckParam (the ordinal space slots are indexed by) + +namespace reasampler::instrument::param { + +using ui::DeckParam; + +// The DeckParam ordinal space. One slot per control, indexed by ordinal, so a lookup is an index +// rather than a search on the audio thread. +inline constexpr std::size_t kDeckParamSlots = static_cast(DeckParam::kCount); + +// One control's automation state as the merge sees it. +struct AutomationSlot { + double norm = 0.0; // the last point this lane delivered + bool held = false; // that point still outranks the model + bool folded = false; // the model has since been rewritten to carry THAT point +}; + +// Patches every still-held slot over `block`, and RELEASES each slot the model has caught up on. +// +// The release is what BOUNDS a point's authority. A lane outranks a plug-in-side set only while +// it is driving; a value it delivered once, already folded back into the model, outranks nothing. +// Without the release a single automation point would defeat every later state restore, bake +// reset and knob move for the life of the instance — which is the failure this function exists +// to make impossible, and which `test_param_merge` is the test of. +// +// RT-SAFE: no allocation, no lock, no transcendental beyond the tapers' own. +void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size_t count, + int sampleRate); + +// Whether a point of `normalized` for a slot in this state actually moves the block. False for a +// point equal to a hold that is still standing — the ordinary read-mode steady state, where a +// host delivers one point per block over a flat lane segment. Republishing there would drive +// `VoiceEngine::refreshLive` over every sounding voice — a `std::pow`, two envelope φ re-fits and +// the filter ramp aims, per voice — for a value that did not move. Once the hold has been +// RELEASED the answer is true again, because some other writer may have moved the model since. +inline bool automationPointMoves(const AutomationSlot& slot, double normalized) { + return !slot.held || slot.norm != normalized; +} + +} // namespace reasampler::instrument::param diff --git a/src/core/instrument/param/param_units.cpp b/src/core/instrument/param/param_units.cpp index bcd20bc..2b358ba 100644 --- a/src/core/instrument/param/param_units.cpp +++ b/src/core/instrument/param/param_units.cpp @@ -69,8 +69,11 @@ UnitKind unitKindFor(DeckParam deck) { // The twelve curve exponents and the filter's two dimensionless tone controls. Listed // rather than defaulted, and everything with no parameter row at all is listed with // them: a `default:` here would let a control promoted later inherit Dimensionless - // silently, and §6.3 freezes an exposed parameter's normalization on the first shipped - // build — so the wrong answer would be permanent rather than correctable. + // silently, and an exposed parameter's normalization is frozen on the first shipped + // build — so the wrong answer would be permanent rather than correctable. THIS switch is + // the one that has to be exhaustive; the `default:` arms further down are pre-dispatch + // filters that fall through to it, so they inherit its exhaustiveness rather than + // needing their own. case DeckParam::kFilterQ: case DeckParam::kFilterDrive: case DeckParam::kAttackCurve: @@ -105,6 +108,10 @@ UnitKind unitKindFor(DeckParam deck) { case DeckParam::kLimiterEnable: case DeckParam::kMasterMeter: case DeckParam::kMasterGr: + return UnitKind::Dimensionless; + // The sentinel, on its own arm: it names no control, so its unit string, plain range and + // toPlain law are all arbitrary. It is here only because the switch is exhaustive, and + // it stays out of the run above so that run reads as a list of real controls. case DeckParam::kCount: return UnitKind::Dimensionless; } @@ -231,6 +238,14 @@ double toNormalized(DeckParam deck, double plain) { return plain; } +double hostStoredFromNorm(DeckParam deck, double normalized) { + // See the header for why the detent is a drag affordance and not part of the value law. + if (ui::deckParamUnit(deck) == ui::UnitCategory::Exponent) { + return util::curveFromKnobNormUndetented(normalized); + } + return ui::storedFromNorm(deck, normalized); +} + ValueHome valueHomeFor(DeckParam deck) { PlaySeconds defaults; if (ui::deckFloatField(deck, defaults)) return ValueHome::ParamSetNorm; diff --git a/src/core/instrument/param/param_units.h b/src/core/instrument/param/param_units.h index bbe2ce5..ee16742 100644 --- a/src/core/instrument/param/param_units.h +++ b/src/core/instrument/param/param_units.h @@ -48,6 +48,16 @@ PlainRange plainRangeFor(DeckParam deck); double toPlain(DeckParam deck, double normalized); double toNormalized(DeckParam deck, double plain); +// The STORED value a host write of `normalized` lands on — `ui::storedFromNorm` for every +// control except the twelve curve exponents, where the knob law's ±0.01 detent is skipped. That +// detent is a DRAG affordance: a drag grid lands on the identity only by luck, so a band wider +// than one drag step snaps to it. A host lane has no grid and `curveFromKnobNorm` already +// answers exactly 1.0 at norm 0.5, so applying the detent here would not make anything +// reachable — it would flatten a knot-drawn near-neutral exponent to 1.0 on any lane pass. +// BOTH host write paths take this map (the model's `writeHostParam`, the audio thread's +// `applyLiveParam`), which is what keeps them from landing different values in the same block. +double hostStoredFromNorm(DeckParam deck, double normalized); + // WHERE a control's value actually lives. The host's read and write paths branch on this, and // the exposed set is asserted against it: a control promoted into the list with no home would // otherwise no-op silently in BOTH directions, with nothing to catch it at compile time. diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index 6370593..7a977aa 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -169,11 +169,14 @@ DeckParam curveParamFor(DeckParam knob); // tier answers "does an edit reach the audio without a reload", not "which mechanism carries // it" — classifying it Reload would have said a gain move re-decodes the WAV, which it never did. // -// kRate is the one NoteOnLatched control, and the reason is a real feature rather than a -// plumbing detail: loop points and contours both scale with rate, and both are note-on folds — -// resolveLoop runs once per note-on and a contour resolves against the note's own span. A live -// rate would mean re-folding an already-resolved loop and re-mapping a contour mid-note without -// a discontinuity. kPitch is not implicated and is ordinarily Live. +// THREE controls are NoteOnLatched: kRate, kKeyTrack and kTrigLength. The exclusions list above +// already gives the latter two their reason — both were Reload until they were promoted so they +// could be automated at all, since a reload per automation point re-decodes the WAV. kRate's +// reason is its own, and is a real feature rather than a plumbing detail: loop points and +// contours both scale with rate, and both are note-on folds — resolveLoop runs once per note-on +// and a contour resolves against the note's own span. A live rate would mean re-folding an +// already-resolved loop and re-mapping a contour mid-note without a discontinuity. kPitch is not +// implicated and is ordinarily Live. enum class LiveCommit { Live, NoteOnLatched, Reload }; LiveCommit deckParamCommit(DeckParam id); diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index 66e4b49..87fe785 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -63,7 +63,7 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) { case DeckParam::kFilterDrive: return clamp01(play.filter.settings.driveNorm); case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount); case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount); - case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax); + case DeckParam::kFilterKeyTrack: return keyTrackNormFrom(play.filter.keyTrack); case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds); case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds); case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds); @@ -341,8 +341,8 @@ double snapDeckParamNorm(DeckParam id, double norm) { snapFractionToWholePercent(deckBipolarFromNorm(norm))); case DeckParam::kKeyTrack: case DeckParam::kFilterKeyTrack: - return clamp01( - snapFractionToWholePercent(clamp01(norm) * kKeyTrackMax) / kKeyTrackMax); + return keyTrackNormFrom( + snapFractionToWholePercent(keyTrackFromNorm(norm))); default: return clamp01(snapFractionToWholePercent(clamp01(norm))); } diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index 4303ac9..3609ca2 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -29,10 +29,10 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones; // Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls. inline constexpr double kKeyTrackMax = 2.0; -// The pitch key-track scalar lives beside the play bundle (on InstrumentParams / SampleData), -// so its two conversions cannot ride the PlaySeconds binding below. One home for them anyway: -// the editor knob, the host's write path and the live fold would otherwise each spell the -// division out. +// The key-track norm <-> stored pair, shared by BOTH key-track controls. It gets its own home +// because the pitch one's value lives beside the play bundle (on InstrumentParams / SampleData) +// and so cannot ride the PlaySeconds binding below — leaving the editor knob, the host's write +// path, the live fold and the snap to each spell the division out. Every one of them calls these. inline double keyTrackFromNorm(double norm) { return util::clamp01(norm) * kKeyTrackMax; } inline double keyTrackNormFrom(double keyTrack) { return util::clamp01(keyTrack / kKeyTrackMax); } diff --git a/src/core/util/curve_law.h b/src/core/util/curve_law.h index 27bb25f..7c7aaa9 100644 --- a/src/core/util/curve_law.h +++ b/src/core/util/curve_law.h @@ -46,11 +46,20 @@ inline double clampCurve(double exponent) { // and a dial swept through the centre cannot skip over it. inline constexpr double kCurveKnobDetent = 0.01; +// The same travel with the detent NOT applied — the map for a writer that has no drag grid. A +// host automation lane delivers a NUMBER, not a gesture, so snapping it would not make the +// identity reachable (t == 0.5 already evaluates exp(0) == 1.0 exactly here); it would only +// destroy a near-neutral exponent the user set some other way. +inline double curveFromKnobNormUndetented(double norm) { + const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm); + return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax))); +} + inline double curveFromKnobNorm(double norm) { const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm); const double off = t - 0.5; if (off < kCurveKnobDetent && off > -kCurveKnobDetent) return kCurveNeutral; - return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax))); + return curveFromKnobNormUndetented(t); } inline double knobNormFromCurve(double exponent) { diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 698829c..771cd83 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -109,17 +109,76 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f the audio thread and may allocate on the way; `process()` merges that block with the host's automation points into `automationLive_`, which is what `SampleData::live` points at. Two blocks rather than one because the seqlock's single-writer contract is load-bearing and the two - writers genuinely differ in thread. The merge republishes ONLY when either side moved, so a - block carrying neither costs one relaxed load and the engine's read shape is unchanged. + writers genuinely differ in thread. + +### THE AUTHORITY MODEL — who may write a parameter's value, and until when + +Two passes got this subtly wrong in opposite directions (the first delivered automation on the +wrong channel; the second made a point's authority permanent), because the model was in nobody's +head and nowhere in the tree. It is here, and the code follows it. + +**`ReaSamplerProcessor::params_` — plus the two instance scalars beside it — is THE model, and +the single authority.** Everything else that holds these values is a cache or a courier: + +| Writer | Authority begins | Authority ends | +|---|---|---| +| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model | +| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) | +| State restore (`setState`) | the call | the call returns | +| Bake reset (`adoptBakedCapture`) | the call | the call returns | +| Reload seed (`reloadInstrument`) | under `reloadMutex_` | the publish (it re-folds the model) | +| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** | + +Every writer except the last writes the model directly, so for those "authority ends" is just +"the write happened". The automation lane is the only one that cannot: the SDK delivers it on the +audio thread, where the model path allocates (`resolvePlay` copies velocity curves and spline +contours). So it patches the engine-facing block in place and is couriered to the UI thread, +which folds it into the model on the next tick. + +**The hold is the bridge across that gap, and nothing more.** Between the point landing and the +fold — at most one UI tick — the model does not yet carry the value, so a model republish in that +window (any knob move) would revert the automated parameter until the lane's next point. The hold +re-applies the point over every merge to stop that. The instant the model carries the value, the +hold has no job and is **released**; from then on every writer above reaches the audio normally. + +**Contention resolves BY RULE, not by timing.** A point outranks the model while the lane is +driving and the model has not caught up — which is VST3's own authority rule (a lane in +read/write mode outranks a plug-in-side set). It does NOT outrank a later restore, bake reset or +knob move, because by then the lane is no longer driving that value; the model is. + +**Where it is enforced, and what fails if it stops holding.** +- The decision is the pure `core/instrument/param/param_merge`'s `mergeAutomation`; + `tests/test_param_merge.cpp`'s + `testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt` is the test — it asserts both halves, + including that a writer AFTER the release reaches the audio. A latch with no release fails it. +- The mechanism — the per-slot sequence the audio thread stamps and the UI thread answers, and + the acquire/release ordering that makes a release imply the publish is visible — is + `automation_channel.h`'s, at its two methods. +- **The release is stored LAST in `drainAutomationToModel`**, after `setInstrumentParams` and + `publishLiveParams`. Moving it earlier reintroduces a one-block revert. +- **`setState` therefore needs no ordering guarantee against the host's first parameter block.** + A lane that is driving re-applies over the restore; a lane that merely sent a point once, and + had it folded, does not — which is the correct reading of the SDK rule, and the one the second + pass got wrong. + +**The editor's `params_` is a CACHE of the model, authoritative for one gesture only.** A commit +writes the WHOLE set back, and `notifyParamsFromModel` diffs it — so a stale copy would +`performEdit` superseded values the user never touched, which a lane in latch or write mode +records. The sync tick re-seeds it (past the drag guard) whenever `paramsGeneration_` has moved +under it: the automation fold, the host's generic panel, a state restore. + +**Two independent gates keep a value-identical point off the per-voice fan-out**, and they cover +different windows: `AutomationChannel::land` drops a repeat of a standing hold whole (the flat +read-mode segment, where a host sends one point per block), and the merge publishes only when the +merged block differs from the last (a model republish that changed nothing). Neither is measured +against a performance budget — they are there because `VoiceEngine::refreshLive` runs +`voice.applyLive` over every active voice, and neither case needs it. + - **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`, called from `getState`, the editor's sync tick, and the bake's reload tail). The blob is authoritative, so a value that never came back would be lost on save. The fold is suppressed from notifying the host — the values came FROM it, and echoing them would let a lane in write mode re-record its own playback. -- **`setState` does not need an ordering guarantee against the host's first parameter block.** - An automation point held by the audio thread is re-applied over every merge, so a written lane - outranks the restore whichever way round the two arrive — which is VST3's own rule, not a race - we lost. - **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what is exposed, an invented map would hijack CCs the user's controller already sends, and `[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without @@ -155,7 +214,9 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. - `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry. -- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws and the formatter. +- `instrument_params` — the VST3 adapter over `core/instrument/param`: one `Parameter` subclass whose `toPlain`/`toNormalized` ARE the taper and whose `toString` calls the one formatter, the single construction of the unit and parameter lists (ascending id, which is also the presentation order), the `setParamNormalized` projection onto the model through each control's existing commit tier, the audio thread's queue drain and the UI thread's fold + release, and the `beginEdit`/`performEdit`/`endEdit` notification path every internal writer reaches through `setInstrumentParams`. Decides nothing — the pure module owns the table, the laws, the formatter and the merge. +- `automation_channel.h` — the host automation lane's per-instance state and the mechanism of its authority lifetime: the audio thread's hold, the per-slot sequence it stamps, the UI thread's release answer, and the acquire/release ordering that makes a release imply the model publish is visible. The MODEL it enforces is the Authority section above; the pure decision it feeds is `core/instrument/param/param_merge`. Internal to this TU family. +- `processor_snapshot.h` — the two namespace-scope aggregates the processor hands across its thread boundary: `LoadedInstrument` (the decoded capture plus the engine playing it, swapped through the drain slot) and `MasterBusMeter` (what the audio thread publishes per block for the editor's meter). Split out of `reasampler_processor.h` on `editor_interaction.h`'s grounds — neither is behaviour. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. - `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. @@ -163,12 +224,13 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f ## Gotchas -- **`reasampler_processor.h` is a documented ~600-line-ceiling exception** (root `CLAUDE.md`, - structural heuristic 1), on the same footing as `voice.h`'s: it is ONE class declaration, so - the seam the heuristic asks for does not exist — a split would be an arbitrary bisection, and - the implementation is already split across three TUs on its real seams. Its bulk is the - drain-slot proof, the RT-discipline constraints and the two-block automation contract, all of - which the comment conventions name as keep-worthy. Not silent overshoot. +- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a + false premise.** It was described as ONE class declaration; it also carried two namespace-scope + aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are + now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds + `editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is + behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the + RT-discipline constraints, which the comment conventions name as keep-worthy. - **The bake click only ARMS; the editor's sync tick runs it.** Calling `Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index 0b43c81..7d4eaf8 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -90,7 +90,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") waveform_view loop_marks bank_sync browser_scroll param_slider tooltip theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage - param_id param_units param_format param_live + param_id param_units param_format param_live param_merge limiter meter_accumulate meter_ballistics master_meter bake_hold file_bytes curve_law stroke_aa curve_tessellate diff --git a/src/shell/instrument/automation_channel.h b/src/shell/instrument/automation_channel.h new file mode 100644 index 0000000..26d8b9c --- /dev/null +++ b/src/shell/instrument/automation_channel.h @@ -0,0 +1,95 @@ +// automation_channel.h — the host automation lane's per-instance state, and the ONE place its +// AUTHORITY LIFETIME is mechanised: a point outranks the model from the block it lands in until +// the UI thread has folded it back into the model AND republished. This directory's CLAUDE.md +// states the model; `core/instrument/param/param_merge` is the pure decision this feeds. + +#pragma once + +#include +#include +#include + +#include "core/instrument/param/param_merge.h" + +namespace reasampler::vst { + +// The DeckParam ordinal space — what the automation slots and the notification diff both index. +inline constexpr std::size_t kDeckParamSlots = instrument::param::kDeckParamSlots; + +class AutomationChannel { +public: + // --- AUDIO THREAD ------------------------------------------------------------------- + // A point landed for `slot`. `ridesTheBlock` is false for a control that reaches the audio + // beside the live block (master gain, whose route is the processor's own atomic): such a + // point takes no hold and does not make the block dirty, so a lane on it alone cannot drive + // the per-voice fan-out every block for a value the block does not carry. + // + // Answers whether the block MOVED, which is what makes the merge conditional. A repeat of a + // standing hold moves nothing and is dropped whole — no hold rewrite, no UI publish — because + // it would only make the fold rewrite the model with the value already in it. + bool land(std::size_t slot, double normalized, bool ridesTheBlock) { + if (ridesTheBlock && !instrument::param::automationPointMoves(slots_[slot], normalized)) { + return false; + } + if (ridesTheBlock) { + slots_[slot].norm = normalized; + slots_[slot].held = true; + } + published_[slot].store(normalized, std::memory_order_relaxed); + // The sequence is stored LAST and with release: the fold reads it FIRST and only then + // trusts the value beside it. + seq_[slot].store(seq_[slot].load(std::memory_order_relaxed) + 1, + std::memory_order_release); + any_.store(true, std::memory_order_release); + return ridesTheBlock; + } + + // Refreshes each held slot's release answer. Must run BEFORE the model block is read: the + // acquire here synchronizes with the UI thread's release store, which it makes only AFTER + // republishing the model — so a slot seen released is one whose value any block read after + // this point is guaranteed to already carry. + void refreshReleases() { + for (std::size_t i = 0; i < kDeckParamSlots; ++i) { + if (!slots_[i].held) continue; + slots_[i].folded = folded_[i].load(std::memory_order_acquire) == + seq_[i].load(std::memory_order_relaxed); + } + } + + instrument::param::AutomationSlot* slots() { return slots_; } + + // --- UI THREAD ---------------------------------------------------------------------- + // True when at least one point has landed since the last drain. + bool takePending() { return any_.exchange(false, std::memory_order_acquire); } + + // The value and sequence of `slot`'s unfolded point, or false when there is nothing new. + bool takeSlot(std::size_t slot, double& value, std::uint32_t& seq) const { + seq = seq_[slot].load(std::memory_order_acquire); + if (seq == folded_[slot].load(std::memory_order_relaxed)) return false; + value = published_[slot].load(std::memory_order_relaxed); + return true; + } + + // Releases `slot`'s hold. ONLY legal once the model carrying that point has been republished + // — calling it earlier would let the audio thread drop the hold ahead of the block that + // carries its value, which is a one-block revert to the superseded value. + void release(std::size_t slot, std::uint32_t seq) { + folded_[slot].store(seq, std::memory_order_release); + } + +private: + instrument::param::AutomationSlot slots_[kDeckParamSlots] = {}; // audio thread only + std::atomic published_[kDeckParamSlots] = {}; + std::atomic seq_[kDeckParamSlots] = {}; // written by the audio thread + std::atomic folded_[kDeckParamSlots] = {}; // written by the UI thread + std::atomic any_{false}; // makes the UI thread's idle drain a single exchange +}; + +// The publication atomics above are read on the audio thread; a locked implementation would be a +// hidden mutex on it. Structural rather than assumed, for a class whose thesis is RT discipline. +static_assert(std::atomic::is_always_lock_free, + "the automation publication must be lock-free — the audio thread writes it"); +static_assert(std::atomic::is_always_lock_free, + "the automation sequence must be lock-free — the audio thread writes it"); + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 43fb78f..4808c18 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -36,7 +36,6 @@ using instrument::ui::kDeckKnobSize; using instrument::ui::kPad; using instrument::ui::deckParamNorm; using instrument::ui::kEnvTimeMaxSeconds; -using instrument::ui::kKeyTrackMax; using instrument::ui::resetDeckParam; using instrument::ui::sampleDeckGroups; using instrument::ui::setDeckParam; @@ -119,7 +118,7 @@ double ReaSamplerEditor::deckControlNorm(int id) const { if (id == kBakeHoldKnobId) return bakeHoldNorm(); switch (static_cast(id)) { case ParamControl::kKeyTrack: - return clamp01(params_.keyTrack / kKeyTrackMax); + return instrument::ui::keyTrackNormFrom(params_.keyTrack); case ParamControl::kVoiceCount: return clamp01(static_cast(voiceCount_ - kMinVoiceCount) / static_cast(kMaxVoiceCount - kMinVoiceCount)); @@ -272,8 +271,9 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const { void ReaSamplerEditor::applyParamControl(int id, double value, int segment) { if (id == static_cast(ParamControl::kKeyTrack)) { - // keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1. - params_.keyTrack = clamp01(value) * kKeyTrackMax; + // keyTrack sits beside the play bundle, so it takes deck_values' own pair rather than + // the PlaySeconds binding. + params_.keyTrack = instrument::ui::keyTrackFromNorm(value); } else { applyControl(id, params_.play, value, segment); } diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index e7f7335..0558f13 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -70,7 +70,7 @@ void ReaSamplerEditor::refreshFromBank() { samples_ = banksJson ? listSamples(*banksJson) : std::vector{}; banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; selectedId_ = processor_->selectedSampleId(); - params_ = processor_->instrumentParams(); + params_ = processor_->instrumentParams(seenParamsGeneration_); channelMode_ = processor_->channelMode(); voiceCount_ = processor_->voiceCount(); voiceMode_ = processor_->voiceMode(); @@ -146,6 +146,17 @@ void ReaSamplerEditor::onSyncTimer() { processor_->flushGainNotify(); processor_->drainAutomationToModel(); + // params_ is a CACHE of the processor's model, authoritative only for the duration of a + // gesture — which is why this sits past the drag guard. Re-seed it whenever the model has + // moved under it: the automation fold just above, the host's own generic panel, a state + // restore. Without this, commitLive writes the WHOLE stale set back and notifyParamsFromModel + // diffs it as a real edit, performEdit-ing superseded values the user never touched — which a + // lane in latch or write mode records. + if (processor_->instrumentParamsGeneration() != seenParamsGeneration_) { + params_ = processor_->instrumentParams(seenParamsGeneration_); + invalidate(); + } + // Resolve the bake affordance's availability on the SAME tick that paints it, so it // can never be enabled on one tick and refuse on the next. const bool available = bakeAvailable(processor_->bridge()); @@ -201,6 +212,9 @@ void ReaSamplerEditor::commitAndReload() { if (!processor_) return; processor_->setSelectedSampleId(selectedId_); processor_->setInstrumentParams(params_); + // This copy IS the model now, so adopt the generation it produced rather than re-seeding off + // it on the next tick. Same reason at commitLive. + seenParamsGeneration_ = processor_->instrumentParamsGeneration(); processor_->reloadInstrument(); // The reload may have auto-defaulted the channel mode (implicit only) — re-read so the // toggle draws what the engine actually decoded with. @@ -214,6 +228,7 @@ void ReaSamplerEditor::commitLive() { // UI thread only. See the declaration for why this still writes the parameter set. if (!processor_) return; processor_->setInstrumentParams(params_); + seenParamsGeneration_ = processor_->instrumentParamsGeneration(); processor_->publishLiveParams(); } diff --git a/src/shell/instrument/instrument_params.cpp b/src/shell/instrument/instrument_params.cpp index 882ba19..79f223d 100644 --- a/src/shell/instrument/instrument_params.cpp +++ b/src/shell/instrument/instrument_params.cpp @@ -13,6 +13,7 @@ #include "core/instrument/engine/master_gain.h" #include "core/instrument/param/param_format.h" #include "core/instrument/param/param_id.h" +#include "core/instrument/param/param_live.h" // writeHostParam (the model side of a host write) #include "core/instrument/param/param_units.h" #include "core/instrument/ui/deck_values.h" @@ -133,23 +134,29 @@ tresult PLUGIN_API ReaSamplerProcessor::setParamNormalized(ParamID tag, ParamVal void ReaSamplerProcessor::writeDeckParamToModel(InstrumentParams& params, DeckParam deck, double normalized) { - // The two homes a control's value can have, and the ONE place the host path knows the - // difference — the editor draws the same split at applyParamControl. param::valueHomeFor is - // the predicate; a promotion whose control has neither home fails param_units' own test - // rather than no-oping silently here. - if (deck == DeckParam::kKeyTrack) { - params.keyTrack = instrument::ui::keyTrackFromNorm(normalized); + // WHICH home a control's value has is param::valueHomeFor's answer, not a list repeated here + // — the read peer below branches on the same predicate, the editor draws the same split at + // applyParamControl, and the instance-scalar set's ARITY is pinned by param_live's own test, + // so a third one cannot appear without a failure. The value map itself is param_live's, which + // is what makes this write and the audio thread's patch of the same point agree. + if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) { + // Master gain never arrives here — both callers route it to setMasterGainLinear, its own + // funnel — so key-track is the whole of this arm. + if (deck == DeckParam::kKeyTrack) { + params.keyTrack = instrument::ui::keyTrackFromNorm(normalized); + } return; } - instrument::ui::setDeckParam(deck, params.play, normalized, /*segment=*/0); + param::writeHostParam(deck, params.play, normalized); } double ReaSamplerProcessor::modelParamNormalized(const InstrumentParams& params, DeckParam deck) const { - if (deck == DeckParam::kMasterGain) { - return instrument::engine::masterGainNormFromLinear(masterGainLinear()); + if (param::valueHomeFor(deck) == param::ValueHome::InstanceScalar) { + return deck == DeckParam::kMasterGain + ? instrument::engine::masterGainNormFromLinear(masterGainLinear()) + : instrument::ui::keyTrackNormFrom(params.keyTrack); } - if (deck == DeckParam::kKeyTrack) return instrument::ui::keyTrackNormFrom(params.keyTrack); return instrument::ui::deckParamNorm(deck, params.play); } @@ -196,7 +203,11 @@ void ReaSamplerProcessor::notifyParamChanged(param::ParamId id, double normalize } if (gestureLatching_ && openGestureCount_ < kMaxOpenGestures) { // First move this drag has made on this parameter: open its bracket and hold it, so the - // whole drag is one edit rather than a run of one-point ones. + // whole drag is one edit rather than a run of one-point ones. An UNRELATED writer that + // reaches here mid-drag (the sync tick's flushGainNotify) latches into the same bracket + // set, so the host sees its touch end when the drag does rather than at once — bounded by + // the drag and correctly closed, and the alternative (a second bracket state per writer) + // buys a distinction no host acts on. openGestureIds_[openGestureCount_++] = id; beginEdit(id); performEdit(id, normalized); @@ -244,34 +255,40 @@ bool ReaSamplerProcessor::drainInputParameterChanges(IParameterChanges* changes) if (queue->getPoint(points - 1, offset, value) != kResultTrue) continue; const param::ParamRow* row = param::exposedRowFor(queue->getParameterId()); if (!row) continue; - landed = true; - const auto slot = static_cast(row->deck); - automationNorm_[slot] = value; - automationHeld_[slot] = true; // Master gain reaches the audio beside the block rather than through it, so its - // automation write is the same one relaxed store the knob makes. - if (row->deck == DeckParam::kMasterGain) { + // automation write is the same one relaxed store the knob makes — and it takes no hold, + // which is what keeps a lane on it alone from republishing the block every block. + const bool ridesTheBlock = row->deck != DeckParam::kMasterGain; + if (!ridesTheBlock) { masterGain_.store( static_cast(instrument::engine::masterGainLinearFromNorm(value)), std::memory_order_relaxed); } - // Publish to the UI thread, which folds it back into the model — the blob stays + // Also published to the UI thread, which folds it back into the model — the blob stays // authoritative, so a value that never came back would be lost on save. - automationPublished_[slot].store(value, std::memory_order_relaxed); - automationPending_[slot].store(true, std::memory_order_release); + landed |= automation_.land(static_cast(row->deck), value, ridesTheBlock); } - if (landed) automationAny_.store(true, std::memory_order_release); return landed; } void ReaSamplerProcessor::drainAutomationToModel() { - if (!automationAny_.exchange(false, std::memory_order_acquire)) return; + if (!automation_.takePending()) return; InstrumentParams params = instrumentParams(); + // What was folded, and at which sequence. Held back rather than released as we go: the + // release below is a statement that the MODEL carries the point, which is only true once the + // publish has happened. + std::size_t foldedSlots[kDeckParamSlots]; + std::uint32_t foldedSeqs[kDeckParamSlots]; + std::size_t foldedCount = 0; bool moved = false; for (const param::ParamRow& row : param::exposedParams()) { const auto slot = static_cast(row.deck); - if (!automationPending_[slot].exchange(false, std::memory_order_acquire)) continue; - const double value = automationPublished_[slot].load(std::memory_order_relaxed); + double value = 0.0; + std::uint32_t seq = 0; + if (!automation_.takeSlot(slot, value, seq)) continue; + foldedSlots[foldedCount] = slot; + foldedSeqs[foldedCount] = seq; + ++foldedCount; // Master gain's model IS the atomic the audio thread already wrote; there is nothing to // fold, only the controller cache to refresh below. if (row.deck != DeckParam::kMasterGain) { @@ -290,6 +307,13 @@ void ReaSamplerProcessor::drainAutomationToModel() { } syncParamsFromModel(); paramNotifySuppressed_ = wasSuppressed; + // LAST, and that is the whole authority rule: the hold outranks the model only until the + // model carries the point. Released any earlier and the audio thread could drop the hold + // ahead of the block that carries its value; never released at all — the defect this + // replaces — and one point would defeat every later restore, reset and knob move. + for (std::size_t i = 0; i < foldedCount; ++i) { + automation_.release(foldedSlots[i], foldedSeqs[i]); + } } void ReaSamplerProcessor::endParamGesture() { diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 3e5b05a..5c32d62 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -153,13 +153,18 @@ std::string ReaSamplerProcessor::reloadInstrument() { // top of the first block after this, which is where the swap below becomes visible // too — so the new snapshot's first note reads it. sample.live = &automationLive_; + // BEFORE the publish: the publish is what makes the audio thread re-merge, and the + // merge resolves every held automation stage time against this rate. Stored after, + // a merge in the window between them would resolve them against the previous + // capture's rate — or, on the first-ever build, against 0, where secondsToFrames + // collapses every automated envelope stage to zero frames. + builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed); { // reloadMutex_ (held for this whole function) nests livePublishMutex_ here; // publishLiveParams never holds reloadMutex_, so this is the only nesting. std::lock_guard lp(livePublishMutex_); liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack)); } - builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed); resolvedId = selId; // the concrete pick that resolved } } diff --git a/src/shell/instrument/processor_snapshot.h b/src/shell/instrument/processor_snapshot.h new file mode 100644 index 0000000..fe8805f --- /dev/null +++ b/src/shell/instrument/processor_snapshot.h @@ -0,0 +1,57 @@ +// processor_snapshot.h — the two namespace-scope aggregates the processor hands ACROSS its +// thread boundary: the loaded instrument the audio thread renders, and the bus state it +// publishes back for the editor's meter. Neither is behaviour, which is what makes them a +// responsibility rather than a bisection of the processor's own declaration. + +#pragma once + +#include +#include + +#include "core/instrument/engine/voice_engine.h" + +namespace reasampler::vst { + +// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw +// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics), +// because a hold timer or a log on the audio thread would be per-block work that buys nothing. +struct MasterBusMeter { + float peakL = 0.f; // max |x| this block + float peakR = 0.f; + // Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT + // scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction) + // rather than the mute's own weight. 1 = no reduction. + float minGain = 1.f; + bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it +}; + +// The decoded capture + the voice engine playing it. The engine holds a reference to the +// sample, so both must live/die together at a stable address — heap-allocated, +// non-copyable, non-movable. process() only ever reads this through an atomic pointer. +struct LoadedInstrument { + SampleData sample; + VoiceEngine engine; + std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_ + + // Takeover declick is on by default here (product default; the pure core defaults it + // off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead + // of clicking. + LoadedInstrument(SampleData sd, std::size_t maxVoices, + std::uint64_t gen, std::size_t preserveVoiceCap = 0, + std::int64_t preserveWindowFrames = 0, + VoiceMode voiceMode = VoiceMode::Poly, + MonoTrigger monoTrigger = MonoTrigger::Retrigger) + : sample(std::move(sd)), + engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames, + voiceMode, monoTrigger, /*takeoverDeclick=*/true), + installedAt(gen) {} + + // True when nothing in this snapshot is sounding; lets the off-thread retirer park an + // idle drain early. Bounded scan (<= maxVoices). + bool fullyIdle() const { return engine.activeVoiceCount() == 0; } + + LoadedInstrument(const LoadedInstrument&) = delete; + LoadedInstrument& operator=(const LoadedInstrument&) = delete; +}; + +} // namespace reasampler::vst diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 30d63ba..bf400d1 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -168,6 +168,17 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() { return params_; } +std::uint32_t ReaSamplerProcessor::instrumentParamsGeneration() { + std::lock_guard lock(paramsMutex_); + return paramsGeneration_; +} + +InstrumentParams ReaSamplerProcessor::instrumentParams(std::uint32_t& generation) { + std::lock_guard lock(paramsMutex_); + generation = paramsGeneration_; + return params_; +} + void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { // The exposed values alone, not the whole set: this funnel fires per mouse move on every live // knob and node drag, and InstrumentParams owns seven vectors — copying all of them to diff @@ -179,6 +190,9 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { before[static_cast(row.deck)] = modelParamNormalized(params_, row.deck); } params_ = params; + // Bumped inside the lock with the write it names, so a reader taking the pair together + // can never see a generation that does not describe the set beside it. + ++paramsGeneration_; } // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — // funnels through here, so mirroring the limiter flag at this one point is what keeps the diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 31f4d54..308599b 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -420,7 +420,11 @@ private: std::vector banks_; // the named banks, for the filter tab strip std::vector visible_; // samples_ narrowed by the active bank filter std::string selectedId_; // the loaded capture ("" = empty state) - InstrumentParams params_; // the ONE parameter set governing it + InstrumentParams params_; // a CACHE of the processor's model (see below) + // The model generation params_ was taken at. That copy is authoritative only for the + // duration of a gesture; between gestures the sync tick re-seeds it whenever this differs + // from the processor's, because a commit writes the WHOLE set back. + std::uint32_t seenParamsGeneration_ = 0; ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot // Mirrors of the processor's persisted voice-system params, refreshed with the rest of the diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index 1934dd5..da57df0 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -17,7 +17,7 @@ #include "pluginterfaces/vst/vstspeaker.h" #include "core/instrument/engine/master_gain.h" // the automation write of the gain's own atomic -#include "core/instrument/param/param_live.h" // the RT-safe patch of one control into the block +#include "core/instrument/param/param_merge.h" // the block-boundary merge decision #include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor #include "shell/instrument/reasampler_embed.h" // embed shell + IReaperUIEmbedInterface (its iid DEF'd there) @@ -207,24 +207,31 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // the merge is here rather than in the model's own publisher. { const bool dirty = drainInputParameterChanges(data.inputParameterChanges); - const std::uint32_t modelGen = liveParams_.generation(); - if (dirty || modelGen != seenModelGeneration_) { + if (dirty || liveParams_.generation() != seenModelGeneration_) { + // BEFORE the block is read, and the ordering is load-bearing — automation_channel.h + // owns why. + automation_.refreshReleases(); // Declared INSIDE the branch: LiveValues carries default member initializers, so a // block where nothing moved must not pay to construct one. instrument::engine::LiveValues merged; - // Re-read the model's fold and re-apply every held automation value over it: without - // the re-apply, any knob move would revert an automated parameter until its lane's - // next point. - if (liveParams_.read(merged) != 0) { - seenModelGeneration_ = modelGen; - const int builtRate = builtSampleRate_.load(std::memory_order_relaxed); - for (std::size_t i = 0; i < kDeckParamSlots; ++i) { - if (!automationHeld_[i]) continue; - instrument::param::applyLiveParam( - merged, static_cast(i), automationNorm_[i], - builtRate); + // The generation ACTUALLY observed, not the one sampled above: a publish landing + // between the two would otherwise leave this thread re-merging an identical block + // every following quiet one. + const std::uint32_t observed = liveParams_.read(merged); + if (observed != 0) { + seenModelGeneration_ = observed; + instrument::param::mergeAutomation( + merged, automation_.slots(), kDeckParamSlots, + builtSampleRate_.load(std::memory_order_relaxed)); + // The second gate, and a different window from the drain's own: this one catches + // a model republish that changed nothing (a knob committed to the value it + // already held, a released hold whose value the model now carries). Field-wise + // — live_params.h owns why it must never become a memcmp. + if (!haveMergedLive_ || merged != lastMergedLive_) { + lastMergedLive_ = merged; + haveMergedLive_ = true; + automationLive_.publish(merged); } - automationLive_.publish(merged); } } } diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index adea0f4..90310e5 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -17,13 +17,14 @@ #include "public.sdk/source/vst/vstsinglecomponenteffect.h" +#include "shell/instrument/automation_channel.h" // the automation hold + its release protocol +#include "shell/instrument/processor_snapshot.h" // LoadedInstrument + MasterBusMeter #include "shell/instrument/reaper_bridge.h" #include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set) #include "core/instrument/map/component_state_io.h" // ComponentState codec #include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter #include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block) #include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume -#include "core/instrument/engine/voice_engine.h" #include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding namespace reasampler::vst { @@ -36,48 +37,6 @@ using instrument::map::kPreviewVelocityDefault; class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface) -// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw -// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics), -// because a hold timer or a log on the audio thread would be per-block work that buys nothing. -struct MasterBusMeter { - float peakL = 0.f; // max |x| this block - float peakR = 0.f; - // Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT - // scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction) - // rather than the mute's own weight. 1 = no reduction. - float minGain = 1.f; - bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it -}; - -// The decoded capture + the voice engine playing it. The engine holds a reference to the -// sample, so both must live/die together at a stable address — heap-allocated, -// non-copyable, non-movable. process() only ever reads this through an atomic pointer. -struct LoadedInstrument { - SampleData sample; - VoiceEngine engine; - std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_ - - // Takeover declick is on by default here (product default; the pure core defaults it - // off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead - // of clicking. - LoadedInstrument(SampleData sd, std::size_t maxVoices, - std::uint64_t gen, std::size_t preserveVoiceCap = 0, - std::int64_t preserveWindowFrames = 0, - VoiceMode voiceMode = VoiceMode::Poly, - MonoTrigger monoTrigger = MonoTrigger::Retrigger) - : sample(std::move(sd)), - engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames, - voiceMode, monoTrigger, /*takeoverDeclick=*/true), - installedAt(gen) {} - - // True when nothing in this snapshot is sounding; lets the off-thread retirer park an - // idle drain early. Bounded scan (<= maxVoices). - bool fullyIdle() const { return engine.activeVoiceCount() == 0; } - - LoadedInstrument(const LoadedInstrument&) = delete; - LoadedInstrument& operator=(const LoadedInstrument&) = delete; -}; - class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect { public: ReaSamplerProcessor() = default; @@ -140,9 +99,11 @@ public: void syncParamsFromModel(); // Folds what the audio thread took from the host's parameter queue back into the model and - // the controller cache, suppressing the host notification (those values came FROM it). - // UI/main thread; a no-op when nothing was automated. Called wherever the model is about to - // be READ as authoritative — getState, the bake, the editor's tick. + // the controller cache, suppressing the host notification (those values came FROM it), then + // RELEASES each folded point's hold — which is what bounds a lane's authority to the window + // where it is actually driving. UI/main thread; a no-op when nothing was automated. Called + // wherever the model is about to be READ as authoritative — getState, the bake, the editor's + // tick. This directory's CLAUDE.md owns the authority model. void drainAutomationToModel(); // A drag's host-edit bracket, so a host in touch or latch mode records ONE continuous edit @@ -231,6 +192,12 @@ public: InstrumentParams instrumentParams(); void setInstrumentParams(const InstrumentParams& params); + // The model's edit counter, bumped by every setInstrumentParams. A holder of a COPY (the + // editor's snapshot) re-seeds when this moves under it; the overload answers both under one + // lock, because reading them apart would let the pair disagree. + std::uint32_t instrumentParamsGeneration(); + InstrumentParams instrumentParams(std::uint32_t& generation); + // Republishes the live-parameter block from the stored parameter set, resolved against the // rate the loaded capture was built at so an unmoved value folds to exactly the frames the // voices already latched. THE tier-3 commit (the three tiers are listed in this @@ -320,10 +287,10 @@ private: void buildParameterList(); // THE automation read, on the audio thread, at the BLOCK BOUNDARY: the last point of each - // queue wins. RT-safe — relaxed atomic stores only. Sample-accurate application would put a - // per-sample "did anything change" question on the per-voice-per-sample path, which the - // phase-wide guardrail forbids. True when at least one point landed, which is what makes the - // merge below it conditional. + // queue wins. RT-safe — relaxed/release atomic stores only. Sample-accurate application would + // put a per-sample "did anything change" question on the per-voice-per-sample path, which the + // phase-wide guardrail forbids. True when at least one point landed ON A CONTROL THE BLOCK + // CARRIES, which is what makes the merge below it conditional. bool drainInputParameterChanges(Steinberg::Vst::IParameterChanges* changes); // The normalized value a control reads at, from the model — a projection of it, never a @@ -421,25 +388,22 @@ private: // leave the drain's still-sounding voices deaf to the knob under them. instrument::engine::LiveParams liveParams_; // The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by - // the audio thread, which merges liveParams_ with the host's automation points once per - // block and republishes ONLY when either side moved — so a block carrying neither costs one - // relaxed load and the engine's own read shape is unchanged. Two blocks because the seqlock's - // single-writer contract is load-bearing and the two writers differ in thread; this - // directory's CLAUDE.md owns the argument. + // the audio thread, which merges liveParams_ with the host's automation points once per block + // and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane + // resending the value it already sent reaches the per-voice fan-out. A block carrying no + // automation and no model change costs one relaxed load plus, when the host passed a non-null + // IParameterChanges (REAPER's normal case), one cross-module getParameterCount(). Two blocks + // because the seqlock's single-writer contract is load-bearing and the two writers differ in + // thread; this directory's CLAUDE.md owns the argument. instrument::engine::LiveParams automationLive_; - // Audio thread only. The last liveParams_ generation merged, and the sticky automation values - // re-applied over every merge — without them a model republish (any knob move) would revert - // an automated parameter until its lane's next point. + // Audio thread only. The last liveParams_ generation merged, the last block published (the + // republish gate compares against it), and the automation slots themselves. std::uint32_t seenModelGeneration_ = 0; - static constexpr std::size_t kDeckParamSlots = - static_cast(instrument::ui::DeckParam::kCount); - double automationNorm_[kDeckParamSlots] = {}; - bool automationHeld_[kDeckParamSlots] = {}; - // The audio thread's publication of those values to the UI thread's fold. automationAny_ - // makes the idle drain a single load. - std::atomic automationPublished_[kDeckParamSlots] = {}; - std::atomic automationPending_[kDeckParamSlots] = {}; - std::atomic automationAny_{false}; + instrument::engine::LiveValues lastMergedLive_{}; + bool haveMergedLive_ = false; + // The host lane's state and its release protocol; automation_channel.h owns the mechanism and + // this directory's CLAUDE.md the authority model it enforces. + AutomationChannel automation_; // Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams) // only — separate from reloadMutex_ so a knob drag's publish never blocks behind a // reload's WAV decode. The audio thread never takes this; process() only reads via @@ -509,10 +473,13 @@ private: std::mutex selectionMutex_; std::string selectedSampleId_; - // The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData - // under the reload lock, never read directly on the audio thread. + // The one parameter set — THE model, and the authority every other holder of these values + // defers to. Off-thread only; reloadInstrument bakes it into the SampleData under the reload + // lock, never read directly on the audio thread. paramsGeneration_ moves with every write, so + // a holder of a copy can tell that it has. std::mutex paramsMutex_; InstrumentParams params_; + std::uint32_t paramsGeneration_ = 0; // Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed // opportunistically from the bank blob when readable; never a bank dependency for diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp index 28875e9..e8b4ac6 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -938,6 +938,106 @@ static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() { if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life); } +// Key-track is the second member of that class, and it is a PITCH-RATIO scalar: a sounding note +// must not be retuned by it, the next note-on must take it. Measured at a note away from the root +// (the ratio is 1.0 at the root whatever key-track says, so the root would prove nothing). +static void testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne() { + SampleData still = rampForReadRate(); + SampleData moved = rampForReadRate(); + LiveParams blockA, blockB; + LiveValues halfTrack = foldLive(moved.play, moved.keyTrack); + halfTrack.keyTrack = 0.5; // half key-tracking: an octave up reads at ratio ~1.414, not 2.0 + + const std::vector baseline = + renderPreserveCapable(still, blockA, nullptr, -1, 72); + const std::vector swept = + renderPreserveCapable(moved, blockB, &halfTrack, 8, 72); + CHECK(baseline.size() == swept.size()); + bool untouched = true; + for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) { + if (baseline[i] != swept[i]) { untouched = false; break; } + } + CHECK(untouched); + + // The next note-on takes it, read straight off the ramp: under Varispeed the output value at + // frame i IS the read position, so the slope over one block is the pitch ratio. + auto slopePerFrame = [&](double keyTrack) { + SampleData fresh = rampForReadRate(); + LiveParams block; + fresh.live = █ + LiveValues published = foldLive(fresh.play, fresh.keyTrack); + published.keyTrack = keyTrack; + block.publish(published); + VoiceEngine engine(1, fresh); + engine.noteOn(72, 100); + std::vector out; + engine.render(out, 512); + return (static_cast(out.back()) - static_cast(out.front())) / + static_cast(out.size() - 1) * 200000.0; + }; + // kKeyTrackDefault is 1.0 — full tracking, so an octave up reads at 2.0. + CHECK(std::fabs(slopePerFrame(1.0) - 2.0) < 0.01); + CHECK(std::fabs(slopePerFrame(0.5) - std::pow(2.0, 0.5)) < 0.01); + // And the value really is carried by the BLOCK: sample.play/keyTrack never moved. + CHECK(std::fabs(slopePerFrame(0.0) - 1.0) < 0.01); +} + +// Trigger length is the third: it resolves playEnd_, so it re-spans the NEXT note and leaves the +// sounding one at the span it was struck with. +static void testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne() { + auto triggerSource = [] { + SampleData s = rampForReadRate(); + s.play.playMode = PlayMode::Trigger; + s.play.trigger.lengthFraction = 1.0; + s.play.trigAhd.holdFraction = 1.0; // flat through the span, so the span IS the lifetime + return s; + }; + // The note's LIFETIME is what the fraction spans, so blocks-alive measures it directly. + auto blocksAlive = [&](double fraction) { + SampleData fresh = triggerSource(); + LiveParams block; + fresh.live = █ + LiveValues published = foldLive(fresh.play, fresh.keyTrack); + published.lengthFraction = fraction; + block.publish(published); + VoiceEngine engine(1, fresh); + engine.noteOn(60, 100); + std::vector out; + int blocks = 0; + while (engine.activeVoiceCount() > 0 && blocks < 4000) { + engine.render(out, 512); + ++blocks; + } + return blocks; + }; + const int whole = blocksAlive(1.0); + const int quarterSpan = blocksAlive(0.25); + CHECK(whole > 100 && whole < 4000); + CHECK(std::fabs(static_cast(quarterSpan) - 0.25 * whole) < 0.05 * whole); + + // And the sounding note is spared. The published fraction is small enough that its span ENDS + // inside the window rendered — asserted, not assumed, because a fraction whose playEnd_ still + // sat past the render would leave the two runs identical whether the field were live or not. + constexpr int kSweepBlocks = 24; // renderPreserveCapable's own loop count + CHECK(blocksAlive(0.05) < kSweepBlocks); + + SampleData still = triggerSource(); + SampleData moved = triggerSource(); + LiveParams blockA, blockB; + LiveValues shortened = foldLive(moved.play, moved.keyTrack); + shortened.lengthFraction = 0.05; + const std::vector baseline = + renderPreserveCapable(still, blockA, nullptr, -1, 60); + const std::vector swept = + renderPreserveCapable(moved, blockB, &shortened, 8, 60); + CHECK(baseline.size() == swept.size()); + bool untouched = true; + for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) { + if (baseline[i] != swept[i]) { untouched = false; break; } + } + CHECK(untouched); +} + // --- What stays latched at note-on ------------------------------------------------------- static void testPitchRatioAndVelocityGainStayLatched() { @@ -966,8 +1066,12 @@ static void testPitchRatioAndVelocityGainStayLatched() { std::vector out; LiveValues hostile = foldLive(s.play, s.keyTrack); - // Everything the block CAN carry, moved as far as it goes. None of it names velocity, the - // note, the pitch ratio, or the PCM — that is the property under test. + // Everything the block CAN carry, moved as far as it goes. keyTrack and lengthFraction DO + // name the pitch ratio and the play span — they are here precisely because a SOUNDING voice + // must not read either, which is what makes them note-on-latched rather than live; the tests + // above are what prove the next note does take them. + hostile.keyTrack = 0.0; + hostile.lengthFraction = 0.05; hostile.filterKeyTrack = 2.0; hostile.filterSettings.cutoffNorm = 0.0f; hostile.filterModAmount = 1.0; @@ -1078,6 +1182,8 @@ int main() { testEveryLiveFilterControlMovesTheSoundingNote(); testOneBlockServesTwoIndependentObservers(); testARateChangeSpareTheSoundingNoteAndReachesTheNextOne(); + testAKeyTrackChangeSparesTheSoundingNoteAndReachesTheNextOne(); + testATriggerLengthChangeSparesTheSoundingNoteAndReachesTheNextOne(); testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines(); testAPublishedPitchOffsetLeavesTheStagedAttackWallClock(); testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan(); diff --git a/tests/test_param_live.cpp b/tests/test_param_live.cpp index 427608b..7e94967 100644 --- a/tests/test_param_live.cpp +++ b/tests/test_param_live.cpp @@ -1,6 +1,7 @@ -// Standalone tests for the audio thread's parameter patch. The load-bearing one is the -// EQUIVALENCE assertion: patching a control into the live block must produce, bit for bit, the -// block the model path would have folded — which is what makes a second routing table safe. +// Standalone tests for a host parameter write, both sides of the model/audio split. The +// load-bearing one is the EQUIVALENCE assertion: patching a control into the live block must +// produce exactly the block the model path would have folded after the same write — which is what +// makes a second routing table safe. #include "../src/core/instrument/param/param_live.h" @@ -8,9 +9,10 @@ #include "../src/core/instrument/param/param_units.h" #include "../src/core/instrument/map/sample_map.h" #include "../src/core/instrument/ui/deck_values.h" +#include "../src/core/util/curve_law.h" #include -#include + using namespace reasampler; using namespace reasampler::instrument::param; @@ -71,9 +73,13 @@ InstrumentParams dialledParams() { } // namespace // THE assertion this module exists for. For every exposed control and several normalized -// positions: writing it through the model and folding must equal patching it into the folded -// block. Bytes, not fields — a member the patch forgot to route is caught as surely as one it -// routed to the wrong place. +// positions: writing it through the MODEL side of a host write and folding must equal patching it +// into the folded block. Whole-block, not per-field — a member the patch forgot to route is +// caught as surely as one it routed to the wrong place. Compared through live_params' own +// field-wise operator==, NOT a memcmp: the block carries padding no copy is required to preserve, +// so a byte compare here was non-deterministic. Both sides are the HOST's paths, which is what +// the shell actually calls; that the host's value map agrees with the editor's everywhere it +// should is the separate assertion below. static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() { const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0}; for (const ParamRow& row : exposedParams()) { @@ -83,7 +89,7 @@ static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() { LiveValues block = modelBlock(dialledParams()); const LiveValues before = block; CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id); - CHECK_ID(std::memcmp(&before, &block, sizeof(LiveValues)) == 0, row.id); + CHECK_ID(before == block, row.id); continue; } for (double norm : kPositions) { @@ -91,14 +97,13 @@ static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() { if (row.deck == DeckParam::kKeyTrack) { written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm); } else { - reasampler::instrument::ui::setDeckParam(row.deck, written.play, norm, - /*segment=*/0); + CHECK_ID(writeHostParam(row.deck, written.play, norm), row.id); } const LiveValues expected = modelBlock(written); LiveValues patched = modelBlock(dialledParams()); CHECK_ID(applyLiveParam(patched, row.deck, norm, kRate), row.id); - CHECK_ID(std::memcmp(&expected, &patched, sizeof(LiveValues)) == 0, row.id); + CHECK_ID(expected == patched, row.id); } } } @@ -122,7 +127,7 @@ static void testAnUnexposedControlIsRefused() { const LiveValues before = block; CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate)); CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 1.0, kRate)); - CHECK(std::memcmp(&before, &block, sizeof(LiveValues)) == 0); + CHECK(before == block); } // Every exposed control resolves to a home the host's read and write paths actually reach. The @@ -142,11 +147,45 @@ static void testEveryExposedControlHasAValueHome() { CHECK(instanceScalars == 2); } +// The host's value map is the editor's EXCEPT on the twelve curve exponents, where it skips the +// knob detent — a drag affordance a lane has no use for and which would otherwise flatten a +// knot-drawn near-neutral exponent to exactly 1.0 on any lane pass. Both halves are asserted: the +// agreement everywhere else, and the difference exactly inside the detent band. +static void testTheHostSkipsTheCurveDetentAndNothingElse() { + using reasampler::instrument::ui::deckParamUnit; + using reasampler::instrument::ui::storedFromNorm; + using reasampler::instrument::ui::UnitCategory; + const double kPositions[] = {0.0, 0.137, 0.4, 0.495, 0.5, 0.505, 0.6, 0.813, 1.0}; + for (const ParamRow& row : exposedParams()) { + const bool exponent = deckParamUnit(row.deck) == UnitCategory::Exponent; + for (double norm : kPositions) { + const double editor = storedFromNorm(row.deck, norm); + const double host = hostStoredFromNorm(row.deck, norm); + // Inside the band but off centre is the ONE place they may differ, and must. + const bool inBand = exponent && norm != 0.5 && + norm > 0.5 - reasampler::util::kCurveKnobDetent && + norm < 0.5 + reasampler::util::kCurveKnobDetent; + if (inBand) { + CHECK_ID(editor == reasampler::util::kCurveNeutral, row.id); + CHECK_ID(host != editor, row.id); + } else { + CHECK_ID(host == editor, row.id); + } + } + // The identity stays reachable from the host side too — that is what makes skipping the + // detent a value-preserving change rather than a lost reset. + if (exponent) { + CHECK_ID(hostStoredFromNorm(row.deck, 0.5) == reasampler::util::kCurveNeutral, row.id); + } + } +} + int main() { testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite(); testTriggerLengthIsInertUnderADrawnEnvelope(); testAnUnexposedControlIsRefused(); testEveryExposedControlHasAValueHome(); + testTheHostSkipsTheCurveDetentAndNothingElse(); if (g_fail == 0) std::printf("param_live: all tests passed\n"); return g_fail == 0 ? 0 : 1; } diff --git a/tests/test_param_merge.cpp b/tests/test_param_merge.cpp new file mode 100644 index 0000000..3af7b3c --- /dev/null +++ b/tests/test_param_merge.cpp @@ -0,0 +1,185 @@ +// Standalone tests for the block-boundary merge — the AUTHORITY LIFETIME of a host automation +// point. The load-bearing one is the RELEASE: a point outranks the model only until the model +// carries it. Held forever, one point defeats every later state restore, bake reset and knob +// move; released too eagerly, a lane in flight reverts for a block. + +#include "../src/core/instrument/param/param_merge.h" + +#include "../src/core/instrument/param/param_id.h" +#include "../src/core/instrument/param/param_live.h" +#include "../src/core/instrument/param/param_units.h" +#include "../src/core/instrument/map/sample_map.h" +#include "../src/core/instrument/ui/deck_values.h" + +#include + + +using namespace reasampler; +using namespace reasampler::instrument::param; +using reasampler::instrument::engine::LiveValues; +using reasampler::instrument::engine::foldLive; +using reasampler::instrument::map::InstrumentParams; +using reasampler::instrument::map::resolvePlay; +using reasampler::instrument::ui::DeckParam; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) +#define CHECK_ID(cond, id) do { if(!(cond)) { \ + std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0) + +namespace { + +constexpr int kRate = 48000; +constexpr std::size_t kCutoff = static_cast(DeckParam::kFilterCutoff); + +LiveValues modelBlock(const InstrumentParams& params) { + return foldLive(resolvePlay(params.play, kRate), params.keyTrack); +} + +// A slot array with one lane driving `deck`. +struct Slots { + AutomationSlot s[kDeckParamSlots] = {}; + AutomationSlot* operator()() { return s; } +}; + +} // namespace + +// THE test this module exists for. A point lands, the merge applies it over the model; the model +// is then rewritten to something else while the hold is STILL outstanding, and the point must win +// — that is the ≤one-tick window the hold is for. Once the fold has caught the model up and the +// slot is marked folded, the merge releases it and the MODEL wins, permanently. +static void testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt() { + InstrumentParams automated; + automated.play.filter.settings.cutoffNorm = 0.9f; + + // 1. The point is held: a model that says 0.9 loses to the lane's 0.2. + Slots slots; + slots.s[kCutoff] = AutomationSlot{0.2, /*held=*/true, /*folded=*/false}; + LiveValues block = modelBlock(automated); + mergeAutomation(block, slots(), kDeckParamSlots, kRate); + CHECK(block.filterSettings.cutoffNorm == 0.2f); + CHECK(slots.s[kCutoff].held); // still outstanding — nothing has folded it + + // 2. A state restore lands a different value while the hold is outstanding. Still the lane's: + // this is the window the hold exists for, and it is the ONLY window. + InstrumentParams restored; + restored.play.filter.settings.cutoffNorm = 0.55f; + block = modelBlock(restored); + mergeAutomation(block, slots(), kDeckParamSlots, kRate); + CHECK(block.filterSettings.cutoffNorm == 0.2f); + + // 3. The UI folds the point into the model and republishes; the merge sees the release. + InstrumentParams folded; + folded.play.filter.settings.cutoffNorm = 0.2f; + slots.s[kCutoff].folded = true; + block = modelBlock(folded); + mergeAutomation(block, slots(), kDeckParamSlots, kRate); + CHECK(block.filterSettings.cutoffNorm == 0.2f); + CHECK(!slots.s[kCutoff].held); // RELEASED — this is the whole fix + + // 4. And now a later writer — a preset load, a bake reset, a knob — actually reaches the + // audio. This is what a latch with no release makes impossible. + block = modelBlock(restored); + mergeAutomation(block, slots(), kDeckParamSlots, kRate); + CHECK(block.filterSettings.cutoffNorm == 0.55f); +} + +// The release must not leak across points: a lane that sent a NEW point after the fold read the +// previous one is still driving, and its new value must survive the release of the old. +static void testANewPointAfterTheFoldIsNotReleasedByIt() { + Slots slots; + // The audio thread re-holds at 0.7; the UI's fold was of the earlier 0.2, so the sequence + // comparison the shell runs leaves `folded` false for this newer point. + slots.s[kCutoff] = AutomationSlot{0.7, /*held=*/true, /*folded=*/false}; + InstrumentParams foldedModel; + foldedModel.play.filter.settings.cutoffNorm = 0.2f; + LiveValues block = modelBlock(foldedModel); + mergeAutomation(block, slots(), kDeckParamSlots, kRate); + CHECK(block.filterSettings.cutoffNorm == 0.7f); + CHECK(slots.s[kCutoff].held); +} + +// A slot that never took a point leaves the block exactly as the model folded it. +static void testAnUnheldSlotLeavesTheBlockAlone() { + Slots slots; + InstrumentParams p; + p.play.adsr.attackSeconds = 0.25; + const LiveValues expected = modelBlock(p); + LiveValues block = modelBlock(p); + mergeAutomation(block, slots(), kDeckParamSlots, kRate); + CHECK(expected == block); +} + +// The dirty gate at its source. A lane resending the value it already sent — the steady state of +// a flat segment in read mode — must report that nothing moved, so the block is never re-read, +// re-merged or republished. A lane that MOVED must report that it did, and so must a repeat that +// arrives after the hold was released (some other writer may have moved the model since). +static void testARepeatOfAStandingHoldMovesNothing() { + AutomationSlot slot{0.4, /*held=*/true, /*folded=*/false}; + CHECK(!automationPointMoves(slot, 0.4)); + CHECK(automationPointMoves(slot, 0.41)); + slot.held = false; + CHECK(automationPointMoves(slot, 0.4)); +} + +// The gate the merge publishes through. Two blocks folded from the same parameter set and merged +// with the same slot state compare EQUAL — which a byte compare does not reliably report, since +// LiveValues carries padding no copy is required to preserve. This is the assertion that fails if +// operator== is ever "simplified" back into a memcmp. +static void testTwoIdenticalMergesCompareEqual() { + InstrumentParams p; + p.play.adsr.attackSeconds = 0.13; + p.play.trigger.lengthFraction = 0.6; + Slots slots; + slots.s[kCutoff] = AutomationSlot{0.4, /*held=*/true, /*folded=*/false}; + + LiveValues first = modelBlock(p); + mergeAutomation(first, slots(), kDeckParamSlots, kRate); + LiveValues again = modelBlock(p); + mergeAutomation(again, slots(), kDeckParamSlots, kRate); + CHECK(first == again); + + slots.s[kCutoff].norm = 0.41; + LiveValues moved = modelBlock(p); + mergeAutomation(moved, slots(), kDeckParamSlots, kRate); + CHECK(first != moved); +} + +// The merge addresses a slot by DeckParam ORDINAL, which is the one thing it does that +// param_live's equivalence test cannot see: a slot recovered as the wrong enumerator would patch +// a neighbouring control. Swept over every exposed control for that reason, not to re-assert the +// value laws param_live already owns. +static void testEverySlotResolvesToItsOwnControl() { + const double kPositions[] = {0.0, 0.29, 0.5, 0.77, 1.0}; + for (const ParamRow& row : exposedParams()) { + if (row.deck == DeckParam::kMasterGain) continue; // reaches the audio beside the block + for (double norm : kPositions) { + InstrumentParams written; + if (row.deck == DeckParam::kKeyTrack) { + written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm); + } else { + writeHostParam(row.deck, written.play, norm); + } + const LiveValues expected = modelBlock(written); + + Slots slots; + slots.s[static_cast(row.deck)] = + AutomationSlot{norm, /*held=*/true, /*folded=*/false}; + LiveValues merged = modelBlock(InstrumentParams{}); + mergeAutomation(merged, slots(), kDeckParamSlots, kRate); + CHECK_ID(expected == merged, row.id); + } + } +} + +int main() { + testAHeldPointOutranksTheModelOnlyUntilTheModelCarriesIt(); + testANewPointAfterTheFoldIsNotReleasedByIt(); + testAnUnheldSlotLeavesTheBlockAlone(); + testARepeatOfAStandingHoldMovesNothing(); + testTwoIdenticalMergesCompareEqual(); + testEverySlotResolvesToItsOwnControl(); + if (g_fail == 0) std::printf("param_merge: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_param_units.cpp b/tests/test_param_units.cpp index d6c6e52..7198818 100644 --- a/tests/test_param_units.cpp +++ b/tests/test_param_units.cpp @@ -136,6 +136,28 @@ static void testTheHostAndTheEditorAgreeOnEveryDefaultPosition() { } } +// The criterion names the editor's DOUBLE-CLICK, and that gesture is resetDeckParam, not +// deckParamNorm over a default-constructed set. Asserted directly: reset a DIALLED set and its +// stored field must read back at exactly the normalized value the host resets to. (The two +// instance scalars have no resetDeckParam entry — the shell resets those from InstrumentParams, +// which the test above covers at the same position.) +static void testADoubleClickResetLandsOnTheHostsDefaultNormalized() { + using reasampler::instrument::ui::deckParamNorm; + using reasampler::instrument::ui::resetDeckParam; + using reasampler::instrument::ui::setDeckParam; + for (const ParamRow& row : exposedParams()) { + if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue; + PlaySeconds dialled; + // Away from the default first, so a reset that did nothing at all cannot pass. + setDeckParam(row.deck, dialled, 0.37, /*segment=*/0); + CHECK_ID(deckParamNorm(row.deck, dialled) != defaultNormalized(row.deck) || + defaultNormalized(row.deck) == 0.37, + row.id); + resetDeckParam(row.deck, dialled); + CHECK_ID(deckParamNorm(row.deck, dialled) == defaultNormalized(row.deck), row.id); + } +} + static void testTheFiltersFourTakeTheirStoredNormVerbatim() { // Their stored value IS the normalized one, so no taper may participate in their default: // this fails the moment someone routes them through toNormalized(toPlain(x)). @@ -218,6 +240,7 @@ int main() { testEveryUnitStringAndRangeMatchesTheSpecifiedTable(); testEveryDefaultHasAnExactNormalizedPreimage(); testTheHostAndTheEditorAgreeOnEveryDefaultPosition(); + testADoubleClickResetLandsOnTheHostsDefaultNormalized(); testTheFiltersFourTakeTheirStoredNormVerbatim(); testToPlainIsMonotoneAcrossTheWholeTravel(); testTheEndpointsAreTheDeclaredPlainRange(); From 9b5393098bb43f0596278b72c057ba9f376e190a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 18:22:21 -0400 Subject: [PATCH 50/56] Close pass four: undetented host curve read, LiveValues guard, ordering assert, static-lane fix, docs Points toPlain's exponent arm at the undetented curve map so host reads match the editor; adds a sizeof guard plus field-poison test for LiveValues::operator==; skips the model write when an automation value hasn't moved; corrects five stale doc citations. --- docs/PLAN.md | 4 +- docs/product/parameter-automation.md | 6 ++ src/core/instrument/engine/live_params.h | 7 +++ src/core/instrument/param/CLAUDE.md | 23 +++----- src/core/instrument/param/param_merge.h | 7 ++- src/core/instrument/param/param_units.cpp | 4 +- src/shell/instrument/CLAUDE.md | 27 +++++---- src/shell/instrument/automation_channel.h | 14 +++-- src/shell/instrument/editor_session.cpp | 12 ++-- src/shell/instrument/instrument_params.cpp | 24 ++++++-- src/shell/instrument/processor_state.cpp | 10 +++- src/shell/instrument/reasampler_processor.h | 8 +-- tests/test_live_params.cpp | 63 +++++++++++++++++++++ tests/test_param_format.cpp | 13 ++--- 14 files changed, 163 insertions(+), 59 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 03fc2fd..af9997a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1321,8 +1321,8 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). that is genuinely DRIVING re-sends and so keeps outranking the restore, while a lane that sent one point and had it folded does not. That is the correct reading of the rule "a lane in read/write mode outranks a plug-in-side set" (stated in `docs/product/parameter-automation.md` - §7 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell it - out). The second pass's unbounded latch made the claim true by making every later writer + §6.1 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell + it out). The second pass's unbounded latch made the claim true by making every later writer permanently deaf; `shell/instrument/CLAUDE.md`'s Authority section is the model now. (c) **STILL OPEN, and it is the half the bundled `[verify, FIRST]` originally asked**: that REAPER calls `setState` (not `setComponentState`) on a single-component plug-in — a state diff --git a/docs/product/parameter-automation.md b/docs/product/parameter-automation.md index 1d75518..6ce5c36 100644 --- a/docs/product/parameter-automation.md +++ b/docs/product/parameter-automation.md @@ -335,6 +335,12 @@ invariant Θ-W1-T1 was run to establish. > outranks anything the plugin sets, because the host replays it. That is inherent to > automation and is not a defect to design away — but it has one sharp consequence for the > resample bake, and that is §9. +> +> **Superseded by the paragraph immediately below.** "Outranks anything the plugin sets" reads +> as unbounded; the bounded formulation there — outranks only until the model has caught up, +> never a later restore/reset/knob move — is the correct one and the one `shell/instrument/ +> CLAUDE.md`'s Authority section and `core/instrument/param/param_merge` implement. An unbounded +> hold was tried and is the specific defect this history keeps. **SETTLED at the track, from the vendored SDK.** The delivery question the `[verify]` here bundled is answered by the headers rather than by the DAW: `setParamNormalized` is documented as diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index 2afd1d1..b8feebb 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -70,6 +70,13 @@ struct LiveValues { // The seqlock copies the block as raw bytes, which is only defensible for a plain value type. static_assert(std::is_trivially_copyable_v, "the live block is copied under a seqlock — it must stay a plain value"); +// Guards operator== against silent staleness: a member added to the struct above changes this +// size, so the assert fails at the new member's own commit instead of leaving a live control +// that never reaches a sounding voice with no compiler or test signal. Confirmed 352 bytes, +// MSVC 19.44 x64, Release (`SizeProbe`, an incomplete-template size probe +// whose error message reports the value). Bump the literal AND operator== together. +static_assert(sizeof(LiveValues) == 352, + "a member was added or removed — extend operator== in live_params.cpp to match"); // FIELD-wise equality, and it must never be "simplified" into a memcmp. LiveValues carries // padding, and nothing gives that padding a determinate value across a copy: NRVO is optional diff --git a/src/core/instrument/param/CLAUDE.md b/src/core/instrument/param/CLAUDE.md index cf20aad..729b3ae 100644 --- a/src/core/instrument/param/CLAUDE.md +++ b/src/core/instrument/param/CLAUDE.md @@ -88,21 +88,16 @@ no longer exist. - **Round-trip exactness at arbitrary values is NOT a property here and must not be asserted.** No log map satisfies `toNormalized(toPlain(n)) == n` in double, and demanding it would rule out the taper the range needs. Exactness is required at the defaults; monotonicity everywhere. -- **A curve exponent inside the knob detent but not exactly neutral READS BACK as `1.00` on the - host, while the stored value keeps its true exponent.** The detent lives in `curve_law`'s - norm↔exponent map, and `toPlain` is that map — so an off-detent near-neutral exponent (an - overlay knot drag can set one) displays as `1.00` in the host. The editor's own label reads the - stored field and shows the true value. - **The WRITE path does NOT have this loss, and that is deliberate.** A host write goes through - `hostStoredFromNorm`, which skips the detent: the detent is a DRAG affordance — a drag grid +- **Both the host's read (`toPlain`) and write (`hostStoredFromNorm`) paths for a curve exponent + skip `curve_law`'s knob detent, and that is deliberate** (Daniel, 2026-08-02: continuous ranges + stay continuous at the host boundary). The detent is a DRAG affordance only — a drag grid delivers `start - dy/128` and lands on the identity only by luck, so a band wider than one drag - step snaps to it — and a lane has no grid. `curveFromKnobNorm` already answers exactly `1.0` at - norm `0.5`, so skipping the detent costs nothing in reachability from the host, and applying it - would flatten a knot-drawn exponent to `1.0` on any lane pass. `test_param_live`'s - `testTheHostSkipsTheCurveDetentAndNothingElse` pins both halves: the host map is the editor's - everywhere else, and differs exactly inside the band. - **What remains is the DISPLAY divergence above**, which this module already documents as - structural and which no change to the frozen `toPlain`/`toNormalized` pair was made to chase. + step snaps to it — and a host lane has no grid. `curveFromKnobNorm` already answers exactly + `1.0` at norm `0.5`, so skipping the detent costs nothing in reachability from the host. The + dial-drag path (`ui::storedFromNorm`/its snap) is the one place the detented map still applies, + because that is where the snap earns its place. `test_param_live`'s + `testTheHostSkipsTheCurveDetentAndNothingElse` pins the write half; `test_param_format`'s + `testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor` pins the read half. - **Master gain's plain value at norm 0 is `-inf`**, which is outside the declared −60…+24 range on purpose — norm 0 is true silence, not the floor. The formatter prints `-inf` there. The editor additionally SUPPRESSES its unit suffix at that one value (`editor_controls`, the diff --git a/src/core/instrument/param/param_merge.h b/src/core/instrument/param/param_merge.h index 004ad91..455eff3 100644 --- a/src/core/instrument/param/param_merge.h +++ b/src/core/instrument/param/param_merge.h @@ -40,9 +40,10 @@ void mergeAutomation(engine::LiveValues& block, AutomationSlot* slots, std::size // Whether a point of `normalized` for a slot in this state actually moves the block. False for a // point equal to a hold that is still standing — the ordinary read-mode steady state, where a // host delivers one point per block over a flat lane segment. Republishing there would drive -// `VoiceEngine::refreshLive` over every sounding voice — a `std::pow`, two envelope φ re-fits and -// the filter ramp aims, per voice — for a value that did not move. Once the hold has been -// RELEASED the answer is true again, because some other writer may have moved the model since. +// `VoiceEngine::applyLiveToActive` over every sounding voice — a `std::pow`, two envelope φ +// re-fits and the filter ramp aims, per voice — for a value that did not move. Once the hold has +// been RELEASED the answer is true again, because some other writer may have moved the model +// since. inline bool automationPointMoves(const AutomationSlot& slot, double normalized) { return !slot.held || slot.norm != normalized; } diff --git a/src/core/instrument/param/param_units.cpp b/src/core/instrument/param/param_units.cpp index 2b358ba..a05cd6b 100644 --- a/src/core/instrument/param/param_units.cpp +++ b/src/core/instrument/param/param_units.cpp @@ -191,7 +191,9 @@ double toPlain(DeckParam deck, double normalized) { return ui::rateRatioFromNorm(normalized, ui::kRateMinRatio, ui::kRateMaxRatio) * kPercentFullScale; case UnitKind::Dimensionless: - return util::curveFromKnobNorm(normalized); + // Undetented: a host-facing continuous range stays continuous (Daniel, 2026-08-02) — + // the detent is a drag affordance, not part of the value law. See hostStoredFromNorm. + return util::curveFromKnobNormUndetented(normalized); case UnitKind::Decibels: case UnitKind::Hertz: break; // handled above diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 771cd83..020e0f9 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -126,14 +126,18 @@ the single authority.** Everything else that holds these values is a cache or a | Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) | | State restore (`setState`) | the call | the call returns | | Bake reset (`adoptBakedCapture`) | the call | the call returns | -| Reload seed (`reloadInstrument`) | under `reloadMutex_` | the publish (it re-folds the model) | +| Limiter toggle (`setLimiterEnabled`) | the call | the call returns (it writes the model too, but through neither `commitLive` nor `commitAndReload`) | +| Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds | | **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** | -Every writer except the last writes the model directly, so for those "authority ends" is just -"the write happened". The automation lane is the only one that cannot: the SDK delivers it on the -audio thread, where the model path allocates (`resolvePlay` copies velocity curves and spline -contours). So it patches the engine-facing block in place and is couriered to the UI thread, -which folds it into the model on the next tick. +Every writer above except the last two writes the model directly, so for those "authority ends" +is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never +touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:192` +— which is why its row states no authority window of its own. The automation lane is the only one +that cannot write directly: the SDK delivers it on the audio thread, where the model path +allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the +engine-facing block in place and is couriered to the UI thread, which folds it into the model on +the next tick. **The hold is the bridge across that gap, and nothing more.** Between the point landing and the fold — at most one UI tick — the model does not yet carry the value, so a model republish in that @@ -171,14 +175,15 @@ under it: the automation fold, the host's generic panel, a state restore. different windows: `AutomationChannel::land` drops a repeat of a standing hold whole (the flat read-mode segment, where a host sends one point per block), and the merge publishes only when the merged block differs from the last (a model republish that changed nothing). Neither is measured -against a performance budget — they are there because `VoiceEngine::refreshLive` runs +against a performance budget — they are there because `VoiceEngine::applyLiveToActive` runs `voice.applyLive` over every active voice, and neither case needs it. - **The automation values fold back into the model on the UI thread** (`drainAutomationToModel`, - called from `getState`, the editor's sync tick, and the bake's reload tail). The blob is - authoritative, so a value that never came back would be lost on save. The fold is suppressed - from notifying the host — the values came FROM it, and echoing them would let a lane in write - mode re-record its own playback. + called from `getState`, the editor's sync tick, and `instrument_bake.cpp:125` — at the HEAD of + the bake chain, before the render, not its reload tail). The blob is authoritative, so a value + that never came back would be lost on save. The fold is suppressed from notifying the host — + the values came FROM it, and echoing them would let a lane in write mode re-record its own + playback. - **`IMidiMapping` is deliberately NOT implemented** — no conventional CC names most of what is exposed, an invented map would hijack CCs the user's controller already sends, and `[verify — DAW]` REAPER's own per-parameter MIDI learn is expected to cover the case without diff --git a/src/shell/instrument/automation_channel.h b/src/shell/instrument/automation_channel.h index 26d8b9c..06ea8a8 100644 --- a/src/shell/instrument/automation_channel.h +++ b/src/shell/instrument/automation_channel.h @@ -2,6 +2,9 @@ // AUTHORITY LIFETIME is mechanised: a point outranks the model from the block it lands in until // the UI thread has folded it back into the model AND republished. This directory's CLAUDE.md // states the model; `core/instrument/param/param_merge` is the pure decision this feeds. +// The release itself is observed the NEXT BLOCK, not the instant it happens — refreshReleases() +// only runs inside process()'s merge branch (reasampler_processor.cpp), and publishLiveParams +// always bumps the generation that branch checks, so the next block is guaranteed to take it. #pragma once @@ -44,10 +47,13 @@ public: return ridesTheBlock; } - // Refreshes each held slot's release answer. Must run BEFORE the model block is read: the - // acquire here synchronizes with the UI thread's release store, which it makes only AFTER - // republishing the model — so a slot seen released is one whose value any block read after - // this point is guaranteed to already carry. + // Refreshes each held slot's release answer. Runs only inside process()'s merge branch, so a + // release lands the NEXT BLOCK after the UI thread makes it, never the same instant — benign, + // because publishLiveParams always bumps the generation that branch checks, so the next block + // is guaranteed to run this. Must run BEFORE the model block is read: the acquire here + // synchronizes with the UI thread's release store, which it makes only AFTER republishing the + // model — so a slot seen released is one whose value any block read after this point is + // guaranteed to already carry. void refreshReleases() { for (std::size_t i = 0; i < kDeckParamSlots; ++i) { if (!slots_[i].held) continue; diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 0558f13..6f02157 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -211,10 +211,11 @@ void ReaSamplerEditor::commitAndReload() { // self-contained for that sample. if (!processor_) return; processor_->setSelectedSampleId(selectedId_); - processor_->setInstrumentParams(params_); - // This copy IS the model now, so adopt the generation it produced rather than re-seeding off - // it on the next tick. Same reason at commitLive. - seenParamsGeneration_ = processor_->instrumentParamsGeneration(); + // This copy IS the model now, so adopt the generation IT produced (the return, not a second + // separate query — a write landing between the two would make this adopt a generation newer + // than the copy just sent) rather than re-seeding off it on the next tick. Same reason at + // commitLive. + seenParamsGeneration_ = processor_->setInstrumentParams(params_); processor_->reloadInstrument(); // The reload may have auto-defaulted the channel mode (implicit only) — re-read so the // toggle draws what the engine actually decoded with. @@ -227,8 +228,7 @@ void ReaSamplerEditor::commitAndReload() { void ReaSamplerEditor::commitLive() { // UI thread only. See the declaration for why this still writes the parameter set. if (!processor_) return; - processor_->setInstrumentParams(params_); - seenParamsGeneration_ = processor_->instrumentParamsGeneration(); + seenParamsGeneration_ = processor_->setInstrumentParams(params_); processor_->publishLiveParams(); } diff --git a/src/shell/instrument/instrument_params.cpp b/src/shell/instrument/instrument_params.cpp index 79f223d..619f268 100644 --- a/src/shell/instrument/instrument_params.cpp +++ b/src/shell/instrument/instrument_params.cpp @@ -6,6 +6,8 @@ #include "shell/instrument/reasampler_processor.h" +#include + #include "base/source/fstring.h" #include "pluginterfaces/base/ustring.h" #include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue @@ -291,16 +293,23 @@ void ReaSamplerProcessor::drainAutomationToModel() { ++foldedCount; // Master gain's model IS the atomic the audio thread already wrote; there is nothing to // fold, only the controller cache to refresh below. - if (row.deck != DeckParam::kMasterGain) { - writeDeckParamToModel(params, row.deck, value); - moved = true; - } + if (row.deck == DeckParam::kMasterGain) continue; + // A present-but-static lane resends the SAME point every tick; writing it back would + // republish liveParams_ and move paramsGeneration_ — a full model copy, a controller + // cache write and an editor repaint, every tick, forever, for a value that never moved. + // The release below still fires: the model already carries the point either way. + if (value == modelParamNormalized(params, row.deck)) continue; + writeDeckParamToModel(params, row.deck, value); + moved = true; } // Suppressed for the whole fold: these values CAME from the host, and echoing them back // through performEdit would let a lane in write mode re-record its own playback. The // controller cache is still refreshed, so the host's display and the editor follow. const bool wasSuppressed = paramNotifySuppressed_; paramNotifySuppressed_ = true; + // Captured before the publish below, so the assert at the release loop can tell "the model's + // publish already landed" from "it merely happened to be in flight for some other reason". + const std::uint32_t generationBeforeFold = liveParams_.generation(); if (moved) { setInstrumentParams(params); publishLiveParams(); @@ -311,6 +320,13 @@ void ReaSamplerProcessor::drainAutomationToModel() { // model carries the point. Released any earlier and the audio thread could drop the hold // ahead of the block that carries its value; never released at all — the defect this // replaces — and one point would defeat every later restore, reset and knob move. + // Enforced, not just commented: two prior passes inverted this order and every test in the + // tree still passed, because nothing exercises `process()`. `publishLiveParams` is a no-op + // before the engine has a sample rate (`builtSampleRate_`), which is the one case this assert + // must not fire for. + assert((!moved || builtSampleRate_.load(std::memory_order_relaxed) <= 0 || + liveParams_.generation() != generationBeforeFold) && + "release ran ahead of the publish that gives it authority"); for (std::size_t i = 0; i < foldedCount; ++i) { automation_.release(foldedSlots[i], foldedSeqs[i]); } diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index bf400d1..6dacac4 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -179,11 +179,12 @@ InstrumentParams ReaSamplerProcessor::instrumentParams(std::uint32_t& generation return params_; } -void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { +std::uint32_t ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { // The exposed values alone, not the whole set: this funnel fires per mouse move on every live // knob and node drag, and InstrumentParams owns seven vectors — copying all of them to diff // 44 doubles is the cost, and the diff is what the notification actually needs. double before[kDeckParamSlots]; + std::uint32_t generation; { std::lock_guard lock(paramsMutex_); for (const instrument::param::ParamRow& row : instrument::param::exposedParams()) { @@ -191,8 +192,10 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { } params_ = params; // Bumped inside the lock with the write it names, so a reader taking the pair together - // can never see a generation that does not describe the set beside it. - ++paramsGeneration_; + // can never see a generation that does not describe the set beside it. Read back before + // the unlock for the same reason — a caller wanting ITS OWN write's generation must not + // race a second writer's bump between this function's unlock and its return. + generation = ++paramsGeneration_; } // Every writer of the parameter set — setState, the editor's commits, the bake's adopt — // funnels through here, so mirroring the limiter flag at this one point is what keeps the @@ -214,6 +217,7 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { // limiter mirror sits here: an internal write that skipped it would leave the host // displaying — and, on the next touch, re-imposing — the superseded value. notifyParamsFromModel(before, params); + return generation; } void ReaSamplerProcessor::flushLatencyRestart() { diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 90310e5..e3b9cc6 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -190,7 +190,7 @@ public: // never read on the audio thread — reloadInstrument bakes it into the SampleData // off-thread. InstrumentParams instrumentParams(); - void setInstrumentParams(const InstrumentParams& params); + std::uint32_t setInstrumentParams(const InstrumentParams& params); // adopt THIS generation, never a second instrumentParamsGeneration() call // The model's edit counter, bumped by every setInstrumentParams. A holder of a COPY (the // editor's snapshot) re-seeds when this moves under it; the overload answers both under one @@ -392,9 +392,9 @@ private: // and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane // resending the value it already sent reaches the per-voice fan-out. A block carrying no // automation and no model change costs one relaxed load plus, when the host passed a non-null - // IParameterChanges (REAPER's normal case), one cross-module getParameterCount(). Two blocks - // because the seqlock's single-writer contract is load-bearing and the two writers differ in - // thread; this directory's CLAUDE.md owns the argument. + // IParameterChanges (`[verify — DAW]` REAPER's normal case), one cross-module + // getParameterCount(). Two blocks because the seqlock's single-writer contract is load-bearing + // and the two writers differ in thread; this directory's CLAUDE.md owns the argument. instrument::engine::LiveParams automationLive_; // Audio thread only. The last liveParams_ generation merged, the last block published (the // republish gate compares against it), and the automation slots themselves. diff --git a/tests/test_live_params.cpp b/tests/test_live_params.cpp index 5bf556d..8ab0379 100644 --- a/tests/test_live_params.cpp +++ b/tests/test_live_params.cpp @@ -91,6 +91,68 @@ static void testADrawnEnvelopePinsTheFoldedTriggerLength() { CHECK(v.lengthFraction == 1.0); } +// Poisons the block ONE LEAF FIELD AT A TIME and checks operator== catches every one — the half +// that actually catches a forgotten field, since the static_assert above only fires when a +// member changes sizeof(LiveValues), which padding can absorb. Covers every leaf of every +// nested struct, not just the 14 top-level members, so a member dropped from sameAdsr/sameAhd/ +// sameFilterSettings is caught here too, not just a member dropped from operator== itself. +static void testEveryFieldOfLiveValuesIsCompared() { + using instrument::engine::filter::MorphLaw; + const LiveValues base{}; + auto poisoned = [&](auto mutate) { + LiveValues v = base; + mutate(v); + return v; + }; + CHECK(base == base); + CHECK(poisoned([](LiveValues& v) { v.filterSettings.cutoffNorm += 0.1f; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterSettings.resonanceNorm += 0.1f; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterSettings.morphNorm += 0.1f; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterSettings.driveNorm += 0.1f; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterSettings.morphLaw = MorphLaw::HighNotchLow; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterModAmount += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterVelAmount += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterKeyTrack += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterEnv.attackFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterEnv.holdFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterEnv.decayFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterEnv.sustainLevel += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterEnv.releaseFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterEnv.attackCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterEnv.decayCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterEnv.releaseCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterAhd.attackFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterAhd.decayFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterAhd.holdFraction += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterAhd.attackCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.filterAhd.decayCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.adsr.attackFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.adsr.holdFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.adsr.decayFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.adsr.sustainLevel += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.adsr.releaseFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.adsr.attackCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.adsr.decayCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.adsr.releaseCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.ampAhd.attackFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.ampAhd.decayFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.ampAhd.holdFraction += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.ampAhd.attackCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.ampAhd.decayCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.pitchEnv.enabled = !v.pitchEnv.enabled; }) != base); + CHECK(poisoned([](LiveValues& v) { v.pitchEnv.peakSemitones += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.attackFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.decayFrames += 1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.holdFraction += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.attackCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.pitchEnv.shape.decayCurve += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.playRate += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.pitchOffsetSemitones += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.keyTrack += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.lengthFraction += 0.1; }) != base); + CHECK(poisoned([](LiveValues& v) { v.splineActive = !v.splineActive; }) != base); +} + static void testUnpublishedBlockReadsAsNothing() { LiveParams block; LiveValues out; @@ -188,6 +250,7 @@ static void testRampStepIsRateDerived() { int main() { testFoldCarriesEveryContinuousControl(); testADrawnEnvelopePinsTheFoldedTriggerLength(); + testEveryFieldOfLiveValuesIsCompared(); testUnpublishedBlockReadsAsNothing(); testConcurrentReaderNeverSeesAHalfAppliedEdit(); testRampTerminatesExactlyOnTheTarget(); diff --git a/tests/test_param_format.cpp b/tests/test_param_format.cpp index 7748ed6..aac4a3b 100644 --- a/tests/test_param_format.cpp +++ b/tests/test_param_format.cpp @@ -126,11 +126,10 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() { } } -// The one place the two surfaces GENUINELY diverge, asserted so it stays a known property rather -// than a surprise: an exponent inside curve_law's centre detent but not exactly neutral is -// reachable only through an overlay knot drag, and the host — which holds the norm and nothing -// else — reads it back as the neutral the knob law snaps to. -static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() { +// A continuous range stays continuous at the host boundary (Daniel, 2026-08-02): the knob +// detent is a drag affordance only, so an exponent inside it but not exactly neutral — reachable +// via an overlay knot drag — must read back true to the host, the same digits the editor shows. +static void testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor() { PlaySeconds play; // The detent is +/-0.01 in NORM, which is a ~+/-0.047 band in the exponent — so 1.04 is // inside it and still prints as a distinct number. @@ -144,7 +143,7 @@ static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() { char hostBuf[24]; formatPlainFor(DeckParam::kAttackCurve, toPlain(DeckParam::kAttackCurve, hostNorm), hostBuf, sizeof(hostBuf)); - CHECK(std::string(hostBuf) == "1.00"); + CHECK(std::string(hostBuf) == "1.04"); } static void testKeyTrackPrintsTheSameDigitsFromEitherSurface() { @@ -227,7 +226,7 @@ static void testEveryExposedParameterHasAFormatterThatWritesSomething() { int main() { testEachCategoryPrintsItsSpecifiedShape(); testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue(); - testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor(); + testAnOffDetentExponentReadsTrueToBothTheHostAndTheEditor(); testMasterGainPrintsTheSameDigitsFromEitherSurface(); testKeyTrackPrintsTheSameDigitsFromEitherSurface(); testTypingBackADisplayedValueLandsOnIt(); From 056c60c8e11e7ef007228f7e24e52d7b12bc4fc4 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 19:36:32 -0400 Subject: [PATCH 51/56] Make the automation release's publish-ordering rule a compile-time guard, not an assert The assert compiled out under Release's NDEBUG and ran in no test target. AutomationChannel::release now requires a ReleaseProof that only publishLiveParams() or noRepublishNeeded() can mint. --- src/shell/instrument/automation_channel.h | 32 ++++++++++++++++++--- src/shell/instrument/instrument_params.cpp | 26 ++++++----------- src/shell/instrument/processor_state.cpp | 17 +++++++---- src/shell/instrument/reasampler_processor.h | 4 +-- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/shell/instrument/automation_channel.h b/src/shell/instrument/automation_channel.h index 06ea8a8..5c2deff 100644 --- a/src/shell/instrument/automation_channel.h +++ b/src/shell/instrument/automation_channel.h @@ -11,6 +11,7 @@ #include #include #include +#include #include "core/instrument/param/param_merge.h" @@ -21,6 +22,29 @@ inline constexpr std::size_t kDeckParamSlots = instrument::param::kDeckParamSlot class AutomationChannel { public: + // Evidence release() below requires: the model has actually caught up with the + // point being released. The private constructor means the only way to obtain one + // is through a factory here, and both are named for the case they cover — + // `fromPublish` for an observed model republish, `noRepublishNeeded` for the two + // cases drainAutomationToModel finds nothing to publish (the fold left the model + // unchanged, or no engine exists yet to read the live block). A caller that has + // not published cannot spell the argument release() needs, which is what turns + // reordering the release ahead of the publish into a compile error. + class ReleaseProof { + public: + static ReleaseProof fromPublish(std::uint32_t before, std::uint32_t after) { + // publish() is guaranteed to advance the generation; landing here means + // that guarantee broke, worth crashing on unconditionally rather than + // tolerating silently. + if (after == before) std::abort(); + return ReleaseProof{}; + } + static ReleaseProof noRepublishNeeded() { return ReleaseProof{}; } + + private: + ReleaseProof() = default; + }; + // --- AUDIO THREAD ------------------------------------------------------------------- // A point landed for `slot`. `ridesTheBlock` is false for a control that reaches the audio // beside the live block (master gain, whose route is the processor's own atomic): such a @@ -76,10 +100,10 @@ public: return true; } - // Releases `slot`'s hold. ONLY legal once the model carrying that point has been republished - // — calling it earlier would let the audio thread drop the hold ahead of the block that - // carries its value, which is a one-block revert to the superseded value. - void release(std::size_t slot, std::uint32_t seq) { + // Releases `slot`'s hold. The `ReleaseProof` argument is the enforcement: it can only be + // constructed once the model carrying this point has been republished (or shown not to need + // it), so a caller earlier in that ordering has no value to pass. + void release(std::size_t slot, std::uint32_t seq, ReleaseProof) { folded_[slot].store(seq, std::memory_order_release); } diff --git a/src/shell/instrument/instrument_params.cpp b/src/shell/instrument/instrument_params.cpp index 619f268..50ab140 100644 --- a/src/shell/instrument/instrument_params.cpp +++ b/src/shell/instrument/instrument_params.cpp @@ -6,8 +6,6 @@ #include "shell/instrument/reasampler_processor.h" -#include - #include "base/source/fstring.h" #include "pluginterfaces/base/ustring.h" #include "pluginterfaces/vst/ivstparameterchanges.h" // IParameterChanges / IParamValueQueue @@ -307,28 +305,20 @@ void ReaSamplerProcessor::drainAutomationToModel() { // controller cache is still refreshed, so the host's display and the editor follow. const bool wasSuppressed = paramNotifySuppressed_; paramNotifySuppressed_ = true; - // Captured before the publish below, so the assert at the release loop can tell "the model's - // publish already landed" from "it merely happened to be in flight for some other reason". - const std::uint32_t generationBeforeFold = liveParams_.generation(); if (moved) { setInstrumentParams(params); - publishLiveParams(); } + // The hold outranks the model only until the model carries the point (this directory's + // CLAUDE.md, THE AUTHORITY MODEL) — `releaseProof` is that ordering enforced structurally: + // `automation_.release` below cannot compile without one, and the only ways to obtain one are + // `publishLiveParams`'s return (the `moved` branch) or `noRepublishNeeded` (nothing to + // publish because the fold left the model already matching the point). + const AutomationChannel::ReleaseProof releaseProof = + moved ? publishLiveParams() : AutomationChannel::ReleaseProof::noRepublishNeeded(); syncParamsFromModel(); paramNotifySuppressed_ = wasSuppressed; - // LAST, and that is the whole authority rule: the hold outranks the model only until the - // model carries the point. Released any earlier and the audio thread could drop the hold - // ahead of the block that carries its value; never released at all — the defect this - // replaces — and one point would defeat every later restore, reset and knob move. - // Enforced, not just commented: two prior passes inverted this order and every test in the - // tree still passed, because nothing exercises `process()`. `publishLiveParams` is a no-op - // before the engine has a sample rate (`builtSampleRate_`), which is the one case this assert - // must not fire for. - assert((!moved || builtSampleRate_.load(std::memory_order_relaxed) <= 0 || - liveParams_.generation() != generationBeforeFold) && - "release ran ahead of the publish that gives it authority"); for (std::size_t i = 0; i < foldedCount; ++i) { - automation_.release(foldedSlots[i], foldedSeqs[i]); + automation_.release(foldedSlots[i], foldedSeqs[i], releaseProof); } } diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 6dacac4..c33fa27 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -276,16 +276,21 @@ void ReaSamplerProcessor::clearMasterBusClip() { meterClip_.store(false, std::memory_order_relaxed); } -void ReaSamplerProcessor::publishLiveParams() { +AutomationChannel::ReleaseProof ReaSamplerProcessor::publishLiveParams() { const int rate = builtSampleRate_.load(std::memory_order_relaxed); - if (rate <= 0) return; + if (rate <= 0) return AutomationChannel::ReleaseProof::noRepublishNeeded(); + const std::uint32_t before = liveParams_.generation(); const InstrumentParams params = instrumentParams(); const instrument::engine::LiveValues block = instrument::engine::foldLive(resolvePlay(params.play, rate), params.keyTrack); - // livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against - // reloadInstrument's publish — held for the publish call only, not the fold above. - std::lock_guard lock(livePublishMutex_); - liveParams_.publish(block); + { + // livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) + // against reloadInstrument's publish — held for the publish call only, not the fold + // above. + std::lock_guard lock(livePublishMutex_); + liveParams_.publish(block); + } + return AutomationChannel::ReleaseProof::fromPublish(before, liveParams_.generation()); } SampleRefs ReaSamplerProcessor::sampleRefs() { diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index e3b9cc6..8b0f177 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -203,8 +203,8 @@ public: // voices already latched. THE tier-3 commit (the three tiers are listed in this // directory's CLAUDE.md). Callers pair this with setInstrumentParams exactly as they // paired it with reloadInstrument. No-op before anything has been decoded (the next reload - // bakes and publishes). UI thread; serialized against reloadInstrument's own publish. - void publishLiveParams(); + // bakes and publishes). UI thread; serialized against reloadInstrument's own publish. Returns drainAutomationToModel's release proof (automation_channel.h); other callers ignore it. + AutomationChannel::ReleaseProof publishLiveParams(); // Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read // on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is From de34fbafdb3afbcc2339aa6c0ee0604066a1fe90 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 20:20:48 -0400 Subject: [PATCH 52/56] Close two defeatable ReleaseProof guards and fix the static-lane skip's dead comparison ReleaseProof{} and copy-reuse both compiled under this project's C++17; user-provided ctor, deleted copy ctor and friend close them. The skip now compares stored values, not norms, so it actually fires. Abort downgraded to a debug assert. --- src/core/instrument/engine/live_params.h | 12 ++-- src/shell/instrument/CLAUDE.md | 2 +- src/shell/instrument/automation_channel.h | 81 +++++++++++++++------- src/shell/instrument/instrument_params.cpp | 35 ++++++++-- 4 files changed, 95 insertions(+), 35 deletions(-) diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index b8feebb..6b12332 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -70,11 +70,13 @@ struct LiveValues { // The seqlock copies the block as raw bytes, which is only defensible for a plain value type. static_assert(std::is_trivially_copyable_v, "the live block is copied under a seqlock — it must stay a plain value"); -// Guards operator== against silent staleness: a member added to the struct above changes this -// size, so the assert fails at the new member's own commit instead of leaving a live control -// that never reaches a sounding voice with no compiler or test signal. Confirmed 352 bytes, -// MSVC 19.44 x64, Release (`SizeProbe`, an incomplete-template size probe -// whose error message reports the value). Bump the literal AND operator== together. +// A SIZE-CHANGING edit only: padding can absorb a member added beside an existing one (a bool +// beside splineActive, a fifth FilterSettings float) without moving this literal at all, so this +// assert is NOT the guard against a forgotten operator== field — +// testEveryFieldOfLiveValuesIsCompared (test_live_params.cpp) is that guard, poisoning one leaf +// at a time. This assert only catches an edit that changes sizeof(LiveValues) itself. Confirmed +// 352 bytes, MSVC 19.44 x64, Release (`SizeProbe`, an incomplete-template +// size probe whose error message reports the value). Bump the literal AND operator== together. static_assert(sizeof(LiveValues) == 352, "a member was added or removed — extend operator== in live_params.cpp to match"); diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 020e0f9..77c9033 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -132,7 +132,7 @@ the single authority.** Everything else that holds these values is a cache or a Every writer above except the last two writes the model directly, so for those "authority ends" is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never -touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:192` +touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193` — which is why its row states no authority window of its own. The automation lane is the only one that cannot write directly: the SDK delivers it on the audio thread, where the model path allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the diff --git a/src/shell/instrument/automation_channel.h b/src/shell/instrument/automation_channel.h index 5c2deff..b3d8764 100644 --- a/src/shell/instrument/automation_channel.h +++ b/src/shell/instrument/automation_channel.h @@ -2,47 +2,74 @@ // AUTHORITY LIFETIME is mechanised: a point outranks the model from the block it lands in until // the UI thread has folded it back into the model AND republished. This directory's CLAUDE.md // states the model; `core/instrument/param/param_merge` is the pure decision this feeds. -// The release itself is observed the NEXT BLOCK, not the instant it happens — refreshReleases() -// only runs inside process()'s merge branch (reasampler_processor.cpp), and publishLiveParams -// always bumps the generation that branch checks, so the next block is guaranteed to take it. +// The release itself is observed the NEXT BLOCK, not the instant it happens — see +// refreshReleases(). #pragma once #include +#include #include #include -#include #include "core/instrument/param/param_merge.h" namespace reasampler::vst { +class ReaSamplerProcessor; // the one class that legitimately mints a ReleaseProof + // The DeckParam ordinal space — what the automation slots and the notification diff both index. inline constexpr std::size_t kDeckParamSlots = instrument::param::kDeckParamSlots; class AutomationChannel { public: // Evidence release() below requires: the model has actually caught up with the - // point being released. The private constructor means the only way to obtain one - // is through a factory here, and both are named for the case they cover — + // point being released. The two factories are named for the case they cover — // `fromPublish` for an observed model republish, `noRepublishNeeded` for the two // cases drainAutomationToModel finds nothing to publish (the fold left the model - // unchanged, or no engine exists yet to read the live block). A caller that has - // not published cannot spell the argument release() needs, which is what turns - // reordering the release ahead of the publish into a compile error. + // unchanged, or no engine exists yet to read the live block). This is NOT a full + // compile-time proof of ordering: `noRepublishNeeded` still lets `ReaSamplerProcessor` + // claim the exemption with no publish at all. What it enforces structurally is that a + // caller cannot spell release()'s argument without naming, by which factory it called, + // WHICH exemption it is claiming — greppable by a reviewer, not silently inline — and + // both factories are restricted to the one class that legitimately needs either. + // The private, user-provided default constructor plus the deleted copy constructor + // close the two ways `{}` and copy-reuse would otherwise fabricate one for free; see + // the two definitions below for which C++17 rule each closes. class ReleaseProof { - public: + private: + friend class ReaSamplerProcessor; + static ReleaseProof fromPublish(std::uint32_t before, std::uint32_t after) { - // publish() is guaranteed to advance the generation; landing here means - // that guarantee broke, worth crashing on unconditionally rather than - // tolerating silently. - if (after == before) std::abort(); + // publish() advances the generation BY CONSTRUCTION (gen + 2, skipping 0 on wrap — + // live_params.h), so `after == before` is unreachable from this, its one call site, + // short of ~2^31 publishes wrapping exactly onto `before`. It also cannot see the + // actually-reachable failure mode, a caller falsely claiming noRepublishNeeded() — + // that path never runs this function. DEBUG-ONLY on purpose: an unconditional abort + // here would take down a musician's whole REAPER session, unsaved work on every other + // track and plugin included, for a condition this call site cannot produce; a debug + // build (where the test suite runs) is where a future regression in publish()'s + // advance guarantee should be caught. + assert(after != before && "publish() must advance the generation"); return ReleaseProof{}; } static ReleaseProof noRepublishNeeded() { return ReleaseProof{}; } - private: - ReleaseProof() = default; + // User-PROVIDED (a body, not `= default`): under C++17 a class with no data + // members and only a user-DECLARED (not user-provided) default constructor is + // still an aggregate, because C++17's aggregate rule excludes private data + // members only, not private constructors — so `ReleaseProof{}` would perform + // aggregate init and never call this. A user-provided constructor defeats that. + // (C++20's P1008 closes the same hole at the language level; this project is + // pinned to C++17 — CMakeLists.txt:28 — so the class must close it itself.) + ReleaseProof() {} + // Closes the other free-mint path: the implicit copy constructor is public by + // default, so one legitimately-minted proof cached in a member could be replayed + // by any later caller with no new publish behind it. A user-declared copy + // constructor (deleted or not) also suppresses the implicit move constructor, so + // no move-based replay path opens in its place — `release()` below takes this by + // const reference for exactly that reason, rather than needing one back. + ReleaseProof(const ReleaseProof&) = delete; }; // --- AUDIO THREAD ------------------------------------------------------------------- @@ -72,12 +99,16 @@ public: } // Refreshes each held slot's release answer. Runs only inside process()'s merge branch, so a - // release lands the NEXT BLOCK after the UI thread makes it, never the same instant — benign, - // because publishLiveParams always bumps the generation that branch checks, so the next block - // is guaranteed to run this. Must run BEFORE the model block is read: the acquire here - // synchronizes with the UI thread's release store, which it makes only AFTER republishing the - // model — so a slot seen released is one whose value any block read after this point is - // guaranteed to already carry. + // release lands the NEXT BLOCK after the UI thread makes it, never the same instant — benign + // on the `moved` path because publishLiveParams always bumps the generation that branch + // checks, so the next block is guaranteed to run this. The `noRepublishNeeded()` path has no + // publish and no generation bump to guarantee that — land() drops a static lane's repeats, so + // `moved` is false there too — but it is equally benign: the model already carries the value + // (that is why nothing published), so the release is simply observed whenever the generation + // next moves under ANY writer, not specifically this one. Must run BEFORE the model block is + // read: the acquire here synchronizes with the UI thread's release store, which it makes only + // AFTER republishing the model — so a slot seen released is one whose value any block read + // after this point is guaranteed to already carry. void refreshReleases() { for (std::size_t i = 0; i < kDeckParamSlots; ++i) { if (!slots_[i].held) continue; @@ -102,8 +133,10 @@ public: // Releases `slot`'s hold. The `ReleaseProof` argument is the enforcement: it can only be // constructed once the model carrying this point has been republished (or shown not to need - // it), so a caller earlier in that ordering has no value to pass. - void release(std::size_t slot, std::uint32_t seq, ReleaseProof) { + // it), so a caller earlier in that ordering has no value to pass. By const reference, not + // value: the copy constructor is deleted (see ReleaseProof), and the one caller releasing a + // whole fold's worth of slots passes the same proof through this repeatedly. + void release(std::size_t slot, std::uint32_t seq, const ReleaseProof&) { folded_[slot].store(seq, std::memory_order_release); } diff --git a/src/shell/instrument/instrument_params.cpp b/src/shell/instrument/instrument_params.cpp index 50ab140..1aa470f 100644 --- a/src/shell/instrument/instrument_params.cpp +++ b/src/shell/instrument/instrument_params.cpp @@ -295,8 +295,27 @@ void ReaSamplerProcessor::drainAutomationToModel() { // A present-but-static lane resends the SAME point every tick; writing it back would // republish liveParams_ and move paramsGeneration_ — a full model copy, a controller // cache write and an editor repaint, every tick, forever, for a value that never moved. - // The release below still fires: the model already carries the point either way. - if (value == modelParamNormalized(params, row.deck)) continue; + // Compared on the STORED side, never the normalized one: toNormalized(toPlain(n)) == n + // does NOT hold in general (param_taper.h — "no log map satisfies it in double"), so + // comparing `value` to modelParamNormalized(params, ...) skipped almost nothing for the + // 33 of 43 exposed controls whose taper is log/curved rather than identity — the churn + // this comment describes ran unabated for those. hostStoredFromNorm is the exact map + // writeDeckParamToModel below applies, so comparing its answer against the field it would + // land in asks "would this write change anything", not "are two norms equal". The + // release below still fires either way: the model already carries the point regardless + // of whether this write actually runs. + const double newStored = param::hostStoredFromNorm(row.deck, value); + bool unchanged = false; + if (row.deck == DeckParam::kKeyTrack) { + unchanged = newStored == params.keyTrack; + } else if (float* f = instrument::ui::deckFloatField(row.deck, params.play)) { + // The filter's four store a float: compared at that width, since that is what a + // re-write would actually round to, not the double newStored computes before it. + unchanged = static_cast(newStored) == *f; + } else if (double* d = instrument::ui::deckDoubleField(row.deck, params.play)) { + unchanged = newStored == *d; + } + if (unchanged) continue; writeDeckParamToModel(params, row.deck, value); moved = true; } @@ -312,9 +331,15 @@ void ReaSamplerProcessor::drainAutomationToModel() { // CLAUDE.md, THE AUTHORITY MODEL) — `releaseProof` is that ordering enforced structurally: // `automation_.release` below cannot compile without one, and the only ways to obtain one are // `publishLiveParams`'s return (the `moved` branch) or `noRepublishNeeded` (nothing to - // publish because the fold left the model already matching the point). - const AutomationChannel::ReleaseProof releaseProof = - moved ? publishLiveParams() : AutomationChannel::ReleaseProof::noRepublishNeeded(); + // publish because the fold left the model already matching the point). Built via an + // immediately-invoked lambda rather than `moved ? publishLiveParams() : noRepublishNeeded()` + // directly: ReleaseProof's copy constructor is deleted (automation_channel.h), and a ternary + // between two same-type prvalue arms needs it to merge them into one value — each `return` + // below is instead its OWN guaranteed-elided construction of the function's result object. + const AutomationChannel::ReleaseProof releaseProof = [&]() -> AutomationChannel::ReleaseProof { + if (moved) return publishLiveParams(); + return AutomationChannel::ReleaseProof::noRepublishNeeded(); + }(); syncParamsFromModel(); paramNotifySuppressed_ = wasSuppressed; for (std::size_t i = 0; i < foldedCount; ++i) { From a9c166b8c2088cb41ebaf403de1ed67fbec410d7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 20:30:04 -0400 Subject: [PATCH 53/56] docs: record Phase Gamma Wave 4's landed track and collapse the wave to it --- docs/COMPLETED.md | 72 +++++++++++++ docs/PLAN.md | 265 +++++++--------------------------------------- 2 files changed, 108 insertions(+), 229 deletions(-) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index c34c465..fba8466 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -1248,3 +1248,75 @@ unlimited — and Daniel has ruled that a future track will change the bake to p limiter. Until that lands this is a recorded, known limitation, not an oversight. **Neither track has been verified in a running DAW; both are asserted in CTest only.** + +### Γ-W4-T1 — vst3-parameter-set + +The instrument now reports its automatable parameters to the host: 44 of 44 issue, under a +FOREVER-FROZEN `ParamID` table — blocks of 100 per deck group in signal-flow order, steps +of 10 within a block, a curve dial at its outer knob's id + 1 — each with a real plain +range, `units` string and display precision at the host boundary, not a raw normalized +float. The exposed set is DERIVED from `deckParamCommit` / `liveCommitFor`, never +hand-maintained: a control qualifies iff its class is `Live` or `NoteOnLatched`. Everything +else — play mode, the pitch engine, filter enable, the three Staged↔Spline toggles, voice +count, Poly|Mono, Retrigger|Legato, and the limiter enable — is OMITTED from the list +entirely rather than exposed read-only, a named limitation rather than a silent one. A new +pure module, `core/instrument/param` (`param_id`, `param_units`, `param_format`, +`param_live`, `param_merge`), holds the id table, the plain-value layer, the one formatter +per unit category (eight of them), and the audio thread's block-boundary merge decision; +`shell/instrument/instrument_params` adapts it onto `Steinberg::Vst::Parameter` and decides +nothing itself. + +**Both VST3 delivery channels are serviced.** An earlier pass routed host automation +through `IEditController::setParamNormalized` alone — the SDK documents that as the +GUI-update channel only ("should update the according GUI element(s) only") — while +`ProcessData::inputParameterChanges` is the audio-side one; the SDK's own +`SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) +drains the queue in `process()` *and* implements `setParamNormalized`. Both are now +serviced. + +**A host automation point's authority is bounded, not permanent.** It outranks the model +only between the point landing and the UI thread folding it into the model and +republishing — at most one UI tick — never a later restore, bake reset, or knob move. An +earlier pass made the hold permanent, which silently defeated `setState`, preset load, +undo, and the bake's reset for any parameter that had ever carried an automation point. +The model is now written down in full — `shell/instrument/CLAUDE.md`'s "THE AUTHORITY +MODEL" section — and enforced by the pure `param_merge`; `test_param_merge` asserts both +halves: that a held point outranks the model until the model catches up, and that a writer +after the release reaches the audio again. + +**Two rulings, both Daniel, 2026-08-02.** (1) Pitch key-track and Trigger length promote +from `Reload` to `NoteOnLatched` — the promotion that takes the count to 44 of 44 and +issues ids 1000 and 1450. It was **not** the predicate-only change the plan anticipated: +key-track lives on `InstrumentParams`, not `PlaySeconds`, so the host's write path could +not reach it without `LiveValues` and `foldLive`'s input widening and `Voice::start` +taking the two latched values as arguments beside rate; the new `param::valueHomeFor` +guard closes the class of bug this exposed (a promoted control with no home would have +no-oped silently in both directions) by asserting every exposed control has a home and +branching the shell's own read/write paths on it. (2) The curve-shape dials' ±0.01 +snap-to-centre band now applies on the mouse-drag path only, never on a host-facing map — +*"our continuous ranges should be continuous."* + +**Two adjacent SDK surfaces were assessed and left unimplemented, with dispositions +recorded rather than re-surveyed later.** `IMidiMapping` — no CC vocabulary fits what's +exposed, and REAPER's own per-parameter MIDI learn is expected to cover the case. +`IParameterFunctionName` and `IAutomationState` are also not implemented; the latter +reports the host's automation mode for the whole plug-in, not per parameter, so it cannot +answer the bake's "is this parameter automated" question. + +**The bake's reset now notifies the host, and its one remaining gap is named rather than +hidden.** Every internal writer of an exposed parameter's value goes through the one +`beginEdit`/`performEdit`/`endEdit` path, the bake's reset included. What it cannot do: +clear a host automation lane. If a reset-class parameter carries one, the lane replays its +curve onto audio the bake already baked that processing into — double processing — and +`IAutomationState`'s whole-plugin (not per-parameter) granularity means there is no way to +detect or refuse it. Documented as a boundary of the bake's fidelity claim, not discovered +later as a bug against Phase Ξ. + +**The per-sample voice path is byte-identical across the whole track.** + +**Not verified in a running DAW — CTest-asserted only.** `docs/TODO.md` carries the +residual DAW-verification items: whether REAPER renders `ParameterInfo::units` beside the +formatted string, whether REAPER's MIDI learn actually covers the un-shipped `IMidiMapping` +case, the three migration round trips (a pre-parameter project, a save/reopen in an older +binary, automation drawn and replayed), whether an offline render replays automation, and +whether REAPER restores instance state through `setState` rather than `setComponentState`. diff --git a/docs/PLAN.md b/docs/PLAN.md index af9997a..fc5ce24 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1121,243 +1121,50 @@ not own the processor's live block, the limiter DSP itself, the parameter surfac ### Γ-W4 — VST3 parameters -**Depends on every earlier wave, and each dependency is a hard prerequisite rather than a -courtesy:** +**Depended on every earlier wave in this phase** — W1-T1's taper module (the host-facing +normalization itself), W1-T2 and W2-T1's control inventory and three-state commit +predicate (the exposed set is exactly `Live ∪ NoteOnLatched`), W3-T1's final MASTER +inventory, and W3-T2's completed bake reset list — each a hard prerequisite rather than a +courtesy. See `docs/COMPLETED.md` for the full narrative of each. -1. **← W1-T1.** The taper and the 10 s ceiling **are** the host-facing normalization, and - W1-T1 is also what extracts them into the one pure module the host reads through. Declaring - parameters against a taper that is still moving is the one-way door this whole phase is - ordered around. -2. **← W1-T2 and W2-T1.** Every control that could be a parameter must exist before the list - is declared. The list is derived from the control inventory; an inventory still growing - produces a list that has to be re-frozen, and it cannot be. -3. **← W2-T1 specifically.** `deckParamCommit` becoming three-valued is the *prerequisite* of - the classification, not an incidental: the exposed set is exactly `Live ∪ NoteOnLatched`. -4. **← W3-T1.** MASTER's inventory (limiter toggle, GR bubble, reserved cell) is the last - change to what controls exist at all. -5. **← W3-T2.** The bake's reset list must already be complete, so this track adds the - host-notification obligation once rather than amending an amendment. - -**One track.** The storage decision governs every part of the work — the projection rule, the -migration path, what `getParamNormalized` returns, and what the bake's reset must do — exactly -as Ξ-W2-T1's crossing decision governs its chain. Every candidate split (a pure -model/classification half and a host-wiring half) is **serial**, so it buys no concurrency and -puts the decision on one side of a boundary and its consequences on the other. +**The track has landed** — Γ-W4-T1 (`vst3-parameter-set`) — see `docs/COMPLETED.md` for +the full narrative. One track: the storage decision governed every part of the work, so a +pure/host-wiring split would have been serial and bought no concurrency. #### Γ-W4-T1 — `vst3-parameter-set` -**Goal.** The instrument reports its automatable parameters to the host, under a frozen id -contract and a logical order — which also hands it REAPER's whole per-parameter modulation -block (LFO, envelope follower, MIDI link, parameter linking) for free. +**Landed** — see `docs/COMPLETED.md` for the full narrative. The instrument now reports +its automatable parameters to the host under a FOREVER-FROZEN `ParamID` table (blocks of +100 per deck group in signal-flow order, steps of 10 within a block, a curve dial at its +outer knob's id + 1) — 44 of 44 issue, derived from `deckParamCommit` / `liveCommitFor` +rather than hand-maintained, each with real units, a plain range and display precision at +the host boundary. A new pure module, `core/instrument/param`, holds the id table, the +plain-value layer, the one formatter per unit category, and the audio thread's +block-boundary merge decision; `shell/instrument/instrument_params` adapts it onto +`Steinberg::Vst::Parameter` and decides nothing. -**Consolidates:** nothing from the seventeen. **Ruling 1** (Daniel, 2026-08-01): *"correct the -phase gamma plan to account for complying with the VST3 standard for parameter reporting… by -the end of gamma we have the automatable params reported. Make the parameter order logical."* +**Both VST3 delivery channels are serviced** — the controller's `setParamNormalized` and +the audio thread's `IParameterChanges` drain, mirroring the SDK's own +`SingleComponentEffect` sample rather than the controller-only delivery an earlier pass +shipped. **A host automation point's authority is bounded**, not permanent: it outranks +the model only until the UI thread folds it in and republishes — at most one UI tick — +never a later restore, bake reset or knob move. `shell/instrument/CLAUDE.md`'s "THE +AUTHORITY MODEL" section states it, `param_merge` enforces it. -**Spec:** `docs/product/parameter-automation.md` — **§§6–10 are the specification; §§1–5 are -the analysis behind it.** Read §6.1 (storage), §6.3 (the freeze), §7 (the classification) and -§8 (the one-way-door sweep) before scoping. **Today the plugin has zero parameters:** -`ReaSamplerProcessor::initialize` (`reasampler_processor.cpp:56-73`) never populates -`SingleComponentEffect::parameters`, so `getParameterCount()` returns the SDK default 0. This -track introduces the whole surface. +**Two rulings, both Daniel, 2026-08-02.** Pitch key-track and Trigger length promote from +`Reload` to `NoteOnLatched` — the promotion that takes the count to 44 of 44 and issues +ids 1000 and 1450, and which needed `LiveValues`/`foldLive` widened and `Voice::start` +handed the two latched values, not just a predicate flip; the new `param::valueHomeFor` +guard closes the class of bug the promotion exposed. And the curve-shape dials' ±0.01 +snap-to-centre band now applies on the mouse-drag path only, never on a host-facing map. -**Surface boundary — owns:** a **new pure parameter-identity module** (the frozen id table, -the `DeckParam` ↔ `ParamID` mapping, the derived exposed set, the unit assignment — with its -own `_tests` target), `shell/instrument/reasampler_processor` + `processor_state` (the -`IEditController` parameter surface and the `IParameterChanges` read), -`shell/instrument/editor_controls.cpp` and the editor's drag-commit sites (the -`beginEdit`/`performEdit`/`endEdit` bracketing), and the bake's reset step **for the -notification path only**. **Does not own** the taper (W1-T1's module, consumed), any control's -value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). +**The bake's reset now notifies the host; the double-processing limitation (a host +automation lane outranking the reset) is a named, documented boundary, not discovered +later.** `IMidiMapping`, `IParameterFunctionName` and `IAutomationState` are all assessed +and NOT implemented. -**Behavior.** -- **The blob stays authoritative; a parameter is a THIRD SURFACE onto the one model** — - a peer of the deck knob and the overlay node, not a second copy of the value. - `docs/product/parameter-automation.md` §6.1 states the load, host→plugin, plugin→host and - save rules, and the two verified findings that closed the fork: this plugin is a - `SingleComponentEffect`, where the SDK itself collapses `IComponent::setState` and - `IEditController::setState` (`vstsinglecomponenteffect.h:41-47`), so there is one state and - §3.3's drift hazard describes a split-component design we do not use; and the blob is a - **cross-artifact contract** the extension's `instrument_drop` writes, which - parameters-as-truth would silently make partial. -- **`ParamID` is an independent, hand-assigned, FOREVER-FROZEN table** — blocks of 100 per - deck group **in signal-flow order** (Γ-F7), steps of 10 within a block, a curve dial at its - outer knob's id + 1, blocks starting at 1000. **The full 44-id assignment is stated at - §6.2** and is to be transcribed, not re-derived. §6.2 also for why hand-assignment beats - derivation and why the within-block order is seeded ONCE rather than tracked against - `cellIds`; **§6.3 for the freeze invariant, which is to be stated in the table's header with - the same force as the command-id strings, the class UIDs and the payload field order.** -- **The exposed set is DERIVED from `deckParamCommit` / `liveCommitFor`, never - hand-maintained** — a control is a parameter iff its class is `Live` or `NoteOnLatched`. - **44 parameters** at the end of Γ-W3, enumerated by group in §7.1. -- **Everything else is OMITTED from the list entirely**, not exposed-and-flagged: the reload - and rebuild tiers, all structural state, and the limiter enable (§3.8, settled). §7.2 - states why omission beats `kIsReadOnly`, and names the limitation plainly — the user cannot - automate filter on/off, play mode, the pitch engine, Staged↔Spline or polyphony, and the - unlock is to give the control a live path first. -- **Every exposed parameter reports REAL UNITS to the host** (Ruling 3). Each declares a - **plain range**, a **`units` string**, and a **display precision**; the complete - eight-category table covering all 44 — ranges, units, precision, and which taper each - category carries — is `docs/product/parameter-automation.md` §6.7.1, and it is a - specification, not a suggestion. VST3's wire format stays normalized (it cannot be - otherwise); the requirement is met through the **plain-value layer** the SDK provides, whose - direct precedent in the vendored tree is `public.sdk/samples/vst/common/logscale.h:221-229` - overriding `toPlain`/`toNormalized` for a log law — the exact shape our log ms and log2 - semitone knobs need. -- **`normalizedParamToPlain` / `plainParamToNormalized` / `getParamStringByValue` / - `getParamValueByString` route through W1-T1's taper module and the ONE formatter per unit - category.** Three functions that agree today is a defect; the host's normalization, the - needle angle and the overlay node must be the same function. **`toNormalized` IS the - taper** — §6.7.3 — which is what makes the phase's one-way-door ordering structurally true - rather than a warning someone has to remember. -- **`stepCount = 0` on all 44, and the editor's shift-snap is never exposed as `stepCount`.** - Snapped drag and parameter continuity are independent axes; `stepCount` quantizes the - parameter permanently, including for the host's automation, and freezes into the forever - contract. The sweep is done and clean (§6.7.6) — every discrete control is reload or rebuild - tier and therefore already omitted, so continuity is structural rather than lucky. -- **`IParameterChanges` is observed at BLOCK boundaries, stated in the header** — the last - point in a block wins. Sample-accurate application would put a per-sample "did anything - change" question on the per-voice-per-sample path, which the phase-wide guardrail forbids. -- **A host parameter change takes the control's existing commit tier and no other.** Nothing - on the automation path may reach `reloadInstrument` or `rebuildVoiceEngine` — which §7.2's - omissions guarantee structurally rather than by care. -- **`IUnitInfo`: one unit per deck group**, mirroring the group inventory rather than the - editor's rows. **Order is signal flow — Γ-F7, RULED** (§6.4). Presentation index order is - ascending id, so identity and presentation agree by construction. -- **The filter's four report plain units WITHOUT being re-tapered.** `toPlain` is read-side - only; reporting Hz / Q / drive depth means calling `filterCutoffHzFromNorm`, - `filterQFromNorm` and `filterDriveDepthFromNorm` — the filter module's own frozen laws, - which `deckValueLabel` already calls today — not restating them. **The one additive piece: - `filterNormFromDriveDepth` does not exist and must be added in `filter_params`**, beside the - two inverses that do; the analytic inverse of a frozen law is not a change to it. §6.7.5. -- **No `kIsBypass` on anything.** The plugin is an instrument and exposes no bypass - parameter; the limiter is a safety device, not a bypass, and binding it there would hand - the host a control that restarts the component. -- **The bake's reset gains a notification obligation** (§9): every internal writer of a value - that is an exposed parameter must go through the one `beginEdit`/`performEdit`/`endEdit` - path, and the bake's reset is the codebase's first non-gesture writer. **Enumerating those - sites is part of this track**, not a follow-up. -- **Two adjacent SDK surfaces are assessed, with dispositions, so they are not re-surveyed:** - `IMidiMapping` is **in scope and nearly free** (a CC → `ParamID` map, one function); - `IParameterFunctionName` is **not implemented** (its vocabulary is compressor/panner - semantics that name nothing here); `IAutomationState` is **not implemented** (it reports the - host's automation mode for the whole plug-in, not per parameter, so it cannot answer the one - question §9 would have wanted it for). - -**Acceptance criteria.** -- **The host lists exactly the derived set, in the ruled order, with no parameter the - predicate does not classify `Live` or `NoteOnLatched`** — asserted against the predicate, - not against a literal count. -- **Every id in the table is asserted unique, in its group's block, and on its step** — and a - test fails if any id changes value, which is what makes the freeze mechanical rather than - cultural. **The asserted values are §6.2's table verbatim**, including the signal-flow block - sequence; a test that recomputes the ids from `cellIds` would defeat the freeze it exists - to hold. -- **`toPlain(info.defaultNormalizedValue)` compares EXACTLY equal to the default**, per - parameter, against a default-constructed `PlaySeconds` (and against `master_gain`'s unity), - so a host's reset-to-default and the editor's double-click land on the same value. - **`defaultNormalizedValue` is computed as `toNormalized(default)`, not written as a - literal** — a grep finds no normalized default constant. If the exactness fails, it is a - W1-T1 defect surfacing here, not a defect of this track. **Round-trip exactness at - arbitrary values is NOT asserted** — it is not required (§6.7.7) and asserting it would - over-constrain the taper. -- **`getParamStringByValue` prints what the editor's knob label prints**, digit for digit, at - the same stored value, across every unit category — asserted by calling **the same - formatter** from both sides in one test, not by comparing two independently produced - strings. **A grep finds exactly one formatter per unit category** and no `snprintf` of a - parameter value outside it. -- **Every exposed parameter's `units` and plain range match §6.7.1**, asserted per parameter; - `stepCount` is asserted **zero on all 44**. -- **A host automation lane moving a Live parameter moves a sounding note; a lane moving a - NoteOnLatched parameter takes effect on the next note and does NOT trigger a reload or an - engine rebuild** — assert the tier, not just the sound. All three NoteOnLatched controls now - have a delivery test in `tests/test_live_delivery.cpp` (rate, key-track, Trigger length), each - asserting BOTH halves: the sounding note byte-identical, the next note taking the value. -- **A host automation point's AUTHORITY IS BOUNDED**, and its release is where both review passes - went wrong: it outranks the model only until the UI thread has folded it in and republished. - `shell/instrument/CLAUDE.md`'s Authority section states the model, `param_merge` enforces it, - `test_param_merge` fails if a hold is never released or is released too early. -- **No automation path reaches `reloadInstrument` or `rebuildVoiceEngine`.** -- **A project saved before this change opens with every parameter reading the blob's value - and sounds identical**; a project saved by this build opens in an older binary with its - sound intact; and a project with automation drawn, saved and reopened, replays against the - same plain values. -- **`process()` takes no new indirection and no new per-sample work** — the parameter read is - a block-boundary act. **Correction to the original wording ("on the existing live-publish - path"):** it cannot be, and the SDK is what decides that. `IParameterChanges` is delivered ON - the audio thread, and the model's publish path allocates (`resolvePlay` copies velocity curves - and spline contours), so the drain lands in `process()` and patches the live block in place. - The block-boundary rule is unchanged and the per-sample path is untouched; what moved is which - thread performs the fold. -- **The bake's reset notifies the host**, verified by the host's displayed value following it - rather than snapping back on next touch. -- **The double-processing limitation is documented, not discovered** — a bake whose - reset-class parameter carries a host lane is a named boundary of the bake's fidelity claim - (§9), stated in the product doc and in this track's review. -- **The lane-linearity consequence is stated in the header, not left to be found** — under a - tapered parameter a straight line drawn in a host automation lane is **not** linear in the - plain unit (exponential in ms, linear in octaves on cutoff, linear in dB on master gain). - This is standard and desirable, it follows directly from Ruling 3 plus the taper, and §6.7.4 - gives it per category. Writing it down is the acceptance criterion; changing the taper to - avoid it is not an option. -- **The filter's four are proven untouched**: a regression baseline shows their audio - unchanged, and their persisted `*Norm` values are byte-identical across a save/reload that - passes through the parameter surface. Reporting Hz/Q/depth changed display only. - -**Open questions.** -- **No [Daniel] questions. Γ-F7 is RULED — signal flow** (2026-08-01, *"signal flow order."*), - and Ruling 3 (real units) arrived specified rather than forked. **There is no unanswered - [Daniel]-class question in this track or anywhere in this plan.** -- **CLOSED from the SDK, not the DAW.** Two things were bundled here and they separate. - (a) The DELIVERY CHANNEL: `ivsteditcontroller.h` documents `setParamNormalized` as the - GUI-update channel ("should update the according GUI element(s) only"), and the SDK's own - `SingleComponentEffect` sample (`public.sdk/samples/vst/again/source/againsimple.cpp`) drains - `ProcessData::inputParameterChanges` in `process()` while also implementing - `setParamNormalized`. **Both are serviced.** This was never a DAW question — the headers - answer it, and building on the paragraph alone is exactly what the first pass did. - (b) The ORDERING of `setState` against the first parameter block: no longer a question, but not - for the reason the second pass gave. A point held by the audio thread is re-applied over every - merge only until the UI thread has folded it into the model — the hold is bounded, and a lane - that is genuinely DRIVING re-sends and so keeps outranking the restore, while a lane that sent - one point and had it folded does not. That is the correct reading of the rule "a lane in - read/write mode outranks a plug-in-side set" (stated in `docs/product/parameter-automation.md` - §6.1 as reasoning from the host's replay behaviour, not from a header — the SDK does not spell - it out). The second pass's unbounded latch made the claim true by making every later writer - permanently deaf; `shell/instrument/CLAUDE.md`'s Authority section is the model now. - (c) **STILL OPEN, and it is the half the bundled `[verify, FIRST]` originally asked**: that - REAPER calls `setState` (not `setComponentState`) on a single-component plug-in — a state - ENTRY-POINT question, not a delivery-channel one. Recorded in `docs/TODO.md` rather than closed: - `vstsinglecomponenteffect.h:41-47` does collapse the names as §6.1 claims, our overrides land on - the `IComponent` pair with `setEditorState`/`getEditorState` left at the base's `kNotImplemented`, - and the blob has shipped through payload v1…v16, so the behaviour is very likely fine — but that - is inference, not observation. The rest of the DAW work is in `docs/TODO.md` too, and none of it - can change the frozen contract. -- **[verify]** whether REAPER renders `ParameterInfo::units` beside the string - `getParamStringByValue` returns, or shows the string alone. **We ship the SDK's own - convention** — digits in the string, unit carried separately, which is what - `RangeParameter::toString` and the `Parameter` constructor's signature both express. If - REAPER shows no unit at all, the fallback is to append the unit **inside the one formatter**: - a one-line change in one place, touching neither the frozen id table nor the editor, because - display strings are explicitly not frozen (§6.7.1). Do not discover this after shipping. -- **RULED (Daniel, 2026-08-02): promote both.** **Key-track** and **Trigger length** move from - `Reload` to `NoteOnLatched` (§7.4) and are exposed; ids **1000** and **1450** issue and the - count is **44 of 44**. **The promotion is NOT the predicate-only change this bullet originally - advertised** — the predicate flip is the smallest part of it. Key-track lives on - `InstrumentParams`, not `PlaySeconds`, so the host's write path (`setDeckParam`/`deckParamNorm`) - structurally could not see it and id 1000 would have no-oped in both directions with no - compile-time guard; both controls also had to reach the engine, which meant widening - `LiveValues` and `foldLive`'s input and handing `Voice::start` the two latched values as - arguments beside the rate. The guard that closes the class is `param::valueHomeFor` — asserted - over the exposed set (every control has a home, the instance-scalar set has exactly two - members) AND branched on by the shell's own read and write paths, so the three cannot drift. -- **[propose at review]** whether to ship a default `IMidiMapping` CC table here or leave MIDI - control to REAPER's host-side learn. Either is defensible; **skipping it silently is not.** -- **[propose at review]** whether this track spends the reserved payload rung. §6.1 says - nothing new is persisted and therefore it should not; if the `setState` verification says - otherwise, it takes the reserved rung and says so. -- **Closed, do not reopen:** Rate lifted from latched to live (§3.5 records the cost); the - limiter enable made automatable (§3.8 — its one reopening condition was the reload/activation - decoupling, which landed in Γ-W3, so the condition is discharged rather than pending). +**Not verified in a running DAW — CTest-asserted only.** `docs/TODO.md` carries the +residual DAW-verification items. --- From 0d316b7b9e77e719697a2960a79b6cf91fa98c26 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 20:57:29 -0400 Subject: [PATCH 54/56] Print the limiter through the bake's master stage, compensating its lookahead so an engaged bake is the approved sound and a bypassed one is unchanged --- docs/product/instrument-control-surface.md | 15 +- src/core/instrument/bake/CLAUDE.md | 21 ++- src/core/instrument/bake/CMakeLists.txt | 4 +- src/core/instrument/bake/bake_render.cpp | 52 ++++-- src/core/instrument/bake/bake_render.h | 14 +- src/core/instrument/bake/bake_reset.h | 7 +- src/shell/instrument/instrument_bake.cpp | 3 +- tests/test_bake_render.cpp | 182 ++++++++++++++++----- tests/test_bake_window.cpp | 9 +- 9 files changed, 232 insertions(+), 75 deletions(-) diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index b5c3ebb..c5f3f73 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -713,8 +713,19 @@ reduction is applied.** Phase Ξ-W2's resample reset scope is settled by rule ("reset what the bake baked in"). Derived against that rule — **no new Daniel call**: **rate → reset**, **pitch offset → -reset**, **limiter enabled → reset** (master gain is already on the reset list, so the bake -includes the master stage, so the limiter's effect is in the audio). +reset**, **limiter enabled → reset**. + +**The limiter clause's original reasoning was false, and the code was changed to make its +conclusion true.** It read "master gain is already on the reset list, so the bake includes +the master stage, so the limiter's effect is in the audio" — but the bake printed a flat +gain multiply and nothing else; the limiter ran in the processor's block, off the bake path, +so a capture baked with it engaged came back unlimited and resetting the enable was +resetting a control whose effect was NOT in the file. Daniel ruled the goal rather than the +premise: `renderBake` now prints the whole master stage, gain then limiter, so the +classification stands on the rule it always claimed to. The lookahead is compensated inside +the render, and a bypassed bake is the pre-limiter render frame for frame — +`src/core/instrument/bake/CLAUDE.md` owns both, plus the double-limiting boundary a baked +capture inherits. **This is now a CORRECTION, not a sequencing note.** The original plan required Phase Γ to land before Ξ-W2 so the bake's reset list would be complete on the day it shipped. **That diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 13912a1..4a33025 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -21,9 +21,21 @@ decision about what the render made obsolete. loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but even a pathological envelope cannot run past the window. -- **The voice chain and master gain are printed; the limiter is not.** The gain multiply in - `bake_render.cpp` carries the argument for the gain, and `bake_reset.h` records where the - printed master stage stops. +- **The whole chain is printed — voice, master gain, then the limiter, in the processor's + own order.** `bake_render.cpp`'s master stage carries the argument. The limiter is printed + only when it is ENGAGED; bypassed, `renderBake` never constructs one and the result is the + pre-limiter render frame for frame. The lookahead is compensated inside the render — the + buffers carry an extra flush window and the capture is read past it — so an engaged bake + under the ceiling is bit-identical to a bypassed one, not the same audio 2 ms late. +- **A printed capture replayed through an engaged limiter is limited TWICE — a NAMED + boundary, not a bug**, and the same shape as the automation-lane limitation below. The + reset is what normally prevents it (`limiterEnabled` is not on the survive list, so a bake + hands the enable back off), and at unity the second pass has nothing to take: every sample + of the printed file is already at or under the ceiling, and the limiter reduces only where + its detector reads ABOVE it — which after a bake means its inter-sample estimate alone. Dial + the enable back on over raised gain, though, and the capture is limited on top of limiting + that is already in its samples. Not detectable from inside the instrument and not corrected + there; the user's remedy is to leave the enable where the bake put it. - **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses a collapsed window, a non-positive rate, a window that rounds to no frames, and one past `kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the @@ -72,7 +84,8 @@ decision about what the render made obsolete. render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and `planBake`, the one `ResolvedNote` + rate -> frames resolution, answering a `PlannedBake`. - `bake_render` — `BakeAudio` and `renderBake`: the programmed note through the sample's - own voice path, summed into an interleaved buffer at the source's own channel count. + own voice path and then the master stage, summed into an interleaved buffer at the + source's own channel count. - `bake_reset` — `BakeReset` and `resetAfterBake`: the ratified reset scope, answered for both the parameter set and the post-mixer master gain. diff --git a/src/core/instrument/bake/CMakeLists.txt b/src/core/instrument/bake/CMakeLists.txt index 7a94710..343ca8b 100644 --- a/src/core/instrument/bake/CMakeLists.txt +++ b/src/core/instrument/bake/CMakeLists.txt @@ -6,9 +6,11 @@ reasampler_pure_library(bake_plan LINK PUBLIC note_program sampler_core trigger_seam) reasampler_test(bake_plan LINK bake_plan) +# limiter beside sampler_core, not through it: the render prints the whole master stage, and +# the limiter runs on the summed output rather than inside a voice. reasampler_pure_library(bake_render SOURCES bake_render.cpp - LINK PUBLIC bake_plan sampler_core) + LINK PUBLIC bake_plan sampler_core limiter) reasampler_test(bake_render LINK bake_render) # No library of its own: the derived window is a PROPERTY of bake_plan + bake_render diff --git a/src/core/instrument/bake/bake_render.cpp b/src/core/instrument/bake/bake_render.cpp index 7c86df7..d0b1696 100644 --- a/src/core/instrument/bake/bake_render.cpp +++ b/src/core/instrument/bake/bake_render.cpp @@ -5,6 +5,7 @@ #include #include +#include "core/instrument/engine/limiter.h" #include "core/instrument/engine/voice_engine.h" namespace reasampler::instrument::bake { @@ -18,7 +19,8 @@ constexpr std::int64_t kBlockFrames = 512; } // namespace -BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear) { +BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear, + bool limiterEnabled) { BakeAudio out; if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out; // Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan @@ -36,9 +38,16 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL sample.live = nullptr; const int channels = sample.channelCount(); + // The limiter delays its output by its lookahead, so the buffers carry that many extra + // frames and the window is read that far in — the file is the same frames it would be + // with the limiter bypassed, not the capture shifted late by 2 ms. The extra input is + // SILENCE rather than more rendered audio: the file ends at the window, so a peak past + // it is not in the capture and must not duck the frames that are. + const auto flushFrames = static_cast( + limiterEnabled ? engine::limiterLookaheadSamples(plan.sampleRate) : 0); const auto rendered = static_cast(plan.renderFrames()); - std::vector left(rendered, 0.f); - std::vector right(channels == 2 ? rendered : 0u, 0.f); + std::vector left(rendered + flushFrames, 0.f); + std::vector right(channels == 2 ? rendered + flushFrames : 0u, 0.f); // Pre-size the Preserve shifters here, off any audio thread, exactly as the processor // does for its live engine — a cold shifter would smear the onset. @@ -69,20 +78,39 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL pos += chunk; } + // The whole master stage is printed here rather than left for the processor, in the + // processor's own order — gain, then the limiter — because resetAfterBake hands both + // controls back neutral: a render that only summed voices would return every iteration + // shifted by 1/gain and unlimited, and a gain dialed to silence would come back at full + // level. A flat gain multiply, not the processor's per-sample ramp: the gain is constant + // for the whole render, which is exactly what that ramp exists to converge to. + const auto gain = static_cast(masterGainLinear); + for (AudioSample& s : left) s *= gain; + for (AudioSample& s : right) s *= gain; + + if (limiterEnabled) { + engine::Limiter limiter; + // Enabled BEFORE prepare, whose reset snaps to the enable target: that starts the + // render already engaged. Enabling afterwards takes process()'s live-engage path, + // which mutes for the delay-line prime and then fades in — silencing the head of the + // capture. prepare()'s allocation and transcendentals are legal here: the bake runs + // on the UI thread, never in process(). + limiter.setEnabled(true); + limiter.prepare(plan.sampleRate); + // One call: kMaxBakeFrames bounds the whole buffer well inside int, and a block + // split would change nothing (the limiter carries its state across calls). + limiter.process(left.data(), channels == 2 ? right.data() : nullptr, + static_cast(left.size())); + } + out.channelCount = channels; out.sampleRate = plan.sampleRate; - const auto lead = static_cast(plan.leadInFrames); + const auto lead = static_cast(plan.leadInFrames) + flushFrames; const auto total = static_cast(plan.totalFrames); out.interleaved.resize(total * static_cast(channels)); - // Printed here rather than left for the processor: resetAfterBake hands master gain - // back to unity, so a render that only summed voices would return every iteration - // shifted by 1/gain, and a gain dialed to silence would come back at full level. A flat - // multiply, not the processor's per-sample ramp: the gain is constant for the whole - // render, which is exactly what that ramp exists to converge to. - const auto gain = static_cast(masterGainLinear); for (std::size_t f = 0; f < total; ++f) { - out.interleaved[f * channels] = left[lead + f] * gain; - if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain; + out.interleaved[f * channels] = left[lead + f]; + if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f]; } return out; } diff --git a/src/core/instrument/bake/bake_render.h b/src/core/instrument/bake/bake_render.h index fa06458..b845e5e 100644 --- a/src/core/instrument/bake/bake_render.h +++ b/src/core/instrument/bake/bake_render.h @@ -29,11 +29,13 @@ struct BakeAudio { bool empty() const { return frameCount() == 0; } }; -// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the -// post-mixer gain the processor applies after the engine; see the gain multiply in -// bake_render.cpp for why it is printed here rather than left to the processor. The result -// is the plan's captured window: the lead-in frames are rendered and dropped. An unplayable -// sample yields an empty result. -BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear); +// Renders `plan` through `sample`'s own voice path and then the master stage the processor +// runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see +// the master stage in bake_render.cpp for why both are printed here rather than left to the +// processor. Bypassed, the limiter costs the result not one sample: `limiterEnabled` false +// is the pre-limiter render, frame for frame. The result is the plan's captured window: the +// lead-in frames are rendered and dropped. An unplayable sample yields an empty result. +BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear, + bool limiterEnabled); } // namespace reasampler::instrument::bake diff --git a/src/core/instrument/bake/bake_reset.h b/src/core/instrument/bake/bake_reset.h index df268f6..ca2159c 100644 --- a/src/core/instrument/bake/bake_reset.h +++ b/src/core/instrument/bake/bake_reset.h @@ -12,11 +12,8 @@ namespace reasampler::instrument::bake { // The two surfaces a bake resets. Master gain lives on the processor rather than in the // parameter set; it is answered here because renderBake prints it into the file (see -// bake_render.cpp's gain multiply) rather than left to the shell. -// -// Gain is the ONLY master-stage control the render prints — the limiter runs in the -// processor's block, off the bake path — so "the bake prints the gain" does not generalize -// to the master stage as a whole, and cannot be used to classify anything else on it. +// bake_render.cpp's master stage) rather than left to the shell. The limiter needs no field +// of its own: its enable rides the parameter set, and the render prints it too. struct BakeReset { map::InstrumentParams params; double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain diff --git a/src/shell/instrument/instrument_bake.cpp b/src/shell/instrument/instrument_bake.cpp index 1a0c967..ed351bd 100644 --- a/src/shell/instrument/instrument_bake.cpp +++ b/src/shell/instrument/instrument_bake.cpp @@ -146,7 +146,8 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { const instrument::bake::BakePlan& plan = *planned.plan; const instrument::bake::BakeAudio audio = - renderBake(std::move(*snapshot), plan, processor.masterGainLinear()); + renderBake(std::move(*snapshot), plan, processor.masterGainLinear(), + dialed.limiterEnabled); if (audio.empty()) return fail("the offline pass produced no audio"); // buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's diff --git a/tests/test_bake_render.cpp b/tests/test_bake_render.cpp index 915d3e4..7a382be 100644 --- a/tests/test_bake_render.cpp +++ b/tests/test_bake_render.cpp @@ -4,12 +4,13 @@ // Covers: Gate termination WITH a sustain loop active (the render must end at the window, // and the tail must be silent because the gate actually released — not merely because the // buffer ran out); Trigger termination on its own play span; channel-count preservation -// with no stereo fold; the master gain being PRINTED into the output; a lead-in rendered -// and discarded; byte-identical repeats; and the refusals (unplayable sample, empty -// window, a window past the frame ceiling). +// with no stereo fold; the master stage — gain and limiter — being PRINTED into the output; +// a lead-in rendered and discarded; byte-identical repeats; and the refusals (unplayable +// sample, empty window, a window past the frame ceiling). #include "../src/core/instrument/bake/bake_render.h" +#include "../src/core/instrument/engine/limiter.h" #include "../src/core/instrument/engine/live_params.h" #include @@ -38,6 +39,22 @@ SampleData makeSample(bool stereo, std::size_t frames = 1000) { return s; } +// A ramp, not DC: an off-by-one read, a reversed span or an output shifted in time is +// visible in it and invisible in a constant. Trigger at its own root under Varispeed reads +// at ratio exactly 1 and hits no filter, so a neutral render prints the source frame for +// frame — which is what makes this fixture an exact expectation rather than a range. +SampleData makeRamp(std::size_t frames = 4000) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) + s.frames[i] = static_cast(i) / static_cast(frames) - 0.5f; + s.sampleRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + return s; +} + // Peak magnitude of channel 0 over [from, to) output frames. double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { double peak = 0.0; @@ -50,6 +67,23 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { return peak; } +// Peak magnitude over EVERY channel — a ceiling is a property of the file, not of one leg. +double peakAll(const BakeAudio& audio) { + double peak = 0.0; + for (AudioSample v : audio.interleaved) { + const double m = std::fabs(static_cast(v)); + if (m > peak) peak = m; + } + return peak; +} + +bool sameSamples(const BakeAudio& a, const BakeAudio& b) { + if (a.interleaved.size() != b.interleaved.size() || a.interleaved.empty()) return false; + for (std::size_t i = 0; i < a.interleaved.size(); ++i) + if (a.interleaved[i] != b.interleaved[i]) return false; + return true; +} + BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff, std::int64_t leadIn = 0) { BakePlan p; @@ -64,6 +98,8 @@ BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff, } constexpr double kUnity = 1.0; +constexpr bool kNoLimiter = false; +constexpr bool kLimiter = true; } // namespace @@ -78,7 +114,7 @@ int main() { s.play.adsr.releaseFrames = 480; // 10 ms — short enough to finish inside the tail const BakePlan plan = planOf(/*total=*/9600, /*noteOn=*/0, /*noteOff=*/4800); - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(audio.frameCount() == 9600); // bounded, not a runaway CHECK(audio.channelCount == 1); @@ -96,7 +132,7 @@ int main() { s.play.trigger.lengthFraction = 0.5; // 500 source frames at unity ratio const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/100); - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(audio.frameCount() == 2000); // Still sounding past the note-off Trigger ignores… @@ -111,7 +147,7 @@ int main() { s.play.playMode = PlayMode::Trigger; const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500); - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(audio.channelCount == 2); CHECK(audio.frameCount() == 500); @@ -126,15 +162,17 @@ int main() { // --- The master gain is PRINTED into the file --------------------------------------- // The reset hands the control back at unity, so a render that summed voices alone would // shift every iteration by 1/gain — and a gain dialed to silence would come back loud. + // Every render here is limiter-bypassed, so the exact scaling below is also the guard + // that the limiter never engages on its own: +4x over this DC is far past the ceiling. { SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000); s.play.playMode = PlayMode::Trigger; const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500); - const BakeAudio unity = renderBake(s, plan, kUnity); - const BakeAudio quiet = renderBake(s, plan, 0.25); - const BakeAudio loud = renderBake(s, plan, 4.0); - const BakeAudio silent = renderBake(s, plan, 0.0); + const BakeAudio unity = renderBake(s, plan, kUnity, kNoLimiter); + const BakeAudio quiet = renderBake(s, plan, 0.25, kNoLimiter); + const BakeAudio loud = renderBake(s, plan, 4.0, kNoLimiter); + const BakeAudio silent = renderBake(s, plan, 0.0, kNoLimiter); CHECK(unity.interleaved.size() == quiet.interleaved.size()); bool scaled = !unity.interleaved.empty(); @@ -150,6 +188,76 @@ int main() { CHECK(peakAt(unity, 0, 500) > 0.4); } + // --- The limiter is PRINTED when engaged: the file holds the ceiling ----------------- + // DC at 0.5 through +4x of gain is a constant 2.0 — over twice the ceiling for every + // frame asserted, not a transient that a quiet fixture would let slide. + { + const double ceiling = instrument::engine::limiterCeilingLinear(); + SampleData s = makeSample(/*stereo=*/false, /*frames=*/4000); + s.play.playMode = PlayMode::Trigger; + const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/2000); + + const BakeAudio unlimited = renderBake(s, plan, 4.0, kNoLimiter); + const BakeAudio limited = renderBake(s, plan, 4.0, kLimiter); + + CHECK(unlimited.frameCount() == 2000); + CHECK(limited.frameCount() == 2000); // the lookahead does not shorten the file + // The fixture really drives it: bypassed, the same render sits at twice the ceiling. + CHECK(peakAt(unlimited, 0, 2000) > ceiling * 1.9); + // …and engaged, not one printed sample is over it. + CHECK(peakAll(limited) <= ceiling + 1e-6); + // Held AT the ceiling once settled, not ducked to silence — a limiter that muted + // everything would pass the bound above. + CHECK(peakAt(limited, 1000, 2000) > ceiling * 0.9); + + // Repeat bakes are bit-identical with the limiter engaged too: the render builds its + // own Limiter, and prepare() zeroes every one of its state fields. + CHECK(sameSamples(limited, renderBake(s, plan, 4.0, kLimiter))); + } + + // --- Engaged but below the ceiling: the render is the bypassed one, frame for frame --- + // The limiter delays its output by its lookahead, so this is where a missing or wrong + // compensation shows: an uncompensated render would print ~96 frames of silence at the + // head and shift the whole capture late. Nothing here reaches the ceiling, so the + // limiter's gain is exactly 1 at every sample and the two renders must agree bit for bit + // — which also pins that the engaged render skips the transition mute (it would fade the + // first 10 ms up from silence). + { + SampleData s = makeRamp(); + const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); + + const BakeAudio bypassed = renderBake(s, plan, kUnity, kNoLimiter); + const BakeAudio engaged = renderBake(s, plan, kUnity, kLimiter); + CHECK(sameSamples(bypassed, engaged)); + // And it is the SOURCE they both agree on, so this cannot pass by both being wrong + // the same way. + bool identity = engaged.frameCount() == 1000; + for (std::size_t f = 0; identity && f < 1000; ++f) + identity = (engaged.interleaved[f] == s.frames[f]); + CHECK(identity); + } + + // --- The limiter is stereo-LINKED, and both legs are printed ------------------------- + { + const double ceiling = instrument::engine::limiterCeilingLinear(); + SampleData s = makeSample(/*stereo=*/true, /*frames=*/4000); // L 0.5, R -0.25 + s.play.playMode = PlayMode::Trigger; + const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/2000); + + const BakeAudio limited = renderBake(s, plan, 4.0, kLimiter); + CHECK(limited.channelCount == 2); + CHECK(peakAll(limited) <= ceiling + 1e-6); + // The quieter leg is limited by the louder one's peak rather than by its own, so the + // source's exact 2:1 level ratio survives — one gain, not two. Both are exact: the + // gain multiplies 2.0 and 1.0, and doubling a float is exact. + bool linked = limited.frameCount() == 2000; + for (std::size_t f = 1000; linked && f < 2000; ++f) + linked = (limited.interleaved[f * 2] == -2.f * limited.interleaved[f * 2 + 1]); + CHECK(linked); + // Non-vacuous: the right leg is really sounding, so the ratio is not 0 == -0. + CHECK(std::fabs(static_cast(limited.interleaved[3001])) > 0.1); + } + // --- A lead-in is rendered and then discarded --------------------------------------- // A positive start offset trims the note's head: the frames before the window must be // produced (so the envelope really is mid-flight when the file opens) and dropped. @@ -160,15 +268,20 @@ int main() { const BakePlan trimmed = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/2000, /*leadIn=*/1000); - const BakeAudio audio = renderBake(s, trimmed, kUnity); + const BakeAudio audio = renderBake(s, trimmed, kUnity, kNoLimiter); CHECK(audio.frameCount() == 1000); // the FILE is the window, not the render // Frame 0 of the file is frame 1000 of the render — the attack's end, not its // start. A clamped-away lead-in would put the attack's silent onset here instead. - const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity); + const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity, + kNoLimiter); CHECK(peakAt(audio, 0, 1) > peakAt(whole, 0, 1)); CHECK(std::fabs(static_cast(audio.interleaved[0]) - static_cast(whole.interleaved[1000])) < 1e-6); + + // The lead-in and the lookahead are two independent offsets into one buffer: with the + // limiter engaged under the ceiling, the trimmed window is still the same frames. + CHECK(sameSamples(audio, renderBake(s, trimmed, kUnity, kLimiter))); } // --- Bit-identical repeats --------------------------------------------------------- @@ -179,15 +292,12 @@ int main() { s.play.adsr.releaseFrames = 211; const BakePlan plan = planOf(/*total=*/4096, /*noteOn=*/13, /*noteOff=*/2731); - const BakeAudio a = renderBake(s, plan, kUnity); - const BakeAudio b = renderBake(s, plan, kUnity); + const BakeAudio a = renderBake(s, plan, kUnity, kNoLimiter); + const BakeAudio b = renderBake(s, plan, kUnity, kNoLimiter); CHECK(a.interleaved.size() == b.interleaved.size()); CHECK(!a.interleaved.empty()); - bool identical = a.interleaved.size() == b.interleaved.size(); - for (std::size_t i = 0; identical && i < a.interleaved.size(); ++i) - identical = (a.interleaved[i] == b.interleaved[i]); - CHECK(identical); + CHECK(sameSamples(a, b)); // The window opened before the note: those frames must be untouched silence. CHECK(peakAt(a, 0, 13) == 0.0); CHECK(peakAt(a, 200, 400) > 0.0); @@ -214,7 +324,7 @@ int main() { } const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(s.live == &block); // the caller's own snapshot was not detached // At frame 100 the dialed instant attack is at full level; the published 900-frame @@ -224,11 +334,8 @@ int main() { // And it matches a render from a block-free copy exactly. SampleData detached = s; detached.live = nullptr; - const BakeAudio reference = renderBake(detached, plan, kUnity); - bool identical = audio.interleaved.size() == reference.interleaved.size(); - for (std::size_t i = 0; identical && i < audio.interleaved.size(); ++i) - identical = (audio.interleaved[i] == reference.interleaved[i]); - CHECK(identical); + const BakeAudio reference = renderBake(detached, plan, kUnity, kNoLimiter); + CHECK(sameSamples(audio, reference)); } // --- Regression baseline: the neutral render is the source, sample for sample -------- @@ -239,20 +346,12 @@ int main() { // guarantee of eval() at an arbitrary velocity. An added stage, a moved default, or a lost // early-out anywhere in the chain moves a sample here. { - SampleData s; - s.frames.resize(4000); - // A ramp, not DC: an off-by-one read or a reversed span is invisible in a constant. - for (std::size_t i = 0; i < s.frames.size(); ++i) - s.frames[i] = static_cast(i) / 4000.f - 0.5f; - s.sampleRate = kRate; - s.rootNote = 60; - s.play.playMode = PlayMode::Trigger; - s.play.pitchEngine = PitchEngine::Varispeed; + SampleData s = makeRamp(); // Shorter than the play span, so the window closes before any note-end shaping. const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); CHECK(s.velocityCurve.eval(100.0) == 1.0); // names the real cause if this ever fails - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(audio.channelCount == 1); CHECK(audio.frameCount() == 1000); @@ -262,8 +361,8 @@ int main() { CHECK(identity); // …and the gain rides that as an exact scalar, which is the only other thing the - // render is permitted to do to the signal. - const BakeAudio halved = renderBake(s, plan, 0.5); + // render is permitted to do to the signal with the limiter bypassed. + const BakeAudio halved = renderBake(s, plan, 0.5, kNoLimiter); bool scaled = halved.frameCount() == 1000; for (std::size_t f = 0; scaled && f < 1000; ++f) scaled = (halved.interleaved[f] == s.frames[f] * 0.5f); @@ -274,20 +373,21 @@ int main() { { SampleData empty; // nothing decoded empty.sampleRate = kRate; - CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity).empty()); + CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity, kNoLimiter).empty()); SampleData s = makeSample(false); - CHECK(renderBake(s, planOf(0, 0, 0), kUnity).empty()); + CHECK(renderBake(s, planOf(0, 0, 0), kUnity, kNoLimiter).empty()); // The ceiling planBake enforces is re-checked here: a hand-built plan must not be // able to walk the render into an allocation it cannot hold. - CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity).empty()); - CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity).empty()); + CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity, kNoLimiter) + .empty()); + CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity, kNoLimiter).empty()); // A hand-built plan can carry a lead-in near the int64 ceiling; the guard must trip // on that field alone rather than signed-overflowing inside renderFrames()'s sum. CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/std::numeric_limits::max() - 10), - kUnity) + kUnity, kNoLimiter) .empty()); } diff --git a/tests/test_bake_window.cpp b/tests/test_bake_window.cpp index 8e8945c..ee5671c 100644 --- a/tests/test_bake_window.cpp +++ b/tests/test_bake_window.cpp @@ -26,6 +26,9 @@ constexpr int kRate = 48000; constexpr double kBpm = 120.0; // a quarter note is 0.5 s == 24000 frames constexpr double kUnity = 1.0; constexpr double kSilence = 1e-6; +// The derived window is a property of the voice chain, not of the master stage: every +// measurement here reads the render with the limiter bypassed. +constexpr bool kNoLimiter = false; // The declick pad every derived window carries. Read off the engine's own constants, so a // retuned ramp moves this file's expectations with it rather than against them. @@ -97,7 +100,7 @@ BakeAudio bakeWith(const SampleData& s, double extraMs, Division hold = oneBar() int velocity = 100) { const std::optional plan = planOf(derivedProgram(s, extraMs, hold, velocity)); if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; } - return renderBake(s, *plan, kUnity); + return renderBake(s, *plan, kUnity, kNoLimiter); } std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) { @@ -116,7 +119,7 @@ std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) { p.end = EndOffset(offsetFromMs(200.0)); const std::optional plan = planOf(p); if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; } - return lastSoundingFrame(renderBake(s, *plan, kUnity)); + return lastSoundingFrame(renderBake(s, *plan, kUnity, kNoLimiter)); } // The last frame of the file, which is where a hard cut shows up. @@ -190,7 +193,7 @@ int main() { CHECK(plan.has_value()); if (plan) { CHECK(plan->totalFrames == kFrames + kPad); - const BakeAudio whole = renderBake(s, *plan, kUnity); + const BakeAudio whole = renderBake(s, *plan, kUnity, kNoLimiter); // Full level across the two seconds the saturated rung used to cut, and the file // still ends on the declick ramp rather than on a hard edge. CHECK(peakAt(whole, kSlowRate * 48, kFrames) > 0.4); From a3698972db11ffd9b4d712a79fd7e64f005795b2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 21:24:00 -0400 Subject: [PATCH 55/56] =?UTF-8?q?docs:=20close=20three=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20the=20build-shape=20index,=20a=20redundant=20header?= =?UTF-8?q?=20restatement,=20and=20the=20CMake=20guard=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the product doc's track count and list for T3; trim the header's pre-limiter restatement now that bake_render.cpp carries it; add limiter to the extension's not-linked enumeration. --- docs/product/instrument-control-surface.md | 3 ++- src/app/CMakeLists.txt | 6 +++--- src/core/instrument/bake/bake_render.h | 5 ++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index c5f3f73..26cf1b8 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -1565,9 +1565,10 @@ Sequenced into `docs/PLAN.md` as **Phase Γ** (worktree slug prefix `pg-`), **fo T1 pitch-rate-deck ................. item A (params + engine + deck descriptor) T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry + the chrome-row loop enable) -Γ-W3 The reflow, and the bake correction [2 tracks] +Γ-W3 The reflow, and the bake correction [3 tracks] T1 deck-reflow ..................... item B's ARRANGEMENT half + C's UI half T2 bake-reset-amendment ............ the Phase Ξ correction Γ owns (§3.4) + T3 bake-prints-limiter ............. prints the limiter through the bake's master stage (§3.4) Γ-W4 VST3 parameters [1 track] T1 vst3-parameter-set .............. Ruling 1 (parameter-automation.md §§6-10) ``` diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 75ca5b7..0b72280 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -52,9 +52,9 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp ) target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name) -# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument -# renders its own bake in its own process, which is what keeps the extension's link graph -# free of the voice engine — a link edge to it here means the design drifted. +# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The +# instrument renders its own bake in its own process, which is what keeps the extension's +# link graph free of the voice engine — a link edge to it here means the design drifted. target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both diff --git a/src/core/instrument/bake/bake_render.h b/src/core/instrument/bake/bake_render.h index b845e5e..c9805fe 100644 --- a/src/core/instrument/bake/bake_render.h +++ b/src/core/instrument/bake/bake_render.h @@ -31,9 +31,8 @@ struct BakeAudio { // Renders `plan` through `sample`'s own voice path and then the master stage the processor // runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see -// the master stage in bake_render.cpp for why both are printed here rather than left to the -// processor. Bypassed, the limiter costs the result not one sample: `limiterEnabled` false -// is the pre-limiter render, frame for frame. The result is the plan's captured window: the +// bake_render.cpp for why both print here rather than in the processor. `limiterEnabled` +// false yields the pre-limiter render. The result is the plan's captured window: the // lead-in frames are rendered and dropped. An unplayable sample yields an empty result. BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear, bool limiterEnabled); From 53e7d35178aede22b3a57acbc0001e7180ff63de Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 21:32:04 -0400 Subject: [PATCH 56/56] docs: record the bake-prints-limiter track and close Phase Gamma --- docs/COMPLETED.md | 45 +++++++++++++++++++++++++++++++ docs/PLAN.md | 67 ++++++++++++++--------------------------------- 2 files changed, 64 insertions(+), 48 deletions(-) diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index fba8466..b57fbee 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -1249,6 +1249,51 @@ limiter. Until that lands this is a recorded, known limitation, not an oversight **Neither track has been verified in a running DAW; both are asserted in CTest only.** +### Γ-W3-T3 — bake-prints-limiter + +The bake's master stage now prints the limiter as well as the gain multiply, closing the +audible gap Γ-W3-T2 recorded and left open: a capture baked with the limiter engaged +returns limited audio rather than unlimited audio. `renderBake` +(`core/instrument/bake/bake_render.cpp`) instantiates its own `Limiter` — the same +bake-only-engine precedent its `VoiceEngine` already set — never linked into +`reaper_reasampler`; `src/app/CMakeLists.txt`'s exclusion comment names the limiter +alongside `sampler_core`/`pitch_shift`/the filter, so the extension's link graph gains no +new edge. + +**The lookahead needed compensation, which was open at spec time.** The limiter delays its +output by `kLimiterLookaheadSeconds` (0.002 s = 96 samples at 48 kHz), so the render's +buffers carry `renderFrames() + flushFrames` frames, the extra span fed silence rather than +more rendered audio, and the capture is read out starting at `leadInFrames + flushFrames` +instead of `leadInFrames` alone — the file is the same frames it would be bypassed, not the +same capture shifted 2 ms late. + +**A sequencing trap, recorded inline at the call site.** `Limiter::prepare()` ends by +calling `reset()`, which snaps to whatever the enable target already is, so the render calls +`setEnabled(true)` before `prepare()`. Reversed, the limiter would take its live-engage path +instead — `process()`'s prime-then-fade — muting and then fading in the first ~12 ms of +every capture (the delay-line prime plus `kLimiterMuteSeconds`, per `limiter.h`). + +**The bypassed path is unchanged.** `test_bake_render.cpp` asserts bypass ≡ engaged +bit-for-bit under the ceiling (a ramp fixture at unity gain, verified against the source +sample for sample too) and separately confirms the printed-limiter path holds the ceiling +and stays stereo-linked under a DC fixture driven well past it; repeat bakes stay +bit-identical with the limiter engaged as well, since `renderBake` builds a fresh `Limiter` +per call and `prepare()` zeroes every one of its state fields. + +**Double-limiting is a named boundary, not a defect** (`bake/CLAUDE.md`): a printed capture +replayed through an engaged limiter is limited twice. The post-bake reset ordinarily +prevents it, since `limiterEnabled` is not on the survive list. + +**`bake/CLAUDE.md`'s invariant is corrected alongside the code.** The text Γ-W3-T2 left in +place ("the limiter is not printed") is replaced with "the whole chain is printed — voice, +master gain, then the limiter, in the processor's own order," and the track's three +`[propose at review]` open questions are answered inline in the same section: the bake +instantiates its own `Limiter`; the lookahead does need in-render compensation, exactly the +above; and yes, this track also corrects the invariant text rather than leaving it to a +later pass. + +**Not verified in a running DAW — CTest-asserted only.** + ### Γ-W4-T1 — vst3-parameter-set The instrument now reports its automatable parameters to the host: 44 of 44 issue, under a diff --git a/docs/PLAN.md b/docs/PLAN.md index fc5ce24..dcd3219 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -597,6 +597,9 @@ system's is in **`docs/product/parameter-automation.md` §§6–10**. Read §1.2 table) and §7 (collisions) before dispatching any track here — every number in this phase is derived there, and `docs/TODO.md`'s old deck-rework geometry is superseded. +**All four waves have landed — Phase Γ is complete.** W1 through W4 each carry their own +landed note below; see `docs/COMPLETED.md` for every track's full narrative. + **Fork state — SEVEN ruled, ONE OF THEM LATER REVERSED, NONE OPEN.** Indexed at spec §8, folded into the tracks below: - **Γ-F1** — `kEditorMinHeight` stays **680**. @@ -1013,9 +1016,9 @@ than re-derived. **T2 depended on Phase Ξ** — `Ξ-W2-T1 (resample-bake-chain) first, the phase's only external gate — see `docs/COMPLETED.md` for the full narrative of each landed track. -**Two tracks have landed** — Γ-W3-T1 (`deck-reflow`) and Γ-W3-T2 (`bake-reset-amendment`) — see -`docs/COMPLETED.md` for the full narrative of each. **A third track is now open and has not -run:** Γ-W3-T3 (`bake-prints-limiter`), added below on Daniel's ruling of 2026-08-02. +**All three tracks have landed** — Γ-W3-T1 (`deck-reflow`), Γ-W3-T2 +(`bake-reset-amendment`), and Γ-W3-T3 (`bake-prints-limiter`) — see `docs/COMPLETED.md` for +the full narrative of each. **None of the three tracks takes a payload rung.** T1 was layout only; T2 changed a reset list, not a format; T3 changes what the render's audio contains, not what is stored. @@ -1066,56 +1069,24 @@ field-by-field assertions over two independently-dialled fixtures, never struct mutation-verified spot-check sweep confirming both fixtures actually moved every asserted field off its default. -**One invariant correction:** `bake/CLAUDE.md` had claimed the whole signal chain prints, master -gain included. It doesn't — the render's gain multiply is the only master-stage value it -prints; the limiter runs in the processor's block, off the bake path entirely. - -**Outstanding, not closed by this track.** A capture baked with the limiter engaged comes back -unlimited — a real audible gap, and Daniel has ruled that a future track will change the bake to -print the limiter. **That track is now Γ-W3-T3, below.** +**One invariant correction, at the time this track landed:** `bake/CLAUDE.md` had claimed the +whole signal chain prints, master gain included. It didn't yet — the render's gain multiply was +the only master-stage value it printed; the limiter ran in the processor's block, off the bake +path entirely, so a capture baked with the limiter engaged came back unlimited. **Γ-W3-T3 +(below) has since closed that gap** — the limiter is printed too now, and `bake/CLAUDE.md`'s +invariant text is corrected again to match. **Neither track has been verified in a running DAW; both are asserted in CTest only.** #### Γ-W3-T3 — `bake-prints-limiter` -**Not started. Opened by Daniel's ruling, 2026-08-02.** - -**Goal.** Print the limiter through the bake's master stage, so a capture baked with the -limiter engaged returns limited audio rather than unlimited audio. - -**Why this exists.** Γ-W3-T2's own finding disproved the premise -`docs/product/instrument-control-surface.md` §3.4's reset classification rested on: -`renderBake` (`core/instrument/bake/bake_render.cpp`) prints only a flat master-gain multiply, -and the limiter (`core/instrument/engine/limiter`) runs in the processor's `process()` block, -off the bake path entirely. Until this track lands, this is a recorded, known limitation — see -`docs/COMPLETED.md`'s Γ-W3-T2 entry — not an oversight. - -**Consolidates:** nothing from the seventeen. A correction, on the same footing as Γ-W3-T2 (see -"Work in this plan that is not one of the seventeen"). - -**Spec:** none yet written. This ruling postdates §3.4 and has no product-doc section of its -own; §3.4 is superseded on this one point, which a future scoping pass of this track should -correct there as well as here. - -**Surface boundary — likely, not yet confirmed against a full scoping pass:** owns -`core/instrument/bake/bake_render` (the gain-multiply step, extended to also run the signal -through a limiter), consuming `core/instrument/engine/limiter` — not owned, not modified. Does -not own the processor's live block, the limiter DSP itself, the parameter surface, or -`bake_reset` (the limiter-enable reset classification is already Γ-W3-T2's, landed). - -**Open questions — none of this is ruled yet, only the goal is:** -- **[propose at review]** Whether the bake instantiates its own `Limiter` — mirroring - `renderBake`'s existing bake-only `VoiceEngine`, off the audio thread, never linked into - `reaper_reasampler` — or reaches the limiter's settled behavior some other way. The - bake-only-engine precedent (`bake/CLAUDE.md`) argues for the former. -- **[propose at review]** Whether the limiter's lookahead needs any accommodation in an - offline, non-realtime render — the processor's `getLatencySamples()` PDC report exists for - the live block, and a bake is not on that clock, so this may be a non-issue; it has not been - checked. -- **[propose at review]** Whether this track also corrects `bake/CLAUDE.md`'s invariant text - ("the limiter is not [printed]") alongside the code, once scoped in full. -- **No [Daniel] question on the goal itself** — the ruling above is the goal; what is open is - the mechanism, not whether to do it. +**Landed** — see `docs/COMPLETED.md` for the full narrative. The bake's master stage now +prints the limiter as well as the gain multiply: `renderBake` instantiates its own `Limiter` +(the bake-only-engine precedent, never linked into `reaper_reasampler`), the lookahead is +compensated inside the render (an extra `flushFrames` of silence past the window, the capture +read out at `leadInFrames + flushFrames`), and `bake/CLAUDE.md`'s invariant text is corrected +alongside the code. All three of the track's `[propose at review]` open questions are +answered in that entry. ---