Merge Γ-W3-T1: two categorical deck rows and a double-height MASTER bus deck, an exact filter tie-line at a 1028 row block, and the instrument reload decoupled from VST3 activation

This commit is contained in:
2026-08-02 13:19:58 -04:00
45 changed files with 2771 additions and 950 deletions
+40 -1
View File
@@ -6,6 +6,42 @@ original Goal, Verify, and checklist points with boxes marked done.
This file holds the current (1.x) cycle's landed milestones only. For all This file holds the current (1.x) cycle's landed milestones only. For all
pre-1.0 (version-0) history, see `docs/ARCHIVE.md`. 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.
**`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) ### Comment-reduction pass (tree-wide, twelve parallel tracks)
Cut source comment volume tree-wide: 209 files changed, net **6,493** lines. Cut source comment volume tree-wide: 209 files changed, net **6,493** lines.
@@ -970,7 +1006,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 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 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`, 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 the group id — `DeckRow { Sound, Contour, Spanning }` plus `deckRowFor(DeckGroupId)`, an
exhaustive switch (Sound = PITCH/RATE, FILTER, VELOCITY, VOICE; Contour = PITCH ENV, FILTER 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 ENV, AMP ENVELOPE; Spanning = MASTER) so a future group left unclassified is a compile
+44 -34
View File
@@ -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 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 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 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 restart expensive *here* was self-inflicted (`setActive(true)` called `reloadInstrument`), so
the cost is ours to reduce and the reduction is filed in `docs/TODO.md` rather than designed the cost was ours to reduce. **That reduction landed in Γ-W3** — see `docs/COMPLETED.md`.
around.
**Γ-F3 was subsequently REVERSED and a seventh fork opened AND CLOSED, all by Daniel's later **Γ-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 rulings of 2026-08-01.** Γ-F3 (*"the stage-time ceiling stays 2.0 s"*) is replaced by *"extend
@@ -613,7 +612,9 @@ folded into the tracks below:
- **Γ-F4** — there **is** an explicit loop enable, and it lives on the **chrome row**, not - **Γ-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. 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 - **Γ-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)` - **Γ-F6****ship dynamic latency as ruled.** The `restartComponent(kLatencyChanged)`
deactivate/reactivate the SDK mandates is accepted: *"the limiter will either be on or off 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 on its instance, toggling during playback is not a use case."* No constant-latency
@@ -625,7 +626,7 @@ folded into the tracks below:
table freezes. The editor's visual rows after the reflow were the rejected alternative. 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 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 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 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. 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: Signal flow is the axis that does not move. Full argument and the accepted residual cost:
@@ -649,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 **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; 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`) 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 is the normal contract, not an exotic one. What made the cycle expensive **here** was entirely
our own doing: `ReaSamplerProcessor::setActive(true)` calls `reloadInstrument()` — a bridge our own doing: `ReaSamplerProcessor::setActive(true)` called `reloadInstrument()` — a bridge
read plus a full WAV re-decode (`reasampler_processor.cpp:89-97`) — where a typical plugin's read plus a full WAV re-decode — where a typical plugin's `setActive` only allocates and frees
`setActive` only allocates and frees buffers, and the deactivate side's freeing of buffers. **The cost was therefore ours to reduce, and the reduction was decoupling reload from
`live_`/`draining_`/graveyard (`:98-107`) is likewise our own design. **The cost is therefore activation — not abandoning dynamic latency.** **Landed in Γ-W3**: the activate branch
ours to reduce if it ever matters, and the reduction is decoupling reload from activation — (`reasampler_processor.cpp:86-132`) now resumes the voice state around a parked `SampleData`
not abandoning dynamic latency.** That improvement is filed as a `docs/TODO.md` entry with its and reloads only when there is nothing to resume from or a bank refresh moved what the park was
trigger condition; it is not scheduled in this phase. 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 **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 correction.** This plan previously asserted that Γ must run before Ξ-W2 and called it *"a
@@ -693,7 +695,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 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, 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 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. 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 2. **`preserve-time-stretch` moved W4 → W1-T5.** It is the longest pole in the phase and has
@@ -825,10 +827,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 - **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 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. 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 - **The window floor is 1198 × 680 and must not exceed 1280 × 720.** **Γ-W1-T4 set it at
wave 1; no other track in the phase may move it**, and from that point every track is 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. 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 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 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) additions are a **separate purse** (they are paid for out of the title slot, not the floor)
@@ -900,7 +905,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), 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 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 Γ-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` #### Γ-W1-T5 — `preserve-time-stretch`
@@ -1063,20 +1070,24 @@ waveform band.
divided equally among the row's (n1) gutters, integer residue to the leftmost; divided equally among the row's (n1) gutters, integer residue to the leftmost;
**no gutter narrower than `kDeckGroupGap` (12)**. **Decks are never stretched.** MASTER is **no gutter narrower than `kDeckGroupGap` (12)**. **Decks are never stretched.** MASTER is
not part of either row's justification. 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, - **Row block = 1028 px at the floor** (widened from the originally specified 1020 — Daniel,
at which width **FILTER's right edge and FILTER ENV's right edge both land on x = 636**. 2026-08-02), giving row 1 gutters 16/16/16 and row 2 gutters 76/76, at which width
That tie-line, row 2's equal gutters, and row 1's minimum gutter being exactly **FILTER's right edge and FILTER ENV's right edge both land on x = 640**. The tie-line and
`kDeckGroupGap` all hold at 1020 and only at 1020 — **this is why the floor is 1190 and not both rows' equal gutters hold at 1028 because each row's slack divides by its gutter count
1186.** Above the floor the tie-line drifts and that is accepted (spec §1.3). with no residue. **This is why the floor is 1198 and not 1190.** The originally specified
- **The floor is already 1190 × 680 and the bands are already 216 / 358** — Γ-W1-T4 landed all 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 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 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 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. 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 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 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. 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 **State this ledger where a future reader will hit it** — spec §1.6 is its home, and a
@@ -1109,10 +1120,10 @@ waveform band.
natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at natural width is mode-stable at 876 because the reserve slots hold FILTER ENV and AMP at
312 in both modes — assert it). 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 - 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. is exact (both edges at x = 640) and BOTH rows' gutters are equal.
- **Row 1's natural width is 980 and fits the 1020 block** — the fit Γ-W1-T4 could not yet - **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. 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. 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 - 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 interim, now reached by construction**: `deckRowCount` at and above the floor is 2 because the
@@ -1410,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 nothing new is persisted and therefore it should not; if the `setState` verification says
otherwise, it takes the reserved rung and says so. 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 - **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` limiter enable made automatable (§3.8 — its one reopening condition was the reload/activation
reload/activation decoupling, and the answer is to do that first, not to re-litigate the decoupling, which landed in Γ-W3, so the condition is discharged rather than pending).
classification).
--- ---
@@ -3633,7 +3643,7 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends
+ 10 s ceiling + AHDSR schematic axis [Ruling 2] + 10 s ceiling + AHDSR schematic axis [Ruling 2]
T2 master-bus-audio ........... limiter + meter ballistics + dynamic PDC [rung 1] T2 master-bus-audio ........... limiter + meter ballistics + dynamic PDC [rung 1]
T3 contour-trace-curves ....... staged traces draw curved, knot on its trace 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] T5 preserve-time-stretch ...... real stretcher [measure-and-report gate]
T6 exhaustive-switch gate on pure libraries ... /we4062, -Werror=switch on T6 exhaustive-switch gate on pure libraries ... /we4062, -Werror=switch on
pure libraries [no PLAN entry — see COMPLETED.md] pure libraries [no PLAN entry — see COMPLETED.md]
+25 -47
View File
@@ -240,57 +240,35 @@ 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. **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 **Context (Γ-W3, meter re-review).** `reasampler_editor.h` stands at **564 lines** against the
first latency reporting: `getLatencySamples()` returns 0 with the limiter off and the lookahead ~600-line ceiling — 36 lines of margin — and it keeps growing because every new surface on the
with it on, and the toggle calls `IComponentHandler::restartComponent(kLatencyChanged)`. The Sample face adds its transient state there. The obvious seam is the drag state machine: `drag_`
vendored SDK defines that flag as a host **deactivate/reactivate** plus the per-gesture anchors it is read against.
(`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."*
**The wart — and it is ours, not the SDK's.** `ReaSamplerProcessor::setActive(true)` calls **Why it was declined rather than taken.** `drag_` has **42 references across 13 shell TUs**
`reloadInstrument()` (`src/shell/instrument/reasampler_processor.cpp:89-97`) — a bridge read (measured over `src/shell/instrument/*.cpp`; the declaration in the header is additional). Of
plus a **full WAV re-decode** plus a fresh engine. `setActive(false)` frees `live_`, the six input TUs, three write it and branch on it (`editor_input`, `_waveform`, `_curve`) and
`draining_` and the graveyard (`:98-107`). So every host-driven activation cycle — a three only write it (`_chrome`, `_browse`, `_deck`) — which is what makes the anchor invariant
latency-change restart, an offline-render bracket, any host that deactivates around transport observed rather than enforced. Extracting it is a real refactor of the editor's input half, not
state — pays a disk read and a decode that nothing about activation requires. **Activation a header move — and doing it inside a wave whose subject is the MASTER deck would have put an
currently means two things at once**: "the audio thread may run" and "the decoded `SampleData` unrelated high-blast-radius change in the same diff. Declining was right; leaving it unrecorded
is (re)built." Dynamic latency is simply the first feature that makes the cycle was not.
user-triggerable.
**Intended fix.** Separate the two lifetimes: keep the decoded `SampleData` alive across a **The shape a fix would take.** A `DragState` type owning the kind plus its anchor payload,
deactivate and rebuild only the voice state on reactivate. The mechanism already exists in this with the input TUs mutating it through named transitions rather than assigning `drag_` and its
file — `rebuildVoiceEngine` performs exactly that shape (drain-slot swap around the anchors independently — which is also what would let the invariant "an anchor is only readable
already-decoded `SampleData`, no bank re-read, no WAV re-decode) for voice-count and voice-mode while its own `DragKind` is in flight" be enforced rather than observed. `editor_interaction.h`
edits. This is a lifetime split, not a new mechanism. already holds the `DragKind` vocabulary and is the natural home.
**The constraint the fix MUST handle.** The deactivate's destruction is deliberate and its **Priority / risk.** Low, but the margin is the clock: the next surface that adds two members to
reason is documented at the call site: a surviving `live_` would be displaced into the drain the header takes it over the ceiling, and at that point the seam gets chosen under time pressure
slot on reactivate and *"resurrect stale sustained voices as ghosts."* **Voice state must still by whoever is unlucky. Take it before that, not after.
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; deferred by ruling. Nothing is incorrect today, only wasteful, and **Done looks like.** `reasampler_editor.h` is back under the ceiling with room; no TU assigns
Daniel has explicitly accepted the user-visible consequence (held notes cut on a limiter `drag_` and an anchor as two independent writes; and the transitions are named where the
toggle). **Trigger conditions — revisit when any one of these holds:** (a) a second `DragKind` catalogue already lives.
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.
## `Sample::sourceMode` has no value meaning "produced by the instrument" ## `Sample::sourceMode` has no value meaning "produced by the instrument"
@@ -365,7 +343,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 **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 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 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.** `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 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. them; it was measured against a layout that no longer exists.
+112 -78
View File
@@ -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 | **PITCH/RATE | FILTER | VELOCITY | VOICE** (sound). Row 2 is **PITCH ENV | FILTER ENV |
AMP ENVELOPE** (contour). **MASTER spans both rows on the far right.** AMP ENVELOPE** (contour). **MASTER spans both rows on the far right.**
- **The arithmetic closes, with room.** Minimum/default window goes **980 × 680 → - **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 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. 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, - **The two rows align exactly, not nearly.** At the floor width the row block is 1028 px,
and at that width row 2's two gutters are equal (72 px each) *and* FILTER's right edge and at that width BOTH rows' gutters are equal (16/16/16 and 76/76) *and* FILTER's right
lands exactly on FILTER ENV's right edge (both at x = 636). That is the aesthetic tie 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. 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 - **PITCH becomes PITCH/RATE**: three knobs (`Key Trk | Rate | Pitch`) under the existing
Varisp|Presrv toggle. Rate 50200 % exponential, Pitch ±24 st. Varisp|Presrv toggle. Rate 50200 % exponential, Pitch ±24 st.
- **MASTER becomes the post-voice-mixer deck it was always reserved to be**: limiter - **MASTER becomes the post-voice-mixer deck it was always reserved to be**: limiter
@@ -49,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 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 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 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 gesture. The cycle used to be expensive only because **our** `setActive` re-decoded the WAV;
the WAV, which is a latent improvement filed in `docs/TODO.md`, not a design constraint. Γ-W3 decoupled the two lifetimes, so it no longer does. §3.1.1.
§3.1.1.
- **The cortex limiter does not clear the bar** — §3.5. Read it, take nothing. - **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 - **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 sits under it. The core finding behind the re-approach: three identical bars draw a
@@ -117,30 +117,31 @@ and `knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle)`.
| | Natural content | Gutters at floor | **Row width** | | | Natural content | Gutters at floor | **Row width** |
|---|---|---|---| |---|---|---|---|
| Row 1 | 192 + 432 + 192 + 164 = **980** | 12 + 14 + 14 = 40 | **1020** | | Row 1 | 192 + 432 + 192 + 164 = **980** | 16 + 16 + 16 = 48 | **1028** |
| Row 2 | 252 + 312 + 312 = **876** | 72 + 72 = 144 | **1020** | | Row 2 | 252 + 312 + 312 = **876** | 76 + 76 = 152 | **1028** |
**Window floor.** **Window floor.**
``` ```
deck band width = 1020 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1174 deck band width = 1028 (row block) + 12 (kDeckGroupGap) + 142 (MASTER) = 1182
kEditorMinWidth = 1174 + 2·kPad(8) = 1190 kEditorMinWidth = 1182 + 2·kPad(8) = 1198
kEditorMinHeight = 680 (unchanged) kEditorMinHeight = 680 (unchanged)
deck band height = 2·kDeckGroupH(104) + kDeckRowGap(8) = 216 (was 328) 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) 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 > **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 > **Γ-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, > 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 > 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 > 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 > 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. `docs/PLAN.md` at Γ-W1-T4 > `Band|Notch` caption move, = 980. Row 2's 876 already fits. Γ-W1-T4 set the block at 1020
> states the seam and the interim layout in full. > 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: Three corrections to the arithmetic in the brief, all small and all in our favour:
@@ -148,9 +149,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 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 it banks 94 px. MASTER may grow to **236** before the ceiling binds; that is the
meter's growth room, not a target. 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 2. **The row block is 1028, not 1016 and not the 1020 originally specified.** 1020 was
two rows align exactly rather than 2 px apart — §1.3. It is the single cheapest chosen to make the two rows align exactly; it does not — 1020 leaves row 1 a 40 px slack
aesthetic purchase in the phase. 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 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 `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. the binding side and its knob row is nearly empty. Leave it.
@@ -182,11 +187,24 @@ approximate:
share a right edge. share a right edge.
2. **The filter tie-line.** At the floor width the two rows' filter groups end on the same 2. **The filter tie-line.** At the floor width the two rows' filter groups end on the same
pixel: pixel:
`row 1: 192 + 12 + 432 = 636` · `row 2: 252 + 72 + 312 = 636`. `row 1: 192 + 16 + 432 = 640` · `row 2: 252 + 76 + 312 = 640`.
That is not a coincidence to be preserved by a special rule — it is what row-block That is not a coincidence preserved by a special rule — it is what row-block width
width **1020** buys, and at 1020 row 2's two gutters are *also* exactly equal (72/72) **1028** buys, and at 1028 both rows' gutters are *also* exactly equal (16/16/16 and
and row 1's smallest gutter is *exactly* `kDeckGroupGap`. Three good properties at one 76/76), because 1028 leaves each row a slack its gutter count divides with no residue.
width. **This is why the floor is 1190 and not 1186.** **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 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 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 sit on the same four lines. The reflow must not break this — it is free today and
@@ -249,32 +267,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 rows at the floor width | 3 (by greedy wrap) | **2 (by construction)** |
| Deck band height | 328 | **216** | | Deck band height | 328 | **216** |
| Waveform band at the floor | 246 | **358** | | Waveform band at the floor | 246 | **358** |
| Minimum / default window | 980 × 680 | **1190 × 680** | | Minimum / default window | 980 × 680 | **1198 × 680** |
| Ceiling headroom | — | **90 px wide, 40 px tall** | | 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 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 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 decides row membership at the floor width (§7.3). And the phase spends its ceiling headroom
budget — §1.6. 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 **Read this before proposing any new knob.** The floor is **1198** against Daniel's hard
**1280** ceiling. That is **90 px of width headroom for the life of this layout**, and it is **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: the single constraint every later addition spends from:
| Purchase | Cost | Headroom after | | Purchase | Cost | Headroom after |
|---|---|---| |---|---|---|
| One more 60 px deck cell on row 1 | 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 | 048 | 4290 | | One more caption toggle on a group whose caption row is the binding side | 048 | 3482 |
| Widening MASTER to a two-cell left column | 60 | 30 | | Widening MASTER to a two-cell left column | 60 | 22 |
| A second cell *and* a wider MASTER | 120 | **over ceiling** | | 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 **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 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 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 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 of a guess. **Reserving capacity you have not designed a use for is not free here — it is
@@ -287,7 +310,7 @@ Two corollaries for a reader who wants to add something:
FILTER's `Band|Notch` move exploits). A cell always costs its 60 px. 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 - **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 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. confused with this one.
--- ---
@@ -536,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 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. 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.** **What made it expensive here was entirely our own design, in one line** — and Γ-W3 removed
`ReaSamplerProcessor::setActive` is deliberately destructive in both directions that line. `ReaSamplerProcessor::setActive` was deliberately destructive in both directions:
(`reasampler_processor.cpp:85-109`):
- `setActive(true)` calls `reloadInstrument()` (`:89-97`) — **a bridge read and a full WAV - `setActive(true)` called `reloadInstrument()`**a bridge read and a full WAV re-decode**,
re-decode**, plus a fresh engine. This is the expensive half, and no part of it is required plus a fresh engine. That was the expensive half, and no part of it was required by the SDK:
by the SDK: it is there because activation was the convenient trigger for a reload, not it was there because activation was the convenient trigger for a reload, not because
because activation implies one. activation implies one.
- `setActive(false)` frees `live_`, `draining_` **and** the graveyard (`:98-107`), so every - `setActive(false)` frees `live_`, `draining_` **and** the graveyard, so every sounding voice
sounding voice dies. The comment there explains why that is correct and must not be dies. That half is correct and must not be softened casually: a surviving `live_` would be
softened casually: a surviving `live_` would be displaced into the drain slot on reactivate displaced into the drain slot on reactivate and *"resurrect stale sustained voices as
and *"resurrect stale sustained voices as ghosts."* ghosts."*
**So the cost is ours, and it is ours to reduce.** The reduction is **decoupling the reload **The cost was ours, and it has been reduced (Γ-W3 — see §7.11).** The deactivate now parks the
from activation** — keeping the decoded `SampleData` alive across a deactivate while still decoded `SampleData` and the reactivate rebuilds only the voice state around it, through the
destroying voice state, which is exactly the shape `rebuildVoiceEngine`'s drain-slot swap same drain-slot swap `rebuildVoiceEngine` uses for voice-count edits. An activation cycle costs
already implements for voice-count edits. **That is a latent improvement with a clear trigger no disk read and no decode; an instance with nothing decoded still takes the full reload, which
condition, filed in `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") — is where the pre-v10 legacy lift lives.
not a reason to abandon dynamic latency, and not scheduled in this phase.**
**The honest cost of the toggle today, stated plainly:** every sounding note stops and the **The honest cost of the toggle, stated plainly:** every sounding note stops. **Daniel has
sample is re-decoded from disk. **Daniel has accepted it** (Γ-F6): *"Toggling the limiter accepted it** (Γ-F6): *"Toggling the limiter killing the voices isn't a deal breaker though,
killing the voices isn't a deal breaker though, the limiter will either be on or off on its the limiter will either be on or off on its instance, toggling during playback is not a use
instance, toggling during playback is not a use case."* There is no fallback design and no case."* There is no fallback design and no measurement gate.
measurement gate.
#### The standing scar, and why this is nonetheless not the forbidden change #### The standing scar, and why this is nonetheless not the forbidden change
@@ -636,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` in the **not-automatable** class, and it is emphatically not the plugin's `kIsBypass`
parameter either. parameter either.
- **Observe what REAPER does, and record it — as evidence, not as a gate.** Whether notes - **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 cut and whether transport hiccups is DAW-observable only. The re-decode half of that
only. Record it in Γ-W1-T2's review because it is the trigger-condition evidence for the question is gone (§7.11), so what remains to observe is the voice cut alone. **No outcome
`docs/TODO.md` decoupling entry. **No outcome changes the design**; Γ-F6 is closed either changes the design**; Γ-F6 is closed either way.
way.
### 3.2 The meter ### 3.2 The meter
@@ -1167,8 +1186,8 @@ Three reasons for that exact slot:
2. **Browse stays rightmost.** It is navigation, not a mode — moving it would break the 2. **Browse stays rightmost.** It is navigation, not a mode — moving it would break the
established right-edge reading. established right-edge reading.
3. **It costs zero window width.** The run is right-anchored and the title slot absorbs it, 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.** 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 1190 floor. If it will not, *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 the enable's segments narrow — the floor does not move. That is a hard rule, because the
floor is a phase-wide acceptance criterion. floor is a phase-wide acceptance criterion.
@@ -1377,13 +1396,27 @@ squarely on `ReaSamplerProcessor::setActive`, which is deliberately destructive
directions. **Those four are hygiene against the `kIoChanged` scar (§3.1.1), not a hedge 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. 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 **7.11 — `setActive` conflated two lifetimes; it no longer does (LANDED, Γ-W3).** Activation
makes a user notice.** Activation currently means both "the audio thread may run" and "the used to mean both "the audio thread may run" and "the decoded `SampleData` is (re)built", so
decoded `SampleData` is (re)built" (`reasampler_processor.cpp:89-97`). Phase Γ does **not** every host-driven cycle paid a bridge read and a full WAV decode. The two are now separate:
separate them — Γ-F6 accepts the cost — but the conflation is now a named, filed improvement `setActive(false)` parks the decoded sample and destroys the voice state, `setActive(true)`
(`docs/TODO.md`, "Decouple the instrument reload from VST3 activation") rather than an rebuilds the voices around the parked sample through the drain-slot swap `rebuildVoiceEngine`
unremarked property. **Do not restructure `setActive` inside this phase**; its destructive already used. **This section's earlier instruction — "do not restructure `setActive` inside
shape is deliberate and its reasoning is documented at the call site. 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.
**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.
--- ---
@@ -1402,8 +1435,8 @@ 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 | | **Γ-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 | | **Γ-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 | | **Γ-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` | | **Γ-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) | | **Γ-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 Three of these corrected this doc rather than confirming it, and all three corrections are
@@ -1449,7 +1482,8 @@ reintroduced:
than just counting: than just counting:
1. **§3.1.1 was rewritten, not annotated.** Its prior framing — dynamic latency as exotic and 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 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 spike. What remains is an *observation* recorded in review as evidence for the deferred
improvement — it gates nothing. improvement — it gates nothing.
@@ -1459,7 +1493,7 @@ than just counting:
4. **The verification requirements survive unchanged**, because they were always about the 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 `kIoChanged` scar (a dual-mono capture panned hard right by a prior mid-session
`restartComponent`), not about this flag. `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 ### 8.3 Γ-F7 — RULED: signal flow. The parameter order
@@ -1534,7 +1568,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 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 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 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 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. like in between — is in `docs/PLAN.md` at Γ-W1-T4.
+11 -10
View File
@@ -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 > latency, and the vendored SDK defines `restartComponent(kLatencyChanged)` as *"the host
> has to deactivate and reactivate the plug-in"* > has to deactivate and reactivate the plug-in"*
> (`pluginterfaces/vst/ivsteditcontroller.h:105-108`). In this plugin a deactivate frees > (`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 > every sounding voice. **An automation lane toggling that parameter would deactivate the
> that parameter would deactivate the plugin on every flip.** > plugin on every flip.**
Two corollaries the parameter work must carry rather than rediscover: 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. 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 - **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 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. flag must survive that cycle.
Full reasoning, the SDK quotes, and the required verification steps are in 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 **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. 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 **The decoupling that was filed against this section has LANDED (Γ-W3), and it changes the
expensive because *this plugin's* `setActive(true)` re-decodes the WAV — not because the SDK cost but not the classification.** `setActive(true)` no longer re-decodes the WAV: the decoded
requires it. `docs/TODO.md` ("Decouple the instrument reload from VST3 activation") files that sample now survives a deactivate and only the voice state is rebuilt
reduction, and **"the limiter enable is wanted automatable" is one of its named trigger (`instrument-control-surface.md` §7.11). So a flip costs a voice rebuild rather than a disk
conditions.** If the parameter work genuinely needs that lane, the answer is to do the read plus a decode — but **the deactivate still frees every sounding voice**, which is the
decoupling first, not to re-litigate the classification. 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.
--- ---
@@ -499,7 +500,7 @@ will eventually propose "fixing" that. The answer is that the two *cannot* both
forever, and only one of the two axes holds still: 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 > **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. > 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 > **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 > drift apart** — and after the first drift the order is neither logical *nor* matching, which
+6 -5
View File
@@ -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 FritschCarlson 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`. - `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 FritschCarlson 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 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. - `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/` ### `map/`
@@ -326,11 +326,11 @@ anything for a trigger shape.
### `ui/` ### `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. - `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. - `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. - `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. - `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. - **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. - **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. - `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.
@@ -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_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. - `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. - `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 (n1) 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 - `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` 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` / 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 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 parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
labels for them. 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. `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. - `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. - `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_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.
@@ -87,3 +87,9 @@ reasampler_test(limiter LINK limiter)
reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp) reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp)
reasampler_test(meter_ballistics LINK meter_ballistics) 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)
@@ -0,0 +1,69 @@
// 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. 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
#include <atomic>
namespace reasampler::instrument::engine {
// A lock-backed std::atomic<float> would put a mutex on the audio thread; assert the freedom
// rather than assume it.
static_assert(std::atomic<float>::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. 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<float>.
template <class Accumulator>
inline void foldPeak(Accumulator& acc, float blockPeak) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_strong(seen, seen > blockPeak ? seen : blockPeak,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
template <class Accumulator>
inline void foldMinGain(Accumulator& acc, float blockMinGain) {
float seen = acc.load(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).
// Concrete: only the folds have the interleave a test seam buys, and a template over one
// instantiation models nothing.
inline float consumePeak(std::atomic<float>& acc) {
return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed);
}
inline float consumeMinGain(std::atomic<float>& acc) {
return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed);
}
} // namespace reasampler::instrument::engine
+6
View File
@@ -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<std::string>& banksJson, LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) { const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry; if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
+7
View File
@@ -85,6 +85,13 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId);
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids); const std::vector<std::string>& 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 // Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references? // for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying. // * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
+27 -9
View File
@@ -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_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome) 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 # sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit the meter's bar
# header needs it. # count consumes that answer, so the type is part of this module's surface, not an internal.
reasampler_pure_library(waveform_view reasampler_pure_library(waveform_view
SOURCES waveform_view.cpp SOURCES waveform_view.cpp
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands) LINK PUBLIC editor_geometry peaks sample_bands)
# sample_bands is linked directly here because the test exercises the lane metrics that
# waveform_view does not re-export.
reasampler_test(waveform_view LINK waveform_view 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 # The loop enable's state machine. Links loop_span for the park bounds the span the user is
@@ -57,17 +55,37 @@ reasampler_test(envelope_edit LINK envelope_edit)
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry) reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
reasampler_test(knob_deck LINK knob_deck) 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 # 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 # 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 # 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, # 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. # 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 reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks curve_law) LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter)
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion # WHICH descriptors the deck carries, and how they resolve to a layout no window-floor budget
# needs the band allocator deck_groups itself has no reason to depend on. # assertion here, so this target needs neither sample_bands nor master_meter.
reasampler_test(deck_groups LINK deck_groups sample_bands) 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.
reasampler_test(deck_groups_state LINK deck_groups)
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike # 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). # envelope_overlay/envelope_edit, which stay engine-free the staged envelopes touch no curve).
+23 -4
View File
@@ -4,6 +4,8 @@
#include <utility> #include <utility>
#include "core/instrument/ui/master_meter.h" // kMeterColumnW (what the column's interior needs)
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
namespace { namespace {
@@ -11,8 +13,9 @@ int id(DeckParam p) { return static_cast<int>(p); }
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); } 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 // 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 // no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob
// its four-cell knob row at 23. Raising it reflows the deck's first row. // row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
// the CONTOUR row, which has 152px of slack, not the SOUND row.
constexpr int kEnvModeSegW = 23; constexpr int kEnvModeSegW = 23;
} // namespace } // namespace
@@ -63,7 +66,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
id(DeckParam::kFilterModAmt), id(DeckParam::kFilterModAmt),
id(DeckParam::kFilterVel), id(DeckParam::kFilterVel),
id(DeckParam::kFilterKeyTrack)}; 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)); out.push_back(std::move(filter));
} }
{ {
@@ -126,12 +131,21 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
out.push_back(std::move(voice)); 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 82px budget on a control nobody has named.
DeckGroupDesc master; DeckGroupDesc master;
master.id = kGroupMaster; master.id = kGroupMaster;
master.captionWidth = 46; 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};
// 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)); out.push_back(std::move(master));
} }
for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast<DeckGroupId>(d.id));
return out; return out;
} }
@@ -257,6 +271,11 @@ LiveCommit deckParamCommit(DeckParam id) {
case DeckParam::kVoiceMode: case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger: case DeckParam::kMonoTrigger:
case DeckParam::kMasterGain: 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 case DeckParam::kCount: // not a control
return LiveCommit::Reload; return LiveCommit::Reload;
} }
+9 -8
View File
@@ -87,6 +87,11 @@ enum class DeckParam {
kVoiceMode, // Poly | Mono caption toggle (VOICE group) kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono) kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group) 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 kCount
}; };
@@ -103,14 +108,10 @@ enum DeckGroupId {
kGroupMaster, kGroupMaster,
}; };
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice // Which row a group belongs to (the row vocabulary itself is knob_deck's — the layout is what
// IS, Contour is how it moves over time, Spanning is what happens after the mixer. // reads it). Membership is a property of the GROUP; width is a property of its descriptor.
enum class DeckRow { Sound, Contour, Spanning }; // 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. 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); DeckRow deckRowFor(DeckGroupId group);
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE // Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
+149 -55
View File
@@ -8,10 +8,19 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell // 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 28px knob), plus the optional row // already carries its own breathing room around the 40px 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) { 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<int>(g.cellIds.size()) * kDeckCellW; int w = static_cast<int>(g.cellIds.size()) * kDeckCellW;
if (g.rowToggle.id >= 0) { if (g.rowToggle.id >= 0) {
if (w > 0) w += kDeckToggleGap; if (w > 0) w += kDeckToggleGap;
@@ -29,6 +38,24 @@ int captionRowWidth(const DeckGroupDesc& g) {
return w; 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. // Place one group's inner geometry given its box.
DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
DeckGroupLayout out; DeckGroupLayout out;
@@ -46,7 +73,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2; const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
out.captionRadio = DeckRadioLayout{ out.captionRadio = DeckRadioLayout{
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight, g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
radioTop + kDeckRadioSize)}; radioTop + kDeckRadioSize),
g.captionRadio.passive};
captionRight = out.captionRadio.box.x - kDeckToggleGap; captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x; out.caption.width = captionRight - out.caption.x;
} }
@@ -65,10 +93,36 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
placeToggle(g.captionToggle, out.captionToggle); placeToggle(g.captionToggle, out.captionToggle);
placeToggle(g.captionToggle2, out.captionToggle2); 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. 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)};
}
return out;
}
// Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared // 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 // 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. // 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<int>(g.cellIds.size()) * kDeckCellW; const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
int presentCells = 0; int presentCells = 0;
for (int id : g.cellIds) { for (int id : g.cellIds) {
@@ -78,19 +132,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
int x = innerLeft + (runWidth - presentCells * cellW) / 2; int x = innerLeft + (runWidth - presentCells * cellW) / 2;
for (int id : g.cellIds) { for (int id : g.cellIds) {
if (id < 0) continue; if (id < 0) continue;
DeckCellLayout c; out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW,
c.id = id; cellTop + kDeckCellH)));
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);
x += cellW; x += cellW;
} }
if (g.rowToggle.id >= 0) { if (g.rowToggle.id >= 0) {
@@ -107,65 +150,110 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
return out; return out;
} }
// The gutters between `count` groups whose widths total `total`, justified space-between
// inside `blockW`. Empty for a single group.
std::vector<int> 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 — see layoutDeck's header note for when this degrade applies.
return std::vector<int>(static_cast<std::size_t>(gutters), kDeckGroupGap);
}
const int base = slack / gutters;
const int residue = slack % gutters; // both non-negative: slack >= gutters * 12 > 0
std::vector<int> out(static_cast<std::size_t>(gutters), base);
for (int i = 0; i < residue; ++i) ++out[static_cast<std::size_t>(i)];
return out;
}
} // namespace } // namespace
int deckGroupWidth(const DeckGroupDesc& g) { int deckGroupWidth(const DeckGroupDesc& g) {
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX; return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
} }
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth) { int deckRowCount(const std::vector<DeckGroupDesc>& groups) {
if (groups.empty()) return 0; bool sound = false, contour = false;
int rows = 1;
int x = 0;
for (const DeckGroupDesc& g : groups) { for (const DeckGroupDesc& g : groups) {
const int w = deckGroupWidth(g); if (g.row == DeckRow::Sound) sound = true;
if (x > 0 && x + kDeckGroupGap + w > availWidth) { else if (g.row == DeckRow::Contour) contour = true;
++rows;
x = w;
} else {
x += (x > 0 ? kDeckGroupGap : 0) + w;
}
} }
return rows; return (sound ? 1 : 0) + (contour ? 1 : 0);
} }
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth) { int deckHeight(const std::vector<DeckGroupDesc>& groups) {
const int rows = deckRowCount(groups, availWidth); const int rows = deckRowCount(groups);
if (rows == 0) return 0; int h = rows > 0 ? rows * kDeckGroupH + (rows - 1) * kDeckRowGap : 0;
return rows * kDeckGroupH + (rows - 1) * kDeckRowGap; for (const DeckGroupDesc& g : groups) {
if (g.row == DeckRow::Spanning) h = (std::max)(h, kDeckSpanningH);
}
return h;
} }
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top, DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth) { int availWidth) {
DeckLayout out; DeckLayout out;
if (groups.empty()) return out; if (groups.empty()) return out;
int x = left;
int y = top; // Partition by the group's OWN row (indices, so the OUTPUT keeps deck order — the shell
bool rowHasGroup = false; // and the tests pair a layout with the descriptor at the same position).
out.rowCount = 1; std::vector<std::size_t> rows[2];
for (const DeckGroupDesc& g : groups) { std::vector<std::size_t> spanning;
const int w = deckGroupWidth(g); for (std::size_t i = 0; i < groups.size(); ++i) {
if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) { const DeckRow row = groups[i].row;
// Wrap: whole trailing group onto the next row (mirror of deckRowCount). if (row == DeckRow::Spanning) spanning.push_back(i);
++out.rowCount; else rows[row == DeckRow::Contour ? 1 : 0].push_back(i);
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;
} }
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<int>(spanning.size()) - 1) * kDeckGroupGap;
}
const int blockW = availWidth - (spanning.empty() ? 0 : spanTotal + kDeckGroupGap);
std::vector<Rect> boxes(groups.size());
int y = top;
for (const std::vector<std::size_t>& 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<int> gutters =
justifyGutters(static_cast<int>(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; return out;
} }
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) { for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue; 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}; return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
} }
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) { for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
@@ -185,7 +273,13 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, 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 {}; return {};
} }
+70 -30
View File
@@ -1,18 +1,8 @@
// knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free // 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 // 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 // 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. // 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.
// 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.
#pragma once #pragma once
@@ -37,8 +27,10 @@ inline constexpr int kDeckGroupPadY = 4; // group box vertical inner paddin
inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap 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 kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row 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 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 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 // 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. // deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
@@ -46,6 +38,15 @@ inline constexpr int kDeckInnerDialSize = 20;
// One group box: padding + caption + gap + cell row + padding. // One group box: padding + caption + gap + cell row + padding.
inline constexpr int kDeckGroupH = inline constexpr int kDeckGroupH =
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY; 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 ------------------------------------ // --- The deck's width budget at the editor's floor ------------------------------------
// DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a // DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a
@@ -53,10 +54,14 @@ inline constexpr int kDeckGroupH =
// from the first two — kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2*kPad — and the // 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 // identity is asserted in test_deck_groups.cpp rather than coded, so the allocator keeps no
// include edge to this header. // 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 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 // 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 // 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. // instrument-control-surface.md §1.6 before spending any of it.
@@ -67,15 +72,29 @@ struct DeckToggleDesc {
}; };
// A single-square corner radio (an exclusive selector across groups, so the group itself // 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 { struct DeckRadioDesc {
int id = -1; 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 // 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 — // 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 // 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). // 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 { struct DeckGroupDesc {
int id = 0; // shell group id (opaque here) int id = 0; // shell group id (opaque here)
int captionWidth = 60; int captionWidth = 60;
@@ -87,6 +106,8 @@ struct DeckGroupDesc {
DeckToggleDesc captionToggle2; DeckToggleDesc captionToggle2;
std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above) std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above)
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none 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 --------------------------------------------------------------- // --- Laid-out geometry ---------------------------------------------------------------
@@ -100,6 +121,12 @@ struct DeckToggleLayout {
struct DeckRadioLayout { struct DeckRadioLayout {
int id = -1; int id = -1;
Rect box; Rect box;
bool passive = false; // a readout lamp, not a selector — see DeckRadioDesc
};
struct DeckColumnLayout {
int id = -1;
Rect box;
}; };
struct DeckCellLayout { struct DeckCellLayout {
@@ -119,34 +146,46 @@ struct DeckGroupLayout {
DeckToggleLayout captionToggle2; DeckToggleLayout captionToggle2;
std::vector<DeckCellLayout> cells; std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent DeckToggleLayout rowToggle; // id -1 when absent
DeckColumnLayout column; // id -1 when absent (Spanning groups only)
}; };
struct DeckLayout { struct DeckLayout {
std::vector<DeckGroupLayout> groups; std::vector<DeckGroupLayout> groups;
int rowCount = 0; int rowCount = 0; // POPULATED categorical rows (0..2). A spanning deck is in neither.
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups 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 // Width of one group box: the wider of its caption row (caption + gap + toggles + radio) and
// knob row (cells + gap + row toggle), plus horizontal padding. // 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); int deckGroupWidth(const DeckGroupDesc& g);
// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap. // How many of the two categorical rows carry at least one group (0..2). Independent of width:
// 0 for an empty list. // row membership is the group's own property.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckRowCount(const std::vector<DeckGroupDesc>& 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. // bottom-anchors a band of exactly this height.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckHeight(const std::vector<DeckGroupDesc>& groups);
// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's // Lays the groups out from (left, top) within `availWidth`. Every rect is absolute, and
// rule. Every rect is absolute. // `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) 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<DeckGroupDesc>& groups, int left, int top, DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth); int availWidth);
// --- Hit-test -------------------------------------------------------------------------- // --- Hit-test --------------------------------------------------------------------------
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio }; enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Column };
struct DeckHit { struct DeckHit {
DeckHitKind kind = DeckHitKind::None; DeckHitKind kind = DeckHitKind::None;
@@ -157,8 +196,9 @@ struct DeckHit {
// The deck element a point lands on: a knob cell (the whole cell, not just the knob // 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 // 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 // a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, an
// the caption-row corner radio. Everything else — fence, padding, outside — misses. // 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); DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
// The knob FACE a point lands on. id -1 is a miss. // The knob FACE a point lands on. id -1 is a miss.
+113
View File
@@ -0,0 +1,113 @@
// master_meter.cpp — see master_meter.h.
#include "core/instrument/ui/master_meter.h"
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<int>(kMeterTickStepDb)) == 0;
}
MeterRects meterRects(const Rect& column, LaneSplit split) {
MeterRects 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());
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;
}
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<int>(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);
// 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;
}
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
const double reduction = -engine::meterDbFromLinear(block.minGain);
// 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;
}
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;
}
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) {
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
+101
View File
@@ -0,0 +1,101 @@
// 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;
// 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
Rect barA; // Single: the one wide bar. Stereo: L.
Rect barB; // empty() unless Stereo
};
// `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, so the lamp is dark again after the single repaint the catch landed on.
double reductionHoldSeconds = 0.0;
};
// 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 window; 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);
// 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. 0.5 dB is a CHOSEN floor, not a measurement — the
// 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);
} // namespace reasampler::instrument::ui
+3 -3
View File
@@ -21,12 +21,12 @@ inline constexpr int kPad = 8;
// this allocator is deliberately independent of the deck (it takes deckHeight as a parameter // 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 // 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. // 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; inline constexpr int kEditorMinHeight = 680;
// The hard ceiling the floor above may not exceed; the window itself still grows freely above // 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 // 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 // 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. // that already includes both this header and knob_deck.h.
inline constexpr int kEditorCeilingWidth = 1280; inline constexpr int kEditorCeilingWidth = 1280;
@@ -55,7 +55,7 @@ struct SampleBands {
}; };
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own // 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. // the deck band is exactly as tall as its content. Pure.
SampleBands computeSampleBands(int w, int h, int deckHeight); SampleBands computeSampleBands(int w, int h, int deckHeight);
+5 -3
View File
@@ -24,13 +24,15 @@ OverlayArea waveformOverlayArea(const Rect& band) {
return OverlayArea{band.empty() ? 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 waveformSurface(const Rect& band, bool stereoMode, int sourceChannels) {
WaveformSurface s; WaveformSurface s;
if (band.empty()) return s; if (band.empty()) return s;
s.overlay = waveformOverlayArea(band); s.overlay = waveformOverlayArea(band);
const bool twoLanes = stereoMode && sourceChannels >= 2; const WaveformLanes lanes = waveformLanes(band, resolveLaneSplit(stereoMode, sourceChannels));
const WaveformLanes lanes =
waveformLanes(band, twoLanes ? LaneSplit::Stereo : LaneSplit::Single);
s.upper = lanes.upper; s.upper = lanes.upper;
s.lower = lanes.lower; s.lower = lanes.lower;
// Derived from the resolved lanes, not `twoLanes` — a stereo split's integer division // Derived from the resolved lanes, not `twoLanes` — a stereo split's integer division
+11 -3
View File
@@ -11,6 +11,7 @@
#include <cstdint> #include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, OverlayArea, contains #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 #include "core/audio/peaks.h" // AudioSample (float), Envelope
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
@@ -35,9 +36,16 @@ struct WaveformSurface {
// the band-stack allocator's kWaveformMinHeight floor. // the band-stack allocator's kWaveformMinHeight floor.
}; };
// Resolves the surface for a waveform band. Two lanes need BOTH stereo mode and a source // THE lane-split decision, free of any pixel geometry: two lanes need BOTH stereo mode and a
// that has a second channel to show: a mono source under stereo mode is dual-mono, so a // source that has a second channel to show, since a mono source under stereo mode is dual-mono
// second lane would be the redundant duplicate single-lane mode exists to avoid. // 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); WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels);
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test // THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
+35 -2
View File
@@ -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`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`,
`param_slider`, `param_taper`, `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`, `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. `core/wire` and is documented there — this directory consumes it but does not own it.
## Invariants ## Invariants
@@ -107,12 +107,13 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules ## 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`. - `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_<instanceGuid>` 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 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_<instanceGuid>` 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_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). - `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. - `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_<guid>` 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_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_<guid>` 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. - `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. - `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.)* - `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,6 +125,38 @@ 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 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 in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one. 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)`, 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.
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
timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed.
**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()`. **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 - 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 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 applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
+1 -1
View File
@@ -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 waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit 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 knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
limiter meter_ballistics bake_hold limiter meter_accumulate meter_ballistics master_meter bake_hold
file_bytes curve_law stroke_aa file_bytes curve_law stroke_aa
curve_tessellate curve_tessellate
bake_plan bake_render bake_reset bake_wire wav_codec) bake_plan bake_render bake_reset bake_wire wav_codec)
+2 -2
View File
@@ -75,10 +75,10 @@ double curveExponentFor(int id, const PlaySeconds& play) {
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const { 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 // 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. // 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; FaceLayout fl;
fl.deckDescs = sampleDeckGroups(params_.play.playMode); 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); fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
return fl; return fl;
} }
+2 -2
View File
@@ -98,8 +98,8 @@ void ReaSamplerEditor::dragBrowse(int x, int y) {
invalidate(); invalidate();
} }
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x, HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
int y) const { int y) const {
const BrowseModal bm = computeBrowseModal(w, h); const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y)) return {HoverKind::kBack, -1}; if (contains(bm.back, x, y)) return {HoverKind::kBack, -1};
if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1}; if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1};
+2 -2
View File
@@ -152,8 +152,8 @@ void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) {
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
} }
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x, HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
int y) const { int y) const {
const ChromeRects& cr = fl.chrome; const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1}; if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
+2 -2
View File
@@ -114,8 +114,8 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) {
/*addOnEmptySpace=*/false); /*addOnEmptySpace=*/false);
} }
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x, HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
int y) const { int y) const {
const CurvePopupLayout pl = computeCurvePopup(w, h); const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1}; if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1};
if (!contains(pl.curveBox, x, y)) return {}; if (!contains(pl.curveBox, x, y)) return {};
+26 -2
View File
@@ -64,6 +64,19 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
applyParamControl(hit.id, 0.0, hit.segment); applyParamControl(hit.id, 0.0, hit.segment);
commitAndReload(); commitAndReload();
break; break;
case ParamControl::kLimiterEnable: {
const bool on = (hit.segment == 1);
if (on != params_.limiterEnabled) {
params_.limiterEnabled = 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;
}
default: { default: {
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable, // Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable,
// and the three env-mode toggles). // and the three env-mode toggles).
@@ -80,6 +93,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
} }
return true; 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) { if (hit.kind == DeckHitKind::Knob) {
// Knobs of a disabled group are drawn but inert. // Knobs of a disabled group are drawn but inert.
if (deckKnobDisabled(hit.id)) return true; if (deckKnobDisabled(hit.id)) return true;
@@ -172,13 +193,16 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
invalidate(); invalidate();
} }
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x, HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
int y) const { int y) const {
const Rect& band = fl.bands.decks; const Rect& band = fl.bands.decks;
if (!contains(band, x, y)) return {}; if (!contains(band, x, y)) return {};
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width); const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit dh = hitTestDeck(dl, x, y); const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind == DeckHitKind::None) return {}; 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::CaptionRadio) return {HoverKind::kEnvRadio, dh.id};
if (dh.kind == DeckHitKind::Knob && dh.inner && if (dh.kind == DeckHitKind::Knob && dh.inner &&
curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) { curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) {
@@ -188,8 +188,8 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
return true; return true;
} }
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x, HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x,
int y) { int y) {
// Caps only: the cap is the grip, so it is the one thing on the overlay a resting pointer // 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. // can be "on". A hovered mark promotes its own label past the suppression rule.
// //
+55
View File
@@ -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
+103 -11
View File
@@ -12,6 +12,8 @@
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state) #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/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" // resolveLaneSplit (THE lane-split fold)
#include "shell/instrument/editor_internal.h" // kit adapters + knob face #include "shell/instrument/editor_internal.h" // kit adapters + knob face
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
@@ -24,6 +26,76 @@ using namespace reasampler::instrument::ui; // deck geometry
// are chrome you read once — this is the readout you read while turning something. // are chrome you read once — this is the readout you read while turning something.
constexpr Font kCellLabelFont = Font::Label; 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 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
// limiter-off case is read against.
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
for (int db = static_cast<int>(instrument::engine::kMeterTopDb);
db >= static_cast<int>(instrument::engine::kMeterFloorDb);
db -= static_cast<int>(kMeterTickStepDb)) {
const int y = meterDbToY(m.field, db);
const bool zero = (db == 0);
LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1,
zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0);
if (meterTickNumeralled(db)) {
kitText(bmp, meterNumeralRect(m.labels, y), tickLabel(db).c_str(), Font::Micro,
Role::TextDim, Align::Right);
}
}
// 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) {
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) {
drawBar(m.barA, meterSingleLaneState(state));
} 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) { void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const Rect& deckArea = fl.bands.decks; const Rect& deckArea = fl.bands.decks;
if (deckArea.width <= 0 || deckArea.height <= 0) return; if (deckArea.width <= 0 || deckArea.height <= 0) return;
@@ -31,6 +103,12 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const PlaySeconds& play = params_.play; const PlaySeconds& play = params_.play;
const bool isMono = (voiceMode_ == VoiceMode::Mono); const bool isMono = (voiceMode_ == VoiceMode::Mono);
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
// 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 // 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. // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
@@ -122,9 +200,19 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
} }
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); 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 // 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. // 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" — // 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 // 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. // picked env has to be checked against kNone itself, not just matched by equality.
@@ -164,6 +252,15 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
case ParamControl::kFilterEnable: case ParamControl::kFilterEnable:
drawToggle(t, "Off", "On", play.filter.enabled, false); drawToggle(t, "Off", "On", play.filter.enabled, false);
break; 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: case ParamControl::kAmpEnvMode:
drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false); drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false);
break; break;
@@ -176,19 +273,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
default: break; 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 (g.rowToggle.id >= 0) {
if (static_cast<ParamControl>(g.rowToggle.id) == ParamControl::kFilterLaw) { drawToggle(g.rowToggle, "Retrig", "Legato",
drawToggle(g.rowToggle, "Band", "Notch", monoTrigger_ == MonoTrigger::Legato, !isMono);
play.filter.settings.morphLaw ==
instrument::engine::filter::MorphLaw::HighNotchLow,
!play.filter.enabled);
} else {
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 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. // The predicate is the input side's, so the drawn state and the inert grab agree.
for (const DeckCellLayout& c : g.cells) { for (const DeckCellLayout& c : g.cells) {
+33
View File
@@ -107,8 +107,41 @@ void ReaSamplerEditor::onSyncTimer() {
// rebuild the instrument and repaint under the cursor, yanking the edit — the next // rebuild the instrument and repaint under the cursor, yanking the edit — the next
// tick picks up the change after release. // tick picks up the change after release.
if (!processor_) return; if (!processor_) return;
// 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 unsigned long long previous = meterTickMs_;
meterTickMs_ = now;
const MasterBusMeter bus = processor_->masterBusMeter();
// 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<double>(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 if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// 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 // 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. // can never be enabled on one tick and refuse on the next.
const bool available = bakeAvailable(processor_->bridge()); const bool available = bakeAvailable(processor_->bridge());
+91 -50
View File
@@ -96,10 +96,6 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// retired-slot free is single-writer; never taken on the audio thread. // retired-slot free is single-writer; never taken on the audio thread.
std::lock_guard<std::mutex> lock(reloadMutex_); std::lock_guard<std::mutex> 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. // 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 // 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 // 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 // Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
// capture's own channel count below, before the decode. // capture's own channel count below, before the decode.
ChannelMode mode = channelMode(); 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<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
std::string resolvedId; std::string resolvedId;
std::unique_ptr<LoadedInstrument> built; std::unique_ptr<LoadedInstrument> built;
@@ -177,17 +162,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
} }
} }
if (havePlayable) { if (havePlayable) built = buildInstrumentLocked(std::move(sample));
// 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<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the // 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 // header). A null `built` (no ref / unreadable WAV) installs silence while any
@@ -246,6 +221,11 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
// ONE reload for the re-point and the reset together: it decodes the new file and // ONE reload for the re-point and the reset together: it decodes the new file and
// publishes the neutral parameters in the same swap. // publishes the neutral parameters in the same swap.
reloadInstrument(); reloadInstrument();
// 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();
} }
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
@@ -288,8 +268,7 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
} }
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) { void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
// REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the // REQUIRES reloadMutex_ held (see the header's drain-slot proof).
// one safety-critical swap dance (see the header's drain-slot proof).
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
graveyard_.erase( graveyard_.erase(
std::remove_if(graveyard_.begin(), graveyard_.end(), std::remove_if(graveyard_.begin(), graveyard_.end(),
@@ -297,6 +276,10 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
return e->installedAt < seen; return e->installedAt < seen;
}), }),
graveyard_.end()); 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()); LoadedInstrument* prev = live_.exchange(built.release());
// A bake's reset gain lands here rather than at its call site, so the gain and the // 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. // capture it belongs to become audible to process() within one block of each other.
@@ -313,6 +296,34 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted)); if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
} }
std::unique_ptr<LoadedInstrument>
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<std::mutex> 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<std::int64_t>(
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<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
void ReaSamplerProcessor::rebuildVoiceEngine() { void ReaSamplerProcessor::rebuildVoiceEngine() {
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds // 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 // the engine around a copy of the live instrument's already-decoded SampleData — no
@@ -320,31 +331,61 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
std::lock_guard<std::mutex> lock(reloadMutex_); std::lock_guard<std::mutex> lock(reloadMutex_);
LoadedInstrument* cur = live_.load(std::memory_order_acquire); LoadedInstrument* cur = live_.load(std::memory_order_acquire);
if (!cur) return; // nothing loaded: the new params bake into the next real reload. 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<std::mutex> 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<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
// Deep-copy the decoded sample: safe to read concurrently with process() because the // Deep-copy the decoded sample: safe to read concurrently with process() because the
// SampleData is immutable after construction and reloadMutex_ prevents `cur` from being // SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
// freed. // freed.
SampleData sample = cur->sample; publishBuiltLocked(buildInstrumentLocked(cur->sample));
auto built = std::make_unique<LoadedInstrument>( }
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); bool ReaSamplerProcessor::resumeDormantInstrument() {
publishBuiltLocked(std::move(built)); // 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<std::mutex> 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;
// 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<std::string> ids = referencedSampleIds(selId);
SampleRefs refs;
bool sourceMoved = false;
{
std::optional<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
std::lock_guard<std::mutex> rl(refsMutex_);
if (banksJson) {
const SelectedSample* before = findRef(sampleRefs_, selId);
const std::optional<SelectedSample> was =
before ? std::optional<SelectedSample>(*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. 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;
} }
void ReaSamplerProcessor::retireIdleDrain() { void ReaSamplerProcessor::retireIdleDrain() {
+53 -18
View File
@@ -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. // A new blob is new facts — the legacy lift gets one fresh run per restored state.
legacyLiftConcluded_.store(false, std::memory_order_relaxed); legacyLiftConcluded_.store(false, std::memory_order_relaxed);
reloadInstrument(); 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) resumes or reloads
// against the refs above, which are only fully restored once this function has run to here.
flushLatencyRestart();
return kResultOk; return kResultOk;
} }
@@ -149,25 +153,50 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
} }
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
bool limiterFlagChanged = false;
{ {
std::lock_guard<std::mutex> lock(paramsMutex_); std::lock_guard<std::mutex> lock(paramsMutex_);
limiterFlagChanged = (params_.limiterEnabled != params.limiterEnabled);
params_ = params; params_ = params;
} }
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt — // 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 // 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 // audio thread's copy and the latency report from ever lagging what is persisted. The MIRROR
// requesting the restart here (not just from setLimiterEnabled) is what keeps the host's // is inline, because that is the sound the user clicked for; the host notification is not,
// PDC from lagging it too. Coalesced: writing the value already held requests nothing. // 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); publishLimiterEnabled(params.limiterEnabled);
if (limiterFlagChanged && componentHandler) { // Armed AFTER the mirror, so getLatencySamples already answers the new value for the whole
// The SDK requires this on the UI thread and answers getLatencySamples only after the // window the arm stays outstanding. Compared against the last ANNOUNCED enable rather than
// host's own deactivate/reactivate — so the flag above is already committed by the time // against the previous parameter set: off->on->off inside one tick ends at the latency the
// the host asks. This is a kLatencyChanged restart with the bus untouched, NOT the // host already knows, and a restart rebuilds the instance, so announcing a latency that
// retired per-mode kIoChanged bus renegotiation (see initialize()); do not conflate. // never changed is pure cost. Any number of changes before one flush still cost at most one
componentHandler->restartComponent(kLatencyChanged); // 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() {
// 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;
// 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.
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.
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) { void ReaSamplerProcessor::publishLimiterEnabled(bool on) {
@@ -176,18 +205,24 @@ void ReaSamplerProcessor::publishLimiterEnabled(bool on) {
} }
void ReaSamplerProcessor::setLimiterEnabled(bool on) { void ReaSamplerProcessor::setLimiterEnabled(bool on) {
// Thin wrapper: setInstrumentParams is the one funnel that mirrors the flag AND requests // Rebased off the PROCESSOR's copy rather than taking a caller-supplied set: an editor
// the restart, so every writer of the parameter set — this one included — agrees. // 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(); InstrumentParams params = instrumentParams();
params.limiterEnabled = on; params.limiterEnabled = on;
setInstrumentParams(params); setInstrumentParams(params);
} }
MasterBusMeter ReaSamplerProcessor::masterBusMeter() const { MasterBusMeter ReaSamplerProcessor::masterBusMeter() {
MasterBusMeter m; MasterBusMeter m;
m.peakL = meterPeakL_.load(std::memory_order_relaxed); // Consuming: each read takes the window and reinstalls its identity element, which is what
m.peakR = meterPeakR_.load(std::memory_order_relaxed); // starts the next one. The audio thread's fold is an unconditional CAS against exactly that
m.minGain = meterMinGain_.load(std::memory_order_relaxed); // (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); m.clip = meterClip_.load(std::memory_order_relaxed);
return m; return m;
} }
+8 -44
View File
@@ -20,6 +20,7 @@
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam) #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/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/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/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/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/spline_edit.h" // the shared point-editing grammar
@@ -27,6 +28,7 @@
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) #include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams #include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state) #include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
#include "shell/instrument/editor_interaction.h" // DragKind / HoverKind / HoverTarget
#ifdef _WIN32 #ifdef _WIN32
#include <windows.h> #include <windows.h>
@@ -78,15 +80,6 @@ private:
// picker overlaid on it. // picker overlaid on it.
enum class View { kSample, kBrowse }; 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 // 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 // whole state machine are the pure deck_groups module's; this alias keeps the shell's
// spelling. // spelling.
@@ -114,41 +107,6 @@ private:
// What those four marks are showing — see pickedMarkers. // What those four marks are showing — see pickedMarkers.
using SetupMarkers = instrument::ui::LoopMarks; 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 // 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 // paint/hit-test path derives both through this one call so draw and hit-test can never
// disagree about where a band is. // disagree about where a band is.
@@ -498,6 +456,12 @@ private:
std::string searchQuery_; // type-to-filter narrow; "" = no search std::string searchQuery_; // type-to-filter narrow; "" = no search
bool searchFocused_ = false; // whether the search box has keyboard focus 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, and why the first read is discarded).
// meterTickMs_ 0 = never ticked.
instrument::ui::MasterMeterUi masterMeter_;
unsigned long long meterTickMs_ = 0;
// Hover state (transient, never persisted). // Hover state (transient, never persisted).
HoverTarget hover_; // the interactive element under the pointer HoverTarget hover_; // the interactive element under the pointer
#ifdef _WIN32 #ifdef _WIN32
+41 -21
View File
@@ -79,34 +79,52 @@ tresult PLUGIN_API ReaSamplerProcessor::terminate() {
delete live_.exchange(nullptr); delete live_.exchange(nullptr);
delete draining_.exchange(nullptr); delete draining_.exchange(nullptr);
graveyard_.clear(); graveyard_.clear();
dormantSample_.reset();
return SingleComponentEffect::terminate(); return SingleComponentEffect::terminate();
} }
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// Activating: build from the currently-selected sample so the first block after // Activation governs ONE thing — whether the audio thread may run. The decoded
// activation can play. Deactivating: process is now guaranteed stopped, so this is // SampleData has its own lifetime and survives the cycle (dormantSample_); only the
// the safe point to reclaim the graveyard. Main/UI-thread call. // 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<bool>(state) == active_) return kResultOk;
active_ = static_cast<bool>(state);
if (state) { if (state) {
// Resolves + decodes from the instance-owned refs — no bank read needed, so it // Rebuild the voices around the sample the deactivate parked — no WAV decode — so a
// plays regardless of PROJEXTSTATE parse state. Also doubles as the non-editor // host-driven cycle (a kLatencyChanged restart, an offline-render bracket) costs no
// legacy-lift trigger for a pre-v10 blob: reloadInstrument's opportunistic // disk read. With nothing to activate from, the full reload runs: it resolves +
// refreshRefsFromBank copies refs in when the bank blob is readable by now. // 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
// 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 // 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 // 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. // 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 // 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 // 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 // to its persisted state — no transition mute, because the deactivate destroyed every
// continuous with once the block above has destroyed every voice. // voice and the rebuild above starts with none sounding.
limiter_.reset(); limiter_.reset();
} else { } else {
std::lock_guard<std::mutex> lock(reloadMutex_); std::lock_guard<std::mutex> lock(reloadMutex_);
// Free EVERYTHING, including live_: its voices are frozen mid-flight, and if it // Park the decoded PCM; free EVERYTHING else, live_ included. Its voices are frozen
// survived deactivation the reactivate reload would displace it into the drain // mid-flight, and if it survived deactivation the reactivate would displace it into
// slot, resurrecting stale sustained voices as ghosts. Reactivation rebuilds from // the drain slot, resurrecting stale sustained voices as ghosts.
// scratch above, so nothing is lost. std::unique_ptr<LoadedInstrument> dying(live_.exchange(nullptr));
delete 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<SampleData>(std::move(dying->sample))
: std::nullopt;
dying.reset();
delete draining_.exchange(nullptr); delete draining_.exchange(nullptr);
graveyard_.clear(); graveyard_.clear();
} }
@@ -309,7 +327,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
} }
// The chain's last stage before the bus, after the gain above. // The chain's last stage before the bus, after the gain above.
const float minGain = limiter_.process(ch0, ch1, frames); const float minGain = limiter_.process(ch0, ch1, frames);
meterMinGain_.store(minGain, std::memory_order_relaxed); instrument::engine::foldMinGain(meterMinGain_, minGain);
// Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). // Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
for (int32 ch = 2; ch < out.numChannels; ++ch) { for (int32 ch = 2; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) { if (float* buf = out.channelBuffers32[ch]) {
@@ -324,8 +342,9 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
if (a0 > peakL) peakL = a0; if (a0 > peakL) peakL = a0;
if (a1 > peakR) peakR = a1; if (a1 > peakR) peakR = a1;
} }
meterPeakL_.store(peakL, std::memory_order_relaxed); instrument::engine::foldPeak(meterPeakL_, peakL);
meterPeakR_.store(peakR, std::memory_order_relaxed); 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); if (peakL >= 1.f || peakR >= 1.f) meterClip_.store(true, std::memory_order_relaxed);
} else if (ch0) { } else if (ch0) {
// Mono: render into channel 0, replicate to any extra channels (defensive). // Mono: render into channel 0, replicate to any extra channels (defensive).
@@ -354,14 +373,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
} }
} }
const float minGain = limiter_.process(ch0, nullptr, frames); const float minGain = limiter_.process(ch0, nullptr, frames);
meterMinGain_.store(minGain, std::memory_order_relaxed); instrument::engine::foldMinGain(meterMinGain_, minGain);
float peak = 0.f; float peak = 0.f;
for (int32 i = 0; i < frames; ++i) { for (int32 i = 0; i < frames; ++i) {
const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i];
if (a > peak) peak = a; if (a > peak) peak = a;
} }
meterPeakL_.store(peak, std::memory_order_relaxed); instrument::engine::foldPeak(meterPeakL_, peak);
meterPeakR_.store(peak, std::memory_order_relaxed); instrument::engine::foldPeak(meterPeakR_, peak);
advisoryPeak_.store(peak, std::memory_order_relaxed);
if (peak >= 1.f) meterClip_.store(true, std::memory_order_relaxed); if (peak >= 1.f) meterClip_.store(true, std::memory_order_relaxed);
for (int32 ch = 1; ch < out.numChannels; ++ch) { for (int32 ch = 1; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) { if (float* buf = out.channelBuffers32[ch]) {
+82 -24
View File
@@ -22,6 +22,7 @@
#include "core/instrument/map/component_state_io.h" // ComponentState codec #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/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/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/engine/voice_engine.h"
namespace reasampler::vst { namespace reasampler::vst {
@@ -128,16 +129,20 @@ public:
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
void** obj) override; void** obj) override;
// The embedded-strip activity level (0..1) for the embed shell, UI thread. The loudest of // The embedded-strip activity level (0..1) for the embed shell, UI thread. The last block's
// the two published bus peaks — one publication serves the strip and the meter. // 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 { double embedActivityLevel() const {
const float l = meterPeakL_.load(std::memory_order_relaxed); return static_cast<double>(advisoryPeak_.load(std::memory_order_relaxed));
const float r = meterPeakR_.load(std::memory_order_relaxed);
return static_cast<double>(l > r ? l : r);
} }
// What the audio thread published about the output bus last block. UI thread. // What the audio thread published since the LAST call: peaks maxed and minGain minimised
MasterBusMeter masterBusMeter() const; // 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; 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(); void clearMasterBusClip();
// Resolves the selection against the instance-owned SampleRefs, decodes its WAV // Resolves the selection against the instance-owned SampleRefs, decodes its WAV
@@ -235,15 +240,24 @@ public:
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()] void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
// The master-bus limiter's single enable (persisted in the parameter set). UI thread only: // 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 // a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the
// requests the host's kLatencyChanged restart, which the SDK requires be issued from the UI // audio thread INLINE — the sound follows the click — and only ARMS the host's latency
// thread and which process() must therefore never trigger. Setting the value it already // restart. Setting the value it already holds is a no-op, so repeated clicks on one segment
// holds is a no-op, so repeated clicks on one segment cost no restart. // cost no restart.
bool limiterEnabled() const { bool limiterEnabled() const {
return limiterEnabled_.load(std::memory_order_relaxed); return limiterEnabled_.load(std::memory_order_relaxed);
} }
void setLimiterEnabled(bool on); 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) — 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 // 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 // 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 // count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
@@ -274,6 +288,20 @@ private:
// swap as a full reload. No-op when nothing is loaded. Off the audio thread only. // swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
void rebuildVoiceEngine(); void rebuildVoiceEngine();
// The reactivation half of the activation/decode lifetime split (see dormantSample_):
// 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
// 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<LoadedInstrument> buildInstrumentLocked(SampleData sample);
// Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress // 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). // (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse).
bool legacyLiftShouldRun(); bool legacyLiftShouldRun();
@@ -281,16 +309,16 @@ private:
// Publishes `built` (null = install silence) into live_: prunes the graveyard by the // Publishes `built` (null = install silence) into live_: prunes the graveyard by the
// last process()-published generation, swaps `built` into live_, displaces the previous // 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. // 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<LoadedInstrument> built); void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// Publishes a silent block to the meter. EVERY process() path that emits no audio calls // Publishes a silent block. EVERY process() path that emits no audio calls this. It clears
// this, or the bar freezes at the last peak it saw. The clip latch is deliberately not // only the ADVISORY level, which is a last-block reading: the meter accumulators need
// touched — it survives silence until the user clears it. // 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() { void publishSilentMeterBlock() {
meterPeakL_.store(0.f, std::memory_order_relaxed); advisoryPeak_.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 // Mirrors the persisted limiter enable onto the audio thread and the latency reader. Called
@@ -359,6 +387,17 @@ private:
// Guarded by reloadMutex_, consumed by publishBuiltLocked. // Guarded by reloadMutex_, consumed by publishBuiltLocked.
std::optional<double> gainAtNextPublish_; std::optional<double> 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<SampleData> 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 // The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread. // audio thread.
std::mutex selectionMutex_; std::mutex selectionMutex_;
@@ -455,14 +494,33 @@ private:
// getLatencySamples answers from. // getLatencySamples answers from.
instrument::engine::Limiter limiter_; instrument::engine::Limiter limiter_;
std::atomic<bool> limiterEnabled_{false}; std::atomic<bool> limiterEnabled_{false};
// Raised (and lowered) by the commit funnel from the difference between the enable and
// 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<bool> latencyRestartPending_{false};
// 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<bool> latencyAnnounced_{false};
// What the audio thread publishes about the output bus each block, relaxed peaks, the // What the audio thread publishes about the output bus each block, relaxed. The peaks and
// latched clip, and the limiter's smallest gain. No dB, no ballistics, no hold timer here; // minGain ACCUMULATE (max / min) across every block since the UI last read, and
// the UI runs those off these values and its own elapsed time. // masterBusMeter() consumes them as it reads — the fix for a bar that displayed roughly one
std::atomic<float> meterPeakL_{0.f}; // block in fifty. The folds and the identity elements below are meter_accumulate's; no dB,
std::atomic<float> meterPeakR_{0.f}; // no ballistics, no hold timer here — the UI runs those off these values and its own
std::atomic<float> meterMinGain_{1.f}; // elapsed time.
std::atomic<float> meterPeakL_{instrument::engine::kMeterPeakIdentity};
std::atomic<float> meterPeakR_{instrument::engine::kMeterPeakIdentity};
std::atomic<float> meterMinGain_{instrument::engine::kMeterGainIdentity};
std::atomic<bool> meterClip_{false}; std::atomic<bool> 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<float> advisoryPeak_{0.f};
}; };
} // namespace reasampler::vst } // namespace reasampler::vst
+114 -432
View File
@@ -1,18 +1,16 @@
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no // 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 // framework. Pins WHICH descriptors the Sample face carries and how they resolve to a layout:
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), // the signal-flow group order (pitch -> filter -> amp), the Filter group's contents, the
// the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve // VELOCITY group's exclusive ownership of the three curve cells and its placement immediately
// cells and its placement immediately left of VOICE, the wrapped deck height at the editor's // left of VOICE, row membership, that no face leaves slack where its dropped controls were,
// floor width and its fit inside the floor window, the pinned Gate group widths, the editor // that a Gate/Spline/Gate round trip restores the layout exactly, the hit-test reaching the new
// floor derived from the deck's width budget and each group's categorical row, // filter controls, and the bipolar knob law's inverse pair. The width-BUDGET fixtures (the
// that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round // editor floor's derivation, the row/gutter arithmetic at the floor, MASTER's interior) live in
// trip restores the layout exactly, the hit-test reaching the new filter controls, the bipolar knob // test_deck_groups_measured.cpp, which needs sample_bands/master_meter and this file does not.
// law's inverse pair, the commit-tier routing — which controls are live, and which drags take // The commit-tier routing and the overlay-selection state machine live in
// the live tier — and the overlay-selection state machine (exclusivity, the none resting state, // test_deck_groups_state.cpp — they touch no layout at all.
// and which selections are inert).
#include "../src/core/instrument/ui/deck_groups.h" #include "../src/core/instrument/ui/deck_groups.h"
#include "../src/core/instrument/ui/sample_bands.h"
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
@@ -25,9 +23,14 @@ static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \ #define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) 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 // A pad and an available width for exercising layoutDeck, kept independent of sample_bands.h —
// the band allocator's kPad inset on each side. // this file pins what the deck IS, not the window-floor budget. kSampleAvail equals the real
static constexpr int kAvailAtMinWidth = kEditorMinWidth - 2 * kPad; // 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<DeckGroupDesc>& g, int id) { static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
for (std::size_t i = 0; i < g.size(); ++i) { 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. // treats them as knob cells, so the popup routing rides an ordinary Knob hit.
static void testVelocityCellsHitTestWithinTheirGroup() { static void testVelocityCellsHitTestWithinTheirGroup() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate); const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
const DeckGroupLayout& v = const DeckGroupLayout& v =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))]; dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
CHECK(v.cells.size() == 3); CHECK(v.cells.size() == 3);
@@ -125,10 +128,11 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel), cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
cell(DeckParam::kFilterKeyTrack)}; cell(DeckParam::kFilterKeyTrack)};
CHECK(f.cellIds == expected); CHECK(f.cellIds == expected);
// Off by default is a state question, but reachability is a layout one: the enable // Off by default is a state question, but reachability is a layout one: BOTH toggles now
// toggle is in the caption row and the morph law in the knob row. // ride the caption row, which is what takes the group from 524 to 432.
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable)); 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<std::size_t>(indexOfGroup(g, kGroupFilterEnv))]; const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
const std::vector<int> env = { const std::vector<int> env = {
@@ -141,14 +145,24 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
CHECK(fe.rowToggle.id == -1); CHECK(fe.rowToggle.id == -1);
} }
// Exactly the three envelope decks carry an overlay-select radio, each its own, and no other // Exactly the three envelope decks carry a SELECTABLE overlay radio, each its own, and no
// group has one — the exclusivity the shell enforces is only meaningful if the id space is. // other group has one — the exclusivity the shell enforces is only meaningful if the id space
static void testOnlyTheThreeEnvelopeDecksCarryARadio() { // 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}) { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode); const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int radios = 0; int radios = 0;
for (const DeckGroupDesc& d : g) { for (const DeckGroupDesc& d : g) {
if (d.captionRadio.id < 0) continue; 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; ++radios;
const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect) const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect)
: d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect) : d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect)
@@ -234,65 +248,37 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
CHECK(a.cellIds.size() == b.cellIds.size()); CHECK(a.cellIds.size() == b.cellIds.size());
CHECK(b.cellIds[4] == -1); // the Trigger face's one reserved blank 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. // 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() { // TWO rows plus the spanning deck, BY CONSTRUCTION: the row count is read off the group
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate); // inventory's own row assignment, not observed as a pack outcome, so it holds at every width.
// An UPPER BOUND, not an equality. The greedy whole-group wrap is still what decides row static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() {
// 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.
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);
}
}
// 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}) { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode); const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const int h = deckHeight(g, kAvailAtMinWidth); CHECK(deckRowCount(g) == 2);
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap);
CHECK(deckRowCount(g, kAvailAtMinWidth) <= 2); // either face; see the bound above CHECK(deckHeight(g) == 216);
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
// 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 — for (int avail : {kSampleAvail, kSampleAvail + 200, 4000}) {
// sample_bands stays independent of knob_deck, so neither header can hold it. This fixture is const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
// the only one that includes both. CHECK(dl.rowCount == 2);
static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() { CHECK(dl.height == 216);
CHECK(kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2 * kPad == kEditorMinWidth); CHECK(dl.groups.size() == g.size());
// The budget: what is left between the derived floor and the hard ceiling, and it is spent int rowTops[2] = {0, kDeckGroupH + kDeckRowGap};
// once. A cell costs 60 of it. for (const DeckGroupLayout& gl : dl.groups) {
CHECK(kEditorCeilingWidth - kEditorMinWidth == 90); const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
// The reflow's 112 px goes entirely to the waveform, so the height does not move. if (row == DeckRow::Spanning) {
CHECK(kEditorMinHeight == 680); CHECK(gl.box.y == 0);
CHECK(gl.box.height == kDeckSpanningH);
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);
}
}
}
}
} }
static void testEveryDeckGroupBelongsToExactlyOneRow() { static void testEveryDeckGroupBelongsToExactlyOneRow() {
@@ -320,41 +306,46 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() {
} }
} }
// What the budget can already be measured against. The contour row fits today and MASTER has // The gap fix as a property of the shipped descriptors, not a picture: whichever face a
// not touched its reserve; the SOUND row does not fit yet and must not be forced to — it is // mode-dependent group shows, its knob row still spans the group's whole reserved run. The
// 1030 against the 1020 block, and the 50 px deficit is exactly what two later descriptor // Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
// changes buy: PITCH becoming PITCH/RATE (+42) and FILTER's Band|Notch moving from the knob // dropped control was. What the run does not cover is the indivisible residue alone, strictly
// row to the caption corner (92), netting 980. The fit is asserted when they land, not here. // under one pixel per cell. Checked at both a tight and a genuinely wider width.
static void testTheContourRowAndTheSpanningDeckFitTheBudget() { static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { for (int avail : {kSampleAvail, kSampleAvailWide}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode); for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
int contourWidth = 0, contourGroups = 0, spanningWidth = 0; const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
for (const DeckGroupDesc& d : g) { const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(d.id)); CHECK(dl.groups.size() == g.size());
if (row == DeckRow::Contour) { for (std::size_t i = 0; i < dl.groups.size(); ++i) {
contourWidth += deckGroupWidth(d); // The spanning deck's slots STACK — the run-division law this pins is the
++contourGroups; // horizontal one, and its vertical guard is its own test.
} else if (row == DeckRow::Spanning) { if (g[i].row == DeckRow::Spanning) continue;
spanningWidth += deckGroupWidth(d); const DeckGroupLayout& lay = dl.groups[i];
const int reserved = static_cast<int>(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<int>(present));
CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX);
CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX);
} }
} }
// 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 "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() { static void testHitTestResolvesTheNewFilterControls() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate); const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); const DeckLayout dl = layoutDeck(g, kSamplePad, 40, kSampleAvail);
const DeckGroupLayout& f = const DeckGroupLayout& f =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))]; dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
@@ -377,10 +368,15 @@ static void testHitTestResolvesTheNewFilterControls() {
f.captionToggle.seg1.y + 2); f.captionToggle.seg1.y + 2);
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1); CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2); // The morph law answers from its NEW home in the caption row, and as a CaptionToggle —
CHECK(band.kind == DeckHitKind::RowToggle); // 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); 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); CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1);
// The filter-envelope knobs resolve too, and are distinct ids from the amp's. // The filter-envelope knobs resolve too, and are distinct ids from the amp's.
@@ -412,308 +408,6 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
CHECK(deckNormFromBipolar(3.0) == 1.0); 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,
};
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<int>(DeckParam::kCount); ++i) {
const DeckParam p = static_cast<DeckParam>(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<int>(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<int>(DeckParam::kFilterCutoff)) ==
LiveCommit::Reload);
}
// --- The overlay selection state machine ---------------------------------------
static int radio(DeckParam p) { return static_cast<int>(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).
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<DeckGroupDesc> 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) {
const DeckGroupLayout& lay = dl.groups[i];
const int reserved = static_cast<int>(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<int>(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<DeckGroupDesc> 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<int>(DeckParam::kKeyTrack));
CHECK(pitch->cellIds[1] == static_cast<int>(DeckParam::kRate));
CHECK(pitch->cellIds[2] == static_cast<int>(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
// 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<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const struct { int id; int width; } want[] = {
{kGroupPitch, 192}, {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);
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);
}
}
static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) { static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) {
return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1; return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1;
} }
@@ -725,8 +419,10 @@ static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
const DeckGroupLayout& x = a.groups[i]; const DeckGroupLayout& x = a.groups[i];
const DeckGroupLayout& y = b.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.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)) if (x.captionRadio.id != y.captionRadio.id ||
return false; !(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) || if (!sameToggle(x.captionToggle, y.captionToggle) ||
!sameToggle(x.captionToggle2, y.captionToggle2) || !sameToggle(x.captionToggle2, y.captionToggle2) ||
!sameToggle(x.rowToggle, y.rowToggle)) return false; !sameToggle(x.rowToggle, y.rowToggle)) return false;
@@ -747,12 +443,12 @@ static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
// forcing rule the real callers do not use. // forcing rule the real callers do not use.
static void testGateSplineGateRoundTripsToTheSameLayout() { static void testGateSplineGateRoundTripsToTheSameLayout() {
PlayParams p; // Gate, all three envelopes staged 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; p.ampSpline.mode = EnvMode::Spline;
enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through
CHECK(p.playMode == PlayMode::Trigger); 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. // The excursion is real: the amp face's cells are strictly wider than Gate's.
const DeckGroupLayout& gateAmp = const DeckGroupLayout& gateAmp =
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate), before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
@@ -767,40 +463,26 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
p.ampSpline.mode = EnvMode::Staged; p.ampSpline.mode = EnvMode::Staged;
CHECK(!splineActive(p)); CHECK(!splineActive(p));
p.playMode = PlayMode::Gate; // Gate is selectable again once nothing is drawn 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)); CHECK(sameLayout(before, after));
} }
int main() { int main() {
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
testClickingTheActiveOverlayRadioClearsToNone();
testANonRadioIdLeavesTheOverlaySelectionAlone();
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
testTheModeTogglesCostNoGroupWidth();
testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight(); testDeckReadsPitchThenFilterThenAmpLeftToRight();
testVelocityGroupOwnsTheThreeCurvesExclusively(); testVelocityGroupOwnsTheThreeCurvesExclusively();
testCurveTargetNamesEachCellsOwnDestination(); testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup(); testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation(); testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio(); testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
testGateAndTriggerFacesCarryTheirOwnShapes(); testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial(); testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip(); testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth(); testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testGateModeGroupWidthsAreUnchanged();
testGateSplineGateRoundTripsToTheSameLayout();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
testEveryDeckGroupBelongsToExactlyOneRow(); testEveryDeckGroupBelongsToExactlyOneRow();
testTheContourRowAndTheSpanningDeckFitTheBudget(); testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testHitTestResolvesTheNewFilterControls(); testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre(); testBipolarKnobLawRoundTripsAndIsExactAtCentre();
testGateSplineGateRoundTripsToTheSameLayout();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
return g_fail == 0 ? 0 : 1; return g_fail == 0 ? 0 : 1;
} }
+399
View File
@@ -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 <cstdio>
#include <vector>
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<DeckGroupDesc>& g, int id) {
for (std::size_t i = 0; i < g.size(); ++i) {
if (g[i].id == id) return static_cast<int>(i);
}
return -1;
}
static int cell(DeckParam p) { return static_cast<int>(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<DeckGroupDesc> 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<DeckGroupDesc> 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<int>(deckRowFor(static_cast<DeckGroupId>(d.id)));
width[r] += deckGroupWidth(d);
++count[r];
}
const int sound = static_cast<int>(DeckRow::Sound);
const int contour = static_cast<int>(DeckRow::Contour);
const int spanning = static_cast<int>(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<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const auto box = [&](int id) {
return dl.groups[static_cast<std::size_t>(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<DeckGroupDesc> 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<DeckGroupId>(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] = &gl;
}
const auto right = [&](int id) {
return dl.groups[static_cast<std::size_t>(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<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m =
dl.groups[static_cast<std::size_t>(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<std::size_t>(indexOfGroup(g, kGroupFilter))];
const DeckGroupLayout& amp =
dl.groups[static_cast<std::size_t>(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<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m =
dl.groups[static_cast<std::size_t>(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<DeckGroupDesc> 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<std::size_t>(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<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
DeckGroupDesc probe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
probe.captionWidth += 40; // unbalances it: the caption row now measures past the knob row
const std::vector<DeckGroupDesc> 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<DeckGroupDesc> 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<int>(DeckParam::kKeyTrack));
CHECK(pitch->cellIds[1] == static_cast<int>(DeckParam::kRate));
CHECK(pitch->cellIds[2] == static_cast<int>(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<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckGroupDesc& penv = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
const DeckGroupDesc& amp = g[static_cast<std::size_t>(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<DeckGroupDesc> 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<std::size_t>(i)]) == w.width);
const DeckGroupLayout& lay =
dl.groups[static_cast<std::size_t>(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;
}
+231
View File
@@ -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 <cstdio>
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<int>(DeckParam::kCount); ++i) {
const DeckParam p = static_cast<DeckParam>(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<int>(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<int>(DeckParam::kFilterCutoff)) ==
LiveCommit::Reload);
}
// --- The overlay selection state machine ---------------------------------------
static int radio(DeckParam p) { return static_cast<int>(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;
}
+4 -4
View File
@@ -306,7 +306,7 @@ static void testGutterAtTheSawtoothMaximumResidueWidth() {
// nor the floor is covered by another test firing if either ever changes. Derived from // 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 // 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 // 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. // them by hand if the floor ever moves.
static void testGutterAtTheShippedDefaultWindowSize() { static void testGutterAtTheShippedDefaultWindowSize() {
// Derive rootStrip's width the same way the shell does, through the real allocator + // 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 ChromeRects chrome = chromeRects(bands.chrome, /*knobSize=*/24);
const int stripW = chrome.rootStrip.width; const int stripW = chrome.rootStrip.width;
CHECK(stripW == kEditorMinWidth - 2 * kPad); CHECK(stripW == kEditorMinWidth - 2 * kPad);
CHECK(stripW == 1174); CHECK(stripW == 1182);
const StripLayout L = layoutStrip(stripW, 30); const StripLayout L = layoutStrip(stripW, 30);
CHECK(L.whiteWidth == 15); CHECK(L.whiteWidth == 15);
const int margins = L.band.width - L.keys.width; const int margins = L.band.width - L.keys.width;
CHECK(margins == 49); CHECK(margins == 57);
const int leftMargin = L.keys.x - L.band.x; const int leftMargin = L.keys.x - L.band.x;
CHECK(leftMargin == 24); CHECK(leftMargin == 28);
} }
int main() { int main() {
+182 -37
View File
@@ -5,8 +5,8 @@
// * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right // * 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. // 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. // * 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 // * rows — membership comes from the group's own DeckRow, never from a wrap outcome;
// always places; deckHeight consistency with deckRowCount. // 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 // * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
// misses, outside-deck misses. // misses, outside-deck misses.
// * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring, // * 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. // No toggles: max(caption, cells) + padding.
DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}}; DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX); 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 two-row deck with a spanning bus deck, shaped like the shipped one but with synthetic
// A width that forces this synthetic deck to wrap: TWO rows, whole trailing groups only. // widths: two Sound groups, two Contour groups, one Spanning group carrying a column.
// Deliberately narrower than the shipped editor floor — this pins the wrap MECHANISM, not static std::vector<DeckGroupDesc> tworowDeck() {
// the shipped deck's row count (that is deck_groups' own test). std::vector<DeckGroupDesc> g;
const auto deck = shellLikeDeck(); g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}});
CHECK(deckRowCount(deck, 544) == 2); g.push_back({1, 38, {}, {101, 48}, {}, {6, 7}, {}, DeckRow::Sound, {}});
CHECK(deckHeight(deck, 544) == 2 * kDeckGroupH + kDeckRowGap); g.push_back({2, 58, {}, {102, 32}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}});
const DeckLayout dl = layoutDeck(deck, 8, 100, 544); g.push_back({3, 38, {}, {103, 40}, {}, {11}, {}, DeckRow::Contour, {}});
CHECK(dl.rowCount == 2); g.push_back({4, 46, {200, true}, {104, 32}, {}, {12, -1}, {},
CHECK(dl.height == deckHeight(deck, 544)); DeckRow::Spanning, {300, 62}});
CHECK(dl.groups.size() == 5); return g;
// 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; // Row membership is the GROUP's, and nothing about the width can change it: the same list at
const int row1Top = row0Top + kDeckGroupH + kDeckRowGap; // three very different widths lays out as the same two rows plus the same spanning deck.
CHECK(dl.groups[0].box.y == row0Top); static void testRowMembershipComesFromTheGroupNotTheWidth() {
CHECK(dl.groups[1].box.y == row0Top); const auto deck = tworowDeck();
bool sawWrap = false; CHECK(deckRowCount(deck) == 2);
for (std::size_t i = 1; i < dl.groups.size(); ++i) { CHECK(deckHeight(deck) == 2 * kDeckGroupH + kDeckRowGap);
if (dl.groups[i].box.y == row1Top && dl.groups[i - 1].box.y == row0Top) { CHECK(deckHeight(deck) == kDeckSpanningH);
CHECK(dl.groups[i].box.x == 8); // wrapped row restarts at the left edge
sawWrap = true; 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() { // Space-between: slack becomes gutters, divided equally with the integer residue on the
// A group wider than the row still places (degenerate width) — exactly one row per group. // LEFTMOST ones, and the row ends flush against the block. Decks are never stretched.
const auto deck = shellLikeDeck(); static void testJustificationSpreadsSlackIntoEqualGutters() {
CHECK(deckRowCount(deck, 100) == 5); const auto deck = tworowDeck();
CHECK(deckHeight(deck, 100) == 5 * kDeckGroupH + 4 * kDeckRowGap); 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<DeckGroupDesc> 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<std::size_t>(i + 1)].box.x -
d4.groups[static_cast<std::size_t>(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<DeckGroupDesc> 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<DeckGroupDesc> 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() { static void testGroupInnerGeometry() {
@@ -401,16 +542,20 @@ static void testInKnobFaceUsesTheSmallerDimensionOnANonSquareRect() {
static void testEmptyDeck() { static void testEmptyDeck() {
const std::vector<DeckGroupDesc> none; const std::vector<DeckGroupDesc> none;
CHECK(deckRowCount(none, 800) == 0); CHECK(deckRowCount(none) == 0);
CHECK(deckHeight(none, 800) == 0); CHECK(deckHeight(none) == 0);
const DeckLayout dl = layoutDeck(none, 0, 0, 800); const DeckLayout dl = layoutDeck(none, 0, 0, 800);
CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0); CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0);
} }
int main() { int main() {
testGroupWidth(); testGroupWidth();
testWrapAtNarrowWidthIsDeterministic(); testRowMembershipComesFromTheGroupNotTheWidth();
testFirstGroupAlwaysPlaces(); testJustificationSpreadsSlackIntoEqualGutters();
testTooNarrowFloorsTheGuttersRatherThanWrapping();
testSpanningColumnStacksFixedSlotsAndCarriesItsReadout();
testPassiveRadioIsLaidOutButNeverHit();
testSpanningOnlyDeckKeepsItsHeight();
testGroupInnerGeometry(); testGroupInnerGeometry();
testHitTest(); testHitTest();
testReservedCellWidthGoesToTheCellsPresent(); testReservedCellWidthGoesToTheCellsPresent();
+317
View File
@@ -0,0 +1,317 @@
// Standalone tests for reasampler::instrument::ui::master_meter — no VST3, no REAPER, no
// framework. Assert:
//
// * 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 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"
#include <cmath>
#include <cstdio>
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. 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());
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 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 LaneSplit split = resolveLaneSplit(stereoMode, sourceChannels);
const MeterRects m = meterRects(kColumn, split);
const int bars = m.barB.empty() ? 1 : 2;
// 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));
}
}
}
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());
// 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() {
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 — 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(std::fabs(s.left.holdDb - (held - kMeterFallDbPerSecond * 0.49)) < 1e-9);
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, 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);
// 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));
// 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() {
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();
testEveryOtherTickCarriesANumeral();
testTheFloorNumeralStaysInsideTheGutter();
testAColumnTooNarrowForTheInteriorDrawsNothing();
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
testPeakHoldSitsForItsFullWindowThenReleases();
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
testSingleLaneStateFoldsBothChannelsPerField();
testGrLampLitOnlyWhileTheLimiterReduces();
testGrLampSurvivesTheFiveHundredMillisecondTick();
testDrawEqualityCoversTheDrawnQuantities();
testDegenerateColumnYieldsNothing();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
return 1;
}
std::printf("master_meter tests passed\n");
return 0;
}
+107
View File
@@ -0,0 +1,107 @@
// 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 <atomic>
#include <cstdio>
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<float> 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,
// which is what makes the retry count assertable: a strong CAS fails only under interference.
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_strong(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<float> 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<float> 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<float> 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;
}
+4 -4
View File
@@ -131,10 +131,10 @@ static void testDeckBandIsBottomAnchoredAtTheEditorFloor() {
CHECK(b.decks.bottom() == kEditorMinHeight - kPad); CHECK(b.decks.bottom() == kEditorMinHeight - kPad);
} }
// At a representative two-row deck height (216px — the ceiling test_deck_groups.cpp bounds // At the shipped two-row deck height (216px — what test_deck_groups.cpp pins the deck to by
// the wrapped deck to), the waveform gets exactly what the floor's own height leaves it: an // construction, at and above the floor width), the waveform gets exactly what the floor's own
// equality, not a bound, so a floor-height change that quietly ate into the waveform's slack // height leaves it: an equality, not a bound, so a floor-height change that quietly ate into
// would fail here rather than only widen/narrow a `>=`. // the waveform's slack would fail here rather than only widen/narrow a `>=`.
static void testWaveformGetsExactlyTheFloorsRemainingHeightAtATwoRowDeck() { static void testWaveformGetsExactlyTheFloorsRemainingHeightAtATwoRowDeck() {
constexpr int twoRowDeckH = 216; constexpr int twoRowDeckH = 216;
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, twoRowDeckH); const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, twoRowDeckH);
+38
View File
@@ -531,6 +531,43 @@ static void testRefreshRefsFromBankUpsertAndOwnership() {
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40); 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() { static void testRetainRefsFiltersToPlayedSet() {
// getState hygiene: only the entries the instance currently plays persist — the table // getState hygiene: only the entries the instance currently plays persist — the table
// cannot grow with browsing history. Order of survivors is preserved. // cannot grow with browsing history. Order of survivors is preserved.
@@ -1015,6 +1052,7 @@ int main() {
testReferencedSampleIdsIsTheLoadedCapture(); testReferencedSampleIdsIsTheLoadedCapture();
testFindRefLooksUpTheOwnedCopy(); testFindRefLooksUpTheOwnedCopy();
testRefreshRefsFromBankUpsertAndOwnership(); testRefreshRefsFromBankUpsertAndOwnership();
testSameDecodeSourceTracksEveryDecodeInput();
testRetainRefsFiltersToPlayedSet(); testRetainRefsFiltersToPlayedSet();
testLegacyLiftDecision(); testLegacyLiftDecision();
testResolvePlayConvertsWallClockAtTheRate(); testResolvePlayConvertsWallClockAtTheRate();