Merge Phase Ω-W1: mode-switch responsiveness and the control surface's second pass
Five tracks: deferred FX park with honest undo mask and synchronous drain before persist; narrowed mode-switch persist; master meter on its own 60 FPS timer with dirty-rect paint; deck reflow with single-button toggles and click-to-focus overlays; one overlay-to-waveform coordinate mapping.
This commit is contained in:
+9
-7
@@ -868,9 +868,11 @@ the golden test literals pin would roll a format change and a codec extraction
|
||||
together, which is the riskier order.
|
||||
|
||||
**Also over the bar, blocked differently.** `src/shell/view/view.cpp` measures
|
||||
**642 lines** (verified this pass). Its seam is blocked not by a private-state/friend
|
||||
question but by a build file another team owns: `src/shell/view/` has no
|
||||
`CMakeLists.txt` of its own today.
|
||||
**625 lines** (re-measured after the deferred FX-park split took `fxGuidString`,
|
||||
`liveFxGuids` and the park/restore FX writes out into `view_fx_park`). Its remaining
|
||||
seam is blocked not by a private-state/friend question but by a build file another
|
||||
team owns: `src/shell/view/` has no `CMakeLists.txt` of its own today — a new TU
|
||||
there costs one `target_sources` line in `src/app/CMakeLists.txt` instead.
|
||||
|
||||
**Priority / risk.** Not stated.
|
||||
|
||||
@@ -879,10 +881,10 @@ question but by a build file another team owns: `src/shell/view/` has no
|
||||
sidestepped), dropping the file under the ~600-line ceiling; `view.cpp`'s own path is
|
||||
unblocked once the build-file ownership question is resolved.
|
||||
|
||||
## FX-GUID stability for `restoreFxOffline` is unverified in the DAW
|
||||
## FX-GUID stability for `applyRestore` is unverified in the DAW
|
||||
|
||||
**Context.** The Design View park/restore FX keying (`restoreFxOffline`,
|
||||
`src/shell/view/view.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
|
||||
**Context.** The Design View park/restore FX keying (`applyRestore`,
|
||||
`src/shell/view/view_fx_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
|
||||
survives a chain reorder while a track is parked. SWS issue #802 reports that after
|
||||
`SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin
|
||||
(`SNM_PreObjectState()` → `RemoveAllIds()`) — if that still holds, an SWS-driven
|
||||
@@ -893,7 +895,7 @@ operation this keying targets.
|
||||
instances of the same plugin.
|
||||
|
||||
**Already flagged in code — this entry is the tracked home, not a restatement.**
|
||||
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view.cpp` and
|
||||
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_fx_park.cpp` and
|
||||
a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating
|
||||
them in full.
|
||||
|
||||
|
||||
@@ -94,31 +94,43 @@ contours are row 2's first two groups.
|
||||
|
||||
### 1.2 The measured layout
|
||||
|
||||
> **Updated for Ω-W1-T4's filter-mod move and toggle conversion.** The table below was
|
||||
> re-measured against the shipped `deck_groups.cpp`/`knob_deck.cpp` after two changes this
|
||||
> track made: the filter's mod-depth cell moved from FILTER to FILTER ENV (one cell each
|
||||
> way), and the six toggles that used to be two-segment (`Off\|On` on PITCH ENV/FILTER/MASTER,
|
||||
> `Staged\|Spline` on PITCH ENV/FILTER ENV/AMP ENVELOPE) are now the single-button
|
||||
> `kEnable`/`kMode` styles — see `knob_deck.h`'s `DeckToggleStyle`. The per-deck corner
|
||||
> radio these three envelope decks carried is gone (superseded by the click-to-focus
|
||||
> overlay border, `core/instrument/CLAUDE.md`'s envelope-overlay section); MASTER alone
|
||||
> keeps the corner slot, for its passive gain-reduction lamp.
|
||||
|
||||
`deckGroupWidth(g) = max(captionRowWidth, knobRowWidth) + 2·kDeckGroupPadX`, with
|
||||
`captionRowWidth = captionWidth + Σ(kDeckToggleGap + 2·segWidth) + (radio ? 4 + 12 : 0)`
|
||||
and `knobRowWidth = |cellIds|·kDeckCellW (+ 4 + 2·segWidth for a rowToggle)`. Metrics:
|
||||
`kDeckCellW 60`, `kDeckCellH 74`, `kDeckKnobSize 40`, `kDeckCellLabelH 16`,
|
||||
`kDeckCaptionH 20`, `kDeckToggleH 18`, `kDeckGroupPadX 6`, `kDeckGroupPadY 4`,
|
||||
`kDeckCaptionGap 2`, `kDeckToggleGap 4`, `kDeckGroupGap 12`, `kDeckRowGap 8`,
|
||||
`kDeckRadioSize 12`, `kDeckGroupH 104`.
|
||||
`captionRowWidth = captionWidth + Σ(kDeckToggleGap + toggleWidth) + (radio ? kDeckToggleGap +
|
||||
kDeckRadioSize : 0)` and `knobRowWidth = |cellIds|·kDeckCellW + (rowToggle ? kDeckToggleGap +
|
||||
rowToggleWidth : 0)`. `toggleWidth` is the WHOLE control either way — a `kSegmented` toggle
|
||||
just halves it into two segments at that width; a single-button `kEnable`/`kMode` toggle
|
||||
draws the whole span as one button. Metrics: `kDeckCellW 60`, `kDeckCellH 74`, `kDeckKnobSize
|
||||
40`, `kDeckCellLabelH 16`, `kDeckCaptionH 20`, `kDeckToggleH 18`, `kDeckGroupPadX 6`,
|
||||
`kDeckGroupPadY 4`, `kDeckCaptionGap 2`, `kDeckToggleGap 4`, `kDeckGroupGap 12`, `kDeckRowGap
|
||||
8`, `kDeckRadioSize 12`, `kDeckGroupH 104`, `kEnableBtnW 52`, `kEnvModeW 46`.
|
||||
|
||||
| Group | Row | Caption run | Knob run | **Width** | Δ | Control inventory |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **PITCH/RATE** | 1 | 70 + 4 + 2·48 = 170 | 3 × 60 = **180** | **192** | +42 | 3 cells `Key Trk` / `Rate` / `Pitch`; caption toggle `Varisp\|Presrv` (48) |
|
||||
| **FILTER** | 1 | 46 + 4 + 2·32 + 4 + 2·44 = 206 | 7 × 60 = **420** | **432** | −92 | 7 cells (morph, cutoff, Q, drive, mod amt, vel, key trk); caption toggle `Off\|On` (32); **caption toggle 2 `Band\|Notch` (44) — moved from the knob row** |
|
||||
| **PITCH/RATE** | 1 | 70 + 4 + 2·48 = 170 | 3 × 60 = **180** | **192** | +42 | 3 cells `Key Trk` / `Rate` / `Pitch`; caption toggle `Varisp\|Presrv` (96, segmented) |
|
||||
| **FILTER** | 1 | 46 + 4 + 52 + 4 + 2·44 = 194 | 6 × 60 = **360** | **372** | −60 vs Γ | 6 cells (morph, cutoff, Q, drive, vel, key trk) — **mod amt left for FILTER ENV**; caption toggle `Filter` (52, single-button ENABLE — was `Off\|On`); caption toggle 2 `Band\|Notch` (88, segmented) |
|
||||
| **VELOCITY** | 1 | 54 | 3 × 60 = **180** | **192** | 0 | 3 curve-popup cells (amp, pitch, filter) |
|
||||
| **VOICE** | 1 | 38 + 4 + 2·40 = 122 | 60 + 4 + 2·44 = **152** | **164** | 0 | 1 cell (voice count); caption toggle `Poly\|Mono` (40); **row toggle `Retrig\|Legato` (44) stays** — see note |
|
||||
| **PITCH ENV** | 2 | 58 + 4 + 64 + 4 + 46 + 4 + 12 = 192 | 4 × 60 = **240** | **252** | 0 | 4 cells (A, H, D, Depth); caption toggle `Off\|On`; caption toggle 2 `Staged\|Spline`; corner radio |
|
||||
| **FILTER ENV** | 2 | 66 + 4 + 46 + 4 + 12 = 132 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: A,H,D + 2 reserves); caption toggle 2; corner radio |
|
||||
| **AMP ENVELOPE** | 2 | 78 + 4 + 88 + 4 + 46 + 4 + 12 = 236 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle `Gate\|Trig` (44); caption toggle 2; corner radio |
|
||||
| **MASTER** | **1+2** | 46 + 4 + 2·32 + 4 + 12 = **130** | 60 + 8 + 62 = **130** | **142** | +70 | 1 cell (gain, upper-left); 1 **reserved** lower-left slot; caption toggle `Limiter Off\|On` (32); corner **bubble** (12, passive); **meter column 62 px, full double height** |
|
||||
| **VOICE** | 1 | 38 + 4 + 2·40 = 122 | 60 + 4 + 2·44 = **152** | **164** | 0 | 1 cell (voice count); caption toggle `Poly\|Mono` (80, segmented); **row toggle `Retrig\|Legato` (88, segmented) stays** — see note |
|
||||
| **PITCH ENV** | 2 | 58 + 4 + 52 + 4 + 46 = 164 | 4 × 60 = **240** | **252** | 0 | 4 cells (A, H, D, Depth); caption toggle `Envelope` (52, single-button ENABLE — was `Off\|On`); caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE — was `Staged\|Spline`); **no corner radio** |
|
||||
| **FILTER ENV** | 2 | 66 + 4 + 46 = 116 | 6 × 60 = **360** | **372** | +60 vs Γ | 6 slots (Gate: A,H,D,S,R,**Mod** / Trigger: A,H,D + 2 reserves,**Mod**) — **mod amt arrives from FILTER**; caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE); **no corner radio**; no enable toggle of its own (FILTER's governs both) |
|
||||
| **AMP ENVELOPE** | 2 | 78 + 4 + 88 + 4 + 46 = 220 | 5 × 60 = **300** | **312** | 0 | 5 slots (Gate: A,H,D,S,R / Trigger: Len,A,H,D + 1 reserve); caption toggle `Gate\|Trig` (88, segmented); caption toggle 2 reads `Stage`/`Spline` (46, single-button MODE — was `Staged\|Spline`); **no corner radio** |
|
||||
| **MASTER** | **1+2** | 46 + 4 + 52 + 4 + 12 = **118** | 60 + 8 + 62 = **130** | **142** | +70 | 1 cell (gain, upper-left); 1 **reserved** lower-left slot; caption toggle `Limiter` (52, single-button ENABLE — was `Off\|On`); corner **bubble** (12, passive); **meter column 62 px, full double height** |
|
||||
|
||||
**Row totals.**
|
||||
|
||||
| | Natural content | Gutters at floor | **Row width** |
|
||||
|---|---|---|---|
|
||||
| Row 1 | 192 + 432 + 192 + 164 = **980** | 16 + 16 + 16 = 48 | **1028** |
|
||||
| Row 2 | 252 + 312 + 312 = **876** | 76 + 76 = 152 | **1028** |
|
||||
| Row 1 | 192 + 372 + 192 + 164 = **920** | 36 + 36 + 36 = 108 | **1028** |
|
||||
| Row 2 | 252 + 372 + 312 = **936** | 46 + 46 = 92 | **1028** |
|
||||
|
||||
**Window floor.**
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ add_library(reaper_reasampler MODULE
|
||||
${LICE_SRC}
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view_fx_park.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
|
||||
@@ -59,6 +60,14 @@ target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model
|
||||
# link graph free of the voice engine — a link edge to it here means the design drifted.
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
|
||||
# The deferred FX-park queue's re-entrancy rule is pure (header-inline, no REAPER
|
||||
# types), so it is CTest-covered like a core/ module. Declared here rather than in a
|
||||
# src/shell/view/CMakeLists.txt because that directory deliberately has none — its
|
||||
# TUs are compiled into this target directly. view_mode_model is linked for
|
||||
# makeRestorePlan alone: the cancel path's round trip is a contract between the two,
|
||||
# and pinning it against hand-built ops would not catch a change to either half.
|
||||
reasampler_test(view_fx_park LINK fx_offline view_mode_model)
|
||||
|
||||
# Bank-package import: the promptless verb plus its action skin. Kept as its own
|
||||
# appended block rather than merged into the lists above, so the two package
|
||||
# directions stay textually independent.
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
|
||||
#include "shell/persist/session.h" // ReaSamplerSession
|
||||
#include "shell/view/view.h" // reconcileManagedLanes / applyMode
|
||||
#include "shell/view/view_fx_park.h" // the mode switch's deferred FX park
|
||||
|
||||
namespace capture = reasampler::capture;
|
||||
|
||||
@@ -180,12 +181,24 @@ static void OnTimer()
|
||||
// reapply so re-arm and model restore ride the one load event (otherwise
|
||||
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
|
||||
if (g_session.consumeLoadSignal()) {
|
||||
// Every path that raises this signal — open, project switch, recycled
|
||||
// pointer, undo/redo state restore — replaced the model the pending FX
|
||||
// intents were planned against, so they are discarded rather than applied
|
||||
// to the project that replaced it. This is the queue's ONLY guard against
|
||||
// a recycled ReaProject*, which a pointer compare cannot see.
|
||||
reasampler::discardDeferredFxParks();
|
||||
reasampler::bankPanelNotifyProjectLoaded();
|
||||
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
|
||||
// the cross-session source of truth) BEFORE reapplying visibility. Never
|
||||
// re-mints, never mass-tags.
|
||||
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
|
||||
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
|
||||
} else {
|
||||
// A mode switch applies its visibility/routing writes synchronously and
|
||||
// leaves the per-FX offline work here, so the new mode paints before the
|
||||
// plugins unload. Idle cost is one empty test. Skipped on a load tick so
|
||||
// the reapply's own intents defer one tick like any other switch's.
|
||||
reasampler::drainDeferredFxParks();
|
||||
}
|
||||
|
||||
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
|
||||
|
||||
@@ -218,9 +218,14 @@ automatable parameters."* It rejects the precedent, not one instance of it.
|
||||
### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)
|
||||
|
||||
The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a
|
||||
transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at
|
||||
most one is overlay-active, and **none is a valid resting state — the editor opens there.**
|
||||
Never persisted; it selects what is drawn, not what is played.
|
||||
transient editor choice: **clicking anywhere in an envelope deck (amp, pitch, filter) — panel,
|
||||
knob or button — focuses that deck's overlay**, and the focused deck takes an `AccentPrimary`
|
||||
border. At most one is overlay-active, and **none is a valid resting state — the editor opens
|
||||
there**, reached by clicking a control surface outside the three decks. Focus SETS rather than
|
||||
toggles, so editing the deck you selected cannot deselect it; the overlay itself neither steals
|
||||
nor clears focus for the same reason. Never persisted; it selects what is drawn, not what is
|
||||
played. *(The per-deck corner radio this replaced is gone; MASTER keeps the corner slot for its
|
||||
passive gain-reduction lamp.)*
|
||||
|
||||
**The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round
|
||||
mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a
|
||||
@@ -336,15 +341,15 @@ anything for a trigger shape.
|
||||
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
|
||||
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||
- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
||||
- **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
|
||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the band's DRAWN COLUMN SPAN at the band's full height, in both modes: same inset and same width as `component_geometry`'s `waveformColumnCount`, so overlay pixel and waveform column are one pixel and every overlay lands on the column that draws the frame it names. Deriving that width instead of restating the inset is what keeps the two chains from drifting apart again. 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. Which of them are PRESENT is the mode's answer, and drawn iff grabbable: Trigger carries START alone (the loop pair and the fade are absent, not dim), Gate always carries the pair so the drag-to-set-loop gesture survives the enable being off, and the fade needs an active loop to have a seam. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
|
||||
- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition.
|
||||
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
|
||||
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
|
||||
- `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
|
||||
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
|
||||
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n−1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n−1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, and the cells present are CENTRED in it at their natural `kDeckCellW`**: a `-1` id reserves one cell's width without a cell, and the reserve is paid for in symmetric end margins, never in wider cells. 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 stretching the knobs off their pitch; only the reserve COUNT matters, so where a `-1` sits in `cellIds` is free. Two rejected laws, recorded so they are not re-proposed: dropping the reserves (reflows every neighbour on a mode flip, which is what the reserves exist to prevent), and dividing the run between the cells present (the retired law — it handed a reduced face 90–100 px cells). A SPANNING group is the one place a reserve's position still matters, because there it advances a fixed slot.
|
||||
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
|
||||
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
|
||||
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
|
||||
@@ -362,8 +367,8 @@ anything for a trigger shape.
|
||||
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
|
||||
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||
labels for them.
|
||||
- `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.
|
||||
- `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. Also owns the editor's two paint-dispatch predicates, so neither lives as a bare comment in the shell: `meterFastPathEligible` (is a WM_PAINT dirty rect wholly inside the field?) and `meterBarsWithinField` (the self-containment invariant that fast path rests on), both asserted in `test_master_meter.cpp`.
|
||||
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `overlayEnvForGroup`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-FOCUS state machine (which deck names which envelope, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the −92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164.
|
||||
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
|
||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
|
||||
@@ -37,6 +37,10 @@ using ParamId = std::uint32_t;
|
||||
// control added later numerically beside its siblings instead of at the end of the table.
|
||||
// 1500-1599 (VELOCITY) and 1600-1699 (VOICE) are RESERVED and empty — a control either group
|
||||
// ever gains lands in its own range rather than in whatever range happened to be free.
|
||||
// A block names the signal-flow STAGE, not the deck group a control happens to be drawn in, and
|
||||
// the two have already diverged once: kParamFilterModAmount (1240) stays in the FILTER block
|
||||
// while its knob moved to the FILTER ENVELOPE deck. Ids key on the control, so a redraw never
|
||||
// renumbers — the freeze is what makes annotating this the fix rather than moving the row.
|
||||
enum : ParamId {
|
||||
kParamKeyTrackPitch = 1000,
|
||||
kParamRate = 1010,
|
||||
|
||||
@@ -22,10 +22,14 @@ reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
|
||||
|
||||
# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit — the meter's bar
|
||||
# count consumes that answer, so the type is part of this module's surface, not an internal.
|
||||
# component_geometry is PRIVATE: the overlay area is DERIVED from the draw chain's own column
|
||||
# count so the two cannot drift, but no type of it appears in this module's header.
|
||||
reasampler_pure_library(waveform_view
|
||||
SOURCES waveform_view.cpp
|
||||
LINK PUBLIC editor_geometry peaks sample_bands)
|
||||
reasampler_test(waveform_view LINK waveform_view sample_bands)
|
||||
LINK PUBLIC editor_geometry peaks sample_bands PRIVATE component_geometry)
|
||||
# component_geometry is linked for the test too: proving the overlay rides the drawn column
|
||||
# band takes the real column count, not a copy of it.
|
||||
reasampler_test(waveform_view LINK waveform_view sample_bands component_geometry)
|
||||
|
||||
# The loop enable's state machine. Links loop_span for the park bounds — the span the user is
|
||||
# offered and the span the engine accepts stay one definition.
|
||||
|
||||
@@ -12,11 +12,23 @@ namespace {
|
||||
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); }
|
||||
|
||||
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
|
||||
// no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob
|
||||
// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
|
||||
// the CONTOUR row, which has 152px of slack, not the SOUND row.
|
||||
constexpr int kEnvModeSegW = 23;
|
||||
// The three Staged|Spline mode selectors, as ONE button reading "Stage" or "Spline". Sized so
|
||||
// each env group's caption row stays no wider than its knob row; the binding group is PITCH
|
||||
// ENV, which reaches its four-cell knob row at 122 (AMP, the next tightest, at 126).
|
||||
constexpr int kEnvModeW = 46;
|
||||
|
||||
// The single-button enables ("Envelope" / "Filter" / "Limiter"). One width because they are one
|
||||
// control grammar. MASTER is the group that BINDS it: its knob row measures 130 (the caption
|
||||
// row, at this width, measures 118), so anything past 64 widens kDeckSpanningW and spends the
|
||||
// editor's width budget.
|
||||
constexpr int kEnableBtnW = 52;
|
||||
|
||||
DeckToggleDesc enableButton(DeckParam p) {
|
||||
return {id(p), kEnableBtnW, DeckToggleStyle::kEnable};
|
||||
}
|
||||
DeckToggleDesc envModeButton(DeckParam p) {
|
||||
return {id(p), kEnvModeW, DeckToggleStyle::kMode};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
|
||||
@@ -27,14 +39,14 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
// PITCH/RATE. The three cells make the knob row 180, which is what the group measures
|
||||
// from; the caption row (caption + gap + two 48px segments) must stay under it, so the
|
||||
// from; the caption row (caption + gap + the 96px toggle) must stay under it, so the
|
||||
// caption reserve has a hard ceiling of 80 — past that the caption row overtakes the knob
|
||||
// row and the group grows past 192. Widening the group is not the answer if the text ever
|
||||
// outgrows 80: narrow the Varisp|Presrv segments to 44 instead.
|
||||
// outgrows 80: narrow the Varisp|Presrv toggle to 88 instead.
|
||||
DeckGroupDesc pitch;
|
||||
pitch.id = kGroupPitch;
|
||||
pitch.captionWidth = 70;
|
||||
pitch.captionToggle = {id(DeckParam::kPitchEngine), 48};
|
||||
pitch.captionToggle = {id(DeckParam::kPitchEngine), 96};
|
||||
pitch.cellIds = {id(DeckParam::kKeyTrack), id(DeckParam::kRate), id(DeckParam::kPitch)};
|
||||
out.push_back(std::move(pitch));
|
||||
}
|
||||
@@ -42,11 +54,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
DeckGroupDesc penv;
|
||||
penv.id = kGroupPitchEnv;
|
||||
penv.captionWidth = 58;
|
||||
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
|
||||
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
|
||||
penv.captionToggle = enableButton(DeckParam::kPitchEnvEnable);
|
||||
// The mode toggle rides the caption slack rather than the knob row — costs no group
|
||||
// width; see this module's CLAUDE.md bullet (knob_deck) for the headroom this relies on.
|
||||
penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW};
|
||||
penv.captionToggle2 = envModeButton(DeckParam::kPitchEnvMode);
|
||||
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
|
||||
id(DeckParam::kPitchEnvHold),
|
||||
id(DeckParam::kPitchEnvDecay),
|
||||
@@ -54,38 +65,41 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
out.push_back(std::move(penv));
|
||||
}
|
||||
{
|
||||
// Tone shaping left-to-right, then the three modulation depths that all target cutoff.
|
||||
// Tone shaping left-to-right, then the two modulation depths that stay with the tone
|
||||
// stage. The envelope's own depth sits with the envelope — see FILTER ENV below.
|
||||
DeckGroupDesc filter;
|
||||
filter.id = kGroupFilter;
|
||||
filter.captionWidth = 46;
|
||||
filter.captionToggle = {id(DeckParam::kFilterEnable), 32};
|
||||
filter.captionToggle = enableButton(DeckParam::kFilterEnable);
|
||||
filter.cellIds = {id(DeckParam::kFilterMorph),
|
||||
id(DeckParam::kFilterCutoff),
|
||||
id(DeckParam::kFilterQ),
|
||||
id(DeckParam::kFilterDrive),
|
||||
id(DeckParam::kFilterModAmt),
|
||||
id(DeckParam::kFilterVel),
|
||||
id(DeckParam::kFilterKeyTrack)};
|
||||
// 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};
|
||||
filter.captionToggle2 = {id(DeckParam::kFilterLaw), 88};
|
||||
out.push_back(std::move(filter));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc fenv;
|
||||
fenv.id = kGroupFilterEnv;
|
||||
fenv.captionWidth = 66;
|
||||
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
|
||||
fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW};
|
||||
fenv.captionToggle2 = envModeButton(DeckParam::kFilterEnvMode);
|
||||
// The mod DEPTH sits with the envelope it scales, last in the run — the kPitchEnvDepth
|
||||
// precedent above. Why moving it renumbers nothing is param_id.h's, at the id table.
|
||||
if (trigger) {
|
||||
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
|
||||
id(DeckParam::kFilterTrigDecay), -1, -1};
|
||||
id(DeckParam::kFilterTrigDecay), -1, -1,
|
||||
id(DeckParam::kFilterModAmt)};
|
||||
} else {
|
||||
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
|
||||
id(DeckParam::kFilterEnvHold),
|
||||
id(DeckParam::kFilterEnvDecay),
|
||||
id(DeckParam::kFilterEnvSustain),
|
||||
id(DeckParam::kFilterEnvRelease)};
|
||||
id(DeckParam::kFilterEnvRelease),
|
||||
id(DeckParam::kFilterModAmt)};
|
||||
}
|
||||
out.push_back(std::move(fenv));
|
||||
}
|
||||
@@ -93,9 +107,8 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
DeckGroupDesc amp;
|
||||
amp.id = kGroupAmpEnv;
|
||||
amp.captionWidth = 78;
|
||||
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
|
||||
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
|
||||
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
|
||||
amp.captionToggle = {id(DeckParam::kPlayMode), 88};
|
||||
amp.captionToggle2 = envModeButton(DeckParam::kAmpEnvMode);
|
||||
if (trigger) {
|
||||
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
|
||||
// the row reads like the drawn envelope. One reserve (-1) keeps the group's width —
|
||||
@@ -125,9 +138,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
DeckGroupDesc voice;
|
||||
voice.id = kGroupVoice;
|
||||
voice.captionWidth = 38;
|
||||
voice.captionToggle = {id(DeckParam::kVoiceMode), 40};
|
||||
voice.captionToggle = {id(DeckParam::kVoiceMode), 80};
|
||||
voice.cellIds = {id(DeckParam::kVoiceCount)};
|
||||
voice.rowToggle = {id(DeckParam::kMonoTrigger), 44};
|
||||
voice.rowToggle = {id(DeckParam::kMonoTrigger), 88};
|
||||
out.push_back(std::move(voice));
|
||||
}
|
||||
{
|
||||
@@ -138,7 +151,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
master.id = kGroupMaster;
|
||||
master.captionWidth = 46;
|
||||
master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true};
|
||||
master.captionToggle = {id(DeckParam::kLimiterEnable), 32};
|
||||
master.captionToggle = enableButton(DeckParam::kLimiterEnable);
|
||||
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.
|
||||
@@ -286,27 +299,12 @@ LiveCommit deckParamCommit(DeckParam id) {
|
||||
return LiveCommit::Reload; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
OverlayEnv overlayEnvForRadio(int radioId) {
|
||||
switch (static_cast<DeckParam>(radioId)) {
|
||||
case DeckParam::kAmpEnvSelect: return OverlayEnv::kAmp;
|
||||
case DeckParam::kPitchEnvSelect: return OverlayEnv::kPitch;
|
||||
case DeckParam::kFilterEnvSelect: return OverlayEnv::kFilter;
|
||||
default: return OverlayEnv::kNone;
|
||||
}
|
||||
}
|
||||
|
||||
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
|
||||
const OverlayEnv picked = overlayEnvForRadio(radioId);
|
||||
if (picked == OverlayEnv::kNone) return current; // not a radio: nothing selects
|
||||
return (current == picked) ? OverlayEnv::kNone : picked;
|
||||
}
|
||||
|
||||
OverlayEnv overlayEnvForModeToggle(int toggleId) {
|
||||
switch (static_cast<DeckParam>(toggleId)) {
|
||||
case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp;
|
||||
case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch;
|
||||
case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter;
|
||||
default: return OverlayEnv::kNone;
|
||||
OverlayEnv overlayEnvForGroup(int groupId) {
|
||||
switch (groupId) {
|
||||
case kGroupAmpEnv: return OverlayEnv::kAmp;
|
||||
case kGroupPitchEnv: return OverlayEnv::kPitch;
|
||||
case kGroupFilterEnv: return OverlayEnv::kFilter;
|
||||
default: return OverlayEnv::kNone;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ enum class DeckParam {
|
||||
kAmpVelCurve,
|
||||
kPitchVelCurve,
|
||||
kFilterVelCurve,
|
||||
// Overlay selection radios — transient view state, not parameters.
|
||||
// Retired overlay-selection radios. The deck itself is the overlay's click target now, so
|
||||
// no group carries these — they survive only because param/'s frozen tables enumerate the
|
||||
// whole id space and must stay total. Do not re-add a radio for them.
|
||||
kAmpEnvSelect,
|
||||
kPitchEnvSelect,
|
||||
kFilterEnvSelect,
|
||||
@@ -201,14 +203,13 @@ LiveCommit liveCommitFor(LiveDragKind kind, int paramId);
|
||||
// never persisted, never a parameter.
|
||||
enum class OverlayEnv { kNone, kAmp, kPitch, kFilter };
|
||||
|
||||
// The envelope a deck's overlay-select radio picks; kNone for any other control id.
|
||||
OverlayEnv overlayEnvForRadio(int radioId);
|
||||
|
||||
// The selection a click on `radioId` produces from `current`. Two rules, provable here rather
|
||||
// than in the shell: picking another deck's radio switches to it (exclusivity), and clicking
|
||||
// the ACTIVE one clears back to kNone — "no envelope shown" is a state the user can get back
|
||||
// to, not an error. A non-radio id leaves the selection alone.
|
||||
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
|
||||
// The selection a click inside deck group `groupId` produces — THE overlay focus map, and the
|
||||
// whole state machine now that the deck is its own click target. An envelope deck names its
|
||||
// envelope; every other group, and every point outside the deck (-1), names kNone, which is
|
||||
// how a click on a control surface outside the envelope decks clears the focus. Exclusive and
|
||||
// IDEMPOTENT by construction: re-clicking a focused deck re-selects it rather than toggling to
|
||||
// none, so a knob tweak on the focused deck cannot unfocus it.
|
||||
OverlayEnv overlayEnvForGroup(int groupId);
|
||||
|
||||
// The group states the two inert predicates below read. One struct rather than a growing
|
||||
// parameter list, so adding a gate is a change at the two predicates and nowhere else.
|
||||
@@ -220,9 +221,6 @@ struct DeckEnableState {
|
||||
bool filterSpline = false;
|
||||
};
|
||||
|
||||
// Which envelope a Staged|Spline mode toggle belongs to; kNone for any other control id.
|
||||
OverlayEnv overlayEnvForModeToggle(int toggleId);
|
||||
|
||||
// Whether `env`'s deck group is switched on at all. Amp has no enable toggle and is always on.
|
||||
// The gate BOTH overlay modes share — a disabled group's contour is as dead as its knobs.
|
||||
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state);
|
||||
|
||||
@@ -182,6 +182,17 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
enforceGateUnavailableWhileDrawn(play);
|
||||
}
|
||||
|
||||
int nextToggleSegment(DeckParam id, const PlaySeconds& play) {
|
||||
switch (id) {
|
||||
case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 0 : 1;
|
||||
case DeckParam::kFilterEnable: return play.filter.enabled ? 0 : 1;
|
||||
case DeckParam::kAmpEnvMode: return play.ampSpline.mode == EnvMode::Spline ? 0 : 1;
|
||||
case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 0 : 1;
|
||||
case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 0 : 1;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// deckParamNorm and setDeckParam carry each id's MAP — which taper, which clamp; these two carry
|
||||
// only its LOCATION, which is the whole mechanism of the taper-free reset (see deck_values.h for
|
||||
// why they are exposed beyond that one caller). A toggle, radio or curve cell has no reset gesture
|
||||
|
||||
@@ -59,6 +59,13 @@ double storedFromNorm(DeckParam id, double norm);
|
||||
// Mutates `play` in place, touching exactly the one field the control names.
|
||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
|
||||
|
||||
// The segment a SINGLE-BUTTON toggle commits: its NEXT state, derived from what `play` holds
|
||||
// now, because a one-button control carries no segment of its own. This is what keeps
|
||||
// setDeckParam's segment contract the only one there is rather than growing a second commit
|
||||
// path beside it. Answers -1 for every control that is not a single-button parameter-set
|
||||
// toggle — the segmented ones already carry the segment the user clicked.
|
||||
int nextToggleSegment(DeckParam id, const PlaySeconds& play);
|
||||
|
||||
// Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no
|
||||
// second table of defaults to drift from the real one, and the value is COPIED rather than
|
||||
// round-tripped through norm -> value. That bypass is MANDATORY: a reset must land on the stored
|
||||
|
||||
@@ -24,7 +24,7 @@ int knobRowWidth(const DeckGroupDesc& g) {
|
||||
int w = static_cast<int>(g.cellIds.size()) * kDeckCellW;
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (w > 0) w += kDeckToggleGap;
|
||||
w += 2 * g.rowToggle.segWidth;
|
||||
w += g.rowToggle.width;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
@@ -32,12 +32,34 @@ int knobRowWidth(const DeckGroupDesc& g) {
|
||||
// The caption-row width: the caption reserve plus the optional caption toggle and radio.
|
||||
int captionRowWidth(const DeckGroupDesc& g) {
|
||||
int w = g.captionWidth;
|
||||
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
|
||||
if (g.captionToggle2.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle2.segWidth;
|
||||
if (g.captionToggle.id >= 0) w += kDeckToggleGap + g.captionToggle.width;
|
||||
if (g.captionToggle2.id >= 0) w += kDeckToggleGap + g.captionToggle2.width;
|
||||
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
|
||||
return w;
|
||||
}
|
||||
|
||||
// One toggle inside [right - d.width, right) at `top`. A segmented toggle splits that span in
|
||||
// two, the left half taking an odd pixel; a single-button style takes the whole span in seg0
|
||||
// and leaves seg1 empty, which is what makes its hit carry no segment.
|
||||
DeckToggleLayout layoutToggle(const DeckToggleDesc& d, int right, int top) {
|
||||
const Rect whole = Rect::ltrb(right - d.width, top, right, top + kDeckToggleH);
|
||||
if (d.style != DeckToggleStyle::kSegmented) return {d.id, whole, Rect{}, d.style};
|
||||
const int split = whole.right() - d.width / 2;
|
||||
return {d.id, Rect::ltrb(whole.x, top, split, whole.bottom()),
|
||||
Rect::ltrb(split, top, whole.right(), whole.bottom()), d.style};
|
||||
}
|
||||
|
||||
// The toggle segment a point lands on: 0/1 when segmented, -1 for the whole of a single
|
||||
// button, and -2 for a miss (a real segment index cannot be negative, and a single button's
|
||||
// own answer is already -1).
|
||||
int toggleSegmentAt(const DeckToggleLayout& t, int x, int y) {
|
||||
if (t.id < 0) return -2;
|
||||
if (t.style != DeckToggleStyle::kSegmented) return contains(t.seg0, x, y) ? -1 : -2;
|
||||
if (contains(t.seg0, x, y)) return 0;
|
||||
if (contains(t.seg1, x, y)) return 1;
|
||||
return -2;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -81,12 +103,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
||||
const auto placeToggle = [&](const DeckToggleDesc& d, DeckToggleLayout& into) {
|
||||
if (d.id < 0) return;
|
||||
const int segW = d.segWidth;
|
||||
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
|
||||
togTop + kDeckToggleH);
|
||||
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
|
||||
into = DeckToggleLayout{d.id, seg0, seg1};
|
||||
captionRight = seg0.x - kDeckToggleGap;
|
||||
into = layoutToggle(d, captionRight, togTop);
|
||||
captionRight -= d.width + kDeckToggleGap;
|
||||
// Caption text stops at the leftmost toggle: pull the right edge in (XYWH: width).
|
||||
out.caption.width = captionRight - out.caption.x;
|
||||
};
|
||||
@@ -120,32 +138,30 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
|
||||
// id, reserves included). Integer division puts an indivisible residue in symmetric end
|
||||
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
|
||||
// Knob row: every cell keeps its natural kDeckCellW and the RUN of them is CENTRED in the
|
||||
// whole reserved width (one kDeckCellW per declared id, reserves included). That is what
|
||||
// holds a group's width across a mode flip without stretching the knobs off their pitch —
|
||||
// a face with no reserves gets the identical run either way. Do not reintroduce dividing
|
||||
// the run between the cells present: it hands a reduced face 90-100 px cells.
|
||||
const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
|
||||
int presentCells = 0;
|
||||
for (int id : g.cellIds) {
|
||||
if (id >= 0) ++presentCells;
|
||||
}
|
||||
const int cellW = presentCells > 0 ? runWidth / presentCells : 0;
|
||||
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
|
||||
int x = innerLeft + (runWidth - presentCells * kDeckCellW) / 2;
|
||||
for (int id : g.cellIds) {
|
||||
if (id < 0) continue;
|
||||
out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW,
|
||||
out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + kDeckCellW,
|
||||
cellTop + kDeckCellH)));
|
||||
x += cellW;
|
||||
x += kDeckCellW;
|
||||
}
|
||||
if (g.rowToggle.id >= 0) {
|
||||
// Anchored past the whole reserved run, not past the last cell, so a residue margin
|
||||
// Anchored past the whole reserved run, not past the last cell, so a centring margin
|
||||
// cannot shift it.
|
||||
int tx = innerLeft + runWidth;
|
||||
if (!g.cellIds.empty()) tx += kDeckToggleGap;
|
||||
const int segW = g.rowToggle.segWidth;
|
||||
const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2;
|
||||
const Rect seg0 = Rect::ltrb(tx, togTop, tx + segW, togTop + kDeckToggleH);
|
||||
const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH);
|
||||
out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1};
|
||||
out.rowToggle = layoutToggle(g.rowToggle, tx + g.rowToggle.width,
|
||||
cellTop + (kDeckCellH - kDeckToggleH) / 2);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -254,32 +270,30 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
if (!contains(g.box, x, y)) continue;
|
||||
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, g.id};
|
||||
}
|
||||
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
|
||||
if (t->id < 0) continue;
|
||||
if (contains(t->seg0, x, y)) return {DeckHitKind::CaptionToggle, t->id, 0};
|
||||
if (contains(t->seg1, x, y)) return {DeckHitKind::CaptionToggle, t->id, 1};
|
||||
const int seg = toggleSegmentAt(*t, x, y);
|
||||
if (seg != -2) return {DeckHitKind::CaptionToggle, t->id, seg, false, g.id};
|
||||
}
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (contains(g.rowToggle.seg0, x, y))
|
||||
return {DeckHitKind::RowToggle, g.rowToggle.id, 0};
|
||||
if (contains(g.rowToggle.seg1, x, y))
|
||||
return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
|
||||
{
|
||||
const int seg = toggleSegmentAt(g.rowToggle, x, y);
|
||||
if (seg != -2) return {DeckHitKind::RowToggle, g.rowToggle.id, seg, false, g.id};
|
||||
}
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
// Every entry here already has a real id — a reserve yields no DeckCellLayout at all.
|
||||
if (contains(c.cell, x, y)) {
|
||||
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
|
||||
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y), g.id};
|
||||
}
|
||||
}
|
||||
if (g.column.id >= 0 && contains(g.column.box, x, y)) {
|
||||
return {DeckHitKind::Column, g.column.id, -1, false};
|
||||
return {DeckHitKind::Column, g.column.id, -1, false, g.id};
|
||||
}
|
||||
// Inside the box but on fence/padding — a miss. First-match is exact while the boxes
|
||||
// Inside the box but on fence/padding — a control miss that still names the group, so
|
||||
// the deck panel's own background stays a target. 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 {DeckHitKind::None, -1, -1, false, g.id};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -65,10 +65,19 @@ inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck
|
||||
// the whole width budget for the life of this layout (asserted in test_deck_groups.cpp) — see
|
||||
// instrument-control-surface.md §1.6 before spending any of it.
|
||||
|
||||
// A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent.
|
||||
// What a click on a toggle MEANS, which is also what it draws as.
|
||||
// kSegmented — two segments side by side; the click picks one, and the hit carries 0 or 1.
|
||||
// kEnable — ONE button labelled with what it controls; on/off, the hit carries NO segment.
|
||||
// kMode — ONE button whose LABEL reads the current mode; it has no off state.
|
||||
// The two single-button styles carry no segment precisely so a commit cannot read one: the
|
||||
// caller derives the next state from the current one (deck_values' nextToggleSegment).
|
||||
enum class DeckToggleStyle { kSegmented, kEnable, kMode };
|
||||
|
||||
// A compact toggle. id -1 = absent.
|
||||
struct DeckToggleDesc {
|
||||
int id = -1; // shell control id returned by the hit-test; -1 = no toggle
|
||||
int segWidth = 44; // px per segment
|
||||
int id = -1; // shell control id returned by the hit-test; -1 = no toggle
|
||||
int width = 88; // the WHOLE control's px width, both styles — a segmented one halves it
|
||||
DeckToggleStyle style = DeckToggleStyle::kSegmented;
|
||||
};
|
||||
|
||||
// A single-square corner radio (an exclusive selector across groups, so the group itself
|
||||
@@ -88,13 +97,14 @@ struct DeckColumnDesc {
|
||||
};
|
||||
|
||||
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
|
||||
// reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
|
||||
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
|
||||
// reserves for the caption text (this module does not measure text).
|
||||
// reserves one cell's WIDTH without a cell, and the cells present are CENTRED in the whole
|
||||
// run at their natural kDeckCellW — see this module's CLAUDE.md bullet for what that buys.
|
||||
// Only the reserve COUNT matters, never where a -1 sits in the list. `captionWidth` is the px
|
||||
// the shell reserves for the caption text (this module does not measure text).
|
||||
//
|
||||
// A SPANNING group reads `cellIds` down instead of across: one FIXED kDeckCellW slot per
|
||||
// declared id, at successive row baselines, reserves included. The run-division law above is
|
||||
// horizontal only — applied vertically it would stretch a lone knob over the whole box.
|
||||
// declared id, at successive row baselines, reserves included — and there a reserve's POSITION
|
||||
// does matter, because it advances the slot.
|
||||
struct DeckGroupDesc {
|
||||
int id = 0; // shell group id (opaque here)
|
||||
int captionWidth = 60;
|
||||
@@ -114,8 +124,9 @@ struct DeckGroupDesc {
|
||||
|
||||
struct DeckToggleLayout {
|
||||
int id = -1;
|
||||
Rect seg0; // left segment
|
||||
Rect seg1; // right segment
|
||||
Rect seg0; // left segment; the WHOLE button under either single-button style
|
||||
Rect seg1; // right segment; EMPTY under either single-button style
|
||||
DeckToggleStyle style = DeckToggleStyle::kSegmented;
|
||||
};
|
||||
|
||||
struct DeckRadioLayout {
|
||||
@@ -190,8 +201,12 @@ enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Col
|
||||
struct DeckHit {
|
||||
DeckHitKind kind = DeckHitKind::None;
|
||||
int id = -1; // the control id of the hit element (cell id / toggle id / radio id)
|
||||
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
|
||||
int segment = -1; // 0/1 for a SEGMENTED toggle hit; -1 otherwise, single buttons included
|
||||
bool inner = false; // Knob hits only: the grab landed on the cell's inner dial
|
||||
// The group whose box contains the point, INDEPENDENT of `kind` — a click on a group's
|
||||
// fence padding is a control miss but still names the deck it landed on, which is what
|
||||
// lets the whole panel be a target. -1 only when the point is outside every group.
|
||||
int group = -1;
|
||||
};
|
||||
|
||||
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
|
||||
|
||||
@@ -26,6 +26,24 @@ MeterRects meterRects(const Rect& column, LaneSplit split) {
|
||||
return r;
|
||||
}
|
||||
|
||||
bool meterFastPathEligible(const MeterRects& m, const Rect& dirty) {
|
||||
return !m.field.empty() && dirty.x >= m.field.x && dirty.y >= m.field.y &&
|
||||
dirty.right() <= m.field.right() && dirty.bottom() <= m.field.bottom();
|
||||
}
|
||||
|
||||
namespace {
|
||||
bool rectWithin(const Rect& outer, const Rect& inner) {
|
||||
return inner.empty() ||
|
||||
(inner.x >= outer.x && inner.right() <= outer.right() && inner.y >= outer.y &&
|
||||
inner.bottom() <= outer.bottom());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool meterBarsWithinField(const MeterRects& m) {
|
||||
if (m.field.empty()) return m.barA.empty() && m.barB.empty();
|
||||
return rectWithin(m.field, m.barA) && rectWithin(m.field, m.barB);
|
||||
}
|
||||
|
||||
Rect meterNumeralRect(const Rect& labels, int y) {
|
||||
if (labels.empty()) return {};
|
||||
int top = y - 5;
|
||||
|
||||
@@ -42,6 +42,19 @@ struct MeterRects {
|
||||
// A column narrower than kMeterColumnW yields nothing rather than an overrunning field.
|
||||
MeterRects meterRects(const Rect& column, LaneSplit split);
|
||||
|
||||
// Whether `dirty` (a WM_PAINT update rect, already client-clipped) lies wholly inside the bar
|
||||
// field — the editor's meter-frame fast-path test. Geometric, not a flag: the caller decides
|
||||
// per paint from the rect Windows actually handed it, never from a remembered "was this a
|
||||
// meter tick" bit, because Windows unions a meter invalidate with any other pending one into a
|
||||
// single rcPaint and a flag would then paint a meter frame over a face that had really changed.
|
||||
bool meterFastPathEligible(const MeterRects& m, const Rect& dirty);
|
||||
|
||||
// The structural invariant drawMeterField's self-containment rests on: every bar it paints
|
||||
// stays inside the field it fills first. The ticks, the held-peak tick and the clip cap are all
|
||||
// drawn directly off `field`'s own coordinates and so are bounded by construction; the bars are
|
||||
// the one independently-computed rect that isn't.
|
||||
bool meterBarsWithinField(const MeterRects& m);
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -17,12 +17,11 @@ constexpr int kToolbarHeight = 58;
|
||||
constexpr int kStripBandHeight = 30;
|
||||
|
||||
constexpr int kRunGap = 6; // between adjacent items of the toolbar run
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
// The loop enable's segments carry a two-word label, so they are wider than Mono|Stereo's.
|
||||
// If the title slot ever fails to hold its text at the editor's floor, THIS narrows — the
|
||||
constexpr int kChanBtnW = 60; // fits the longer of the two mode labels ("Stereo")
|
||||
constexpr int kChanBtnH = 18; // shared height of the Loop and channel-mode single buttons
|
||||
// If the title slot ever fails to hold its text at the editor's floor, THESE narrow — the
|
||||
// floor does not move.
|
||||
constexpr int kLoopSegW = 58;
|
||||
constexpr int kLoopBtnW = 52;
|
||||
constexpr int kVelCellW = 56;
|
||||
constexpr int kHoldCellW = 56; // the bake Hold cell, same grammar as the velocity cell
|
||||
constexpr int kVelLabelH = 16;
|
||||
@@ -47,8 +46,7 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
|
||||
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
|
||||
|
||||
// The fixed run, right to left: Browse, Mono|Stereo, Loop Off|On, velocity cell, preview,
|
||||
// bake, hold.
|
||||
// The fixed run, right to left: Browse, channel, loop, velocity cell, preview, bake, hold.
|
||||
// The velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
|
||||
const int navH = std::min(kRunButtonH, row.height);
|
||||
const int navTop = topFor(navH);
|
||||
@@ -56,22 +54,18 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
r.navBrowse = Rect::ltrb(leftOf(navRight, kNavButtonWidth), navTop, navRight,
|
||||
navTop + navH);
|
||||
|
||||
const int chanTop = topFor(kChanSegH);
|
||||
const int chanTop = topFor(kChanBtnH);
|
||||
const int chanRight = leftOf(r.navBrowse.x, kRunGap);
|
||||
r.chanStereo = Rect::ltrb(leftOf(chanRight, kChanSegW), chanTop, chanRight,
|
||||
chanTop + kChanSegH);
|
||||
r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x,
|
||||
chanTop + kChanSegH);
|
||||
r.channel = Rect::ltrb(leftOf(chanRight, kChanBtnW), chanTop, chanRight,
|
||||
chanTop + kChanBtnH);
|
||||
|
||||
const int loopRight = leftOf(r.chanMono.x, kRunGap);
|
||||
r.loopOn = Rect::ltrb(leftOf(loopRight, kLoopSegW), chanTop, loopRight,
|
||||
chanTop + kChanSegH);
|
||||
r.loopOff = Rect::ltrb(leftOf(r.loopOn.x, kLoopSegW), chanTop, r.loopOn.x,
|
||||
chanTop + kChanSegH);
|
||||
const int loopRight = leftOf(r.channel.x, kRunGap);
|
||||
r.loop = Rect::ltrb(leftOf(loopRight, kLoopBtnW), chanTop, loopRight,
|
||||
chanTop + kChanBtnH);
|
||||
|
||||
const int cellH = std::min(row.height, knobSize + kVelLabelH);
|
||||
const int cellTop = topFor(cellH);
|
||||
const int cellRight = leftOf(r.loopOff.x, kRunGap);
|
||||
const int cellRight = leftOf(r.loop.x, kRunGap);
|
||||
r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH);
|
||||
const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2;
|
||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||
|
||||
@@ -33,13 +33,12 @@ struct ChromeRects {
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
// The sustain loop's enable. Immediately left of the channel toggle because it is the same
|
||||
// class of control — a playback mode of the loaded capture — and because the run is
|
||||
// right-anchored, so the title slot absorbs its width and the editor's floor does not move.
|
||||
Rect loopOff;
|
||||
Rect loopOn;
|
||||
Rect chanMono;
|
||||
Rect chanStereo;
|
||||
// The sustain loop's enable, ONE button. Immediately left of the channel selector because
|
||||
// it is the same class of control — a playback mode of the loaded capture — and because the
|
||||
// run is right-anchored, so the title slot absorbs its width and the editor's floor does
|
||||
// not move.
|
||||
Rect loop;
|
||||
Rect channel; // the mono/stereo MODE selector: its label reads the current mode
|
||||
Rect navBrowse;
|
||||
Rect controls; // full-width second row
|
||||
Rect rootStrip; // the piano strip: the whole row, inset only by the shared band pad
|
||||
|
||||
@@ -37,9 +37,10 @@ SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Bo
|
||||
SplineGesture gesture, int x, int y);
|
||||
|
||||
// The contour's mapping box inside the waveform overlay: the FULL area, so the drawn contour
|
||||
// spans the whole sample width 1:1 with its time axis. No inset — unlike the popup's box, which
|
||||
// insets to keep endpoint handles clear of the sheet border, this one must stay 1:1 with the
|
||||
// waveform beneath it. Takes the overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||
// spans the whole sample width 1:1 with its time axis. It adds no inset of its own — unlike the
|
||||
// popup's box, which insets to keep endpoint handles clear of the sheet border — because the
|
||||
// overlay area IS the drawn column span already, which is what makes the 1:1 claim true rather
|
||||
// than off by the waveform's inset. Takes the overlay (not a lane) — see waveform_view.h.
|
||||
VelocityCurve::Box splineOverlayBox(const OverlayArea& area);
|
||||
|
||||
// Two more rules complete the grammar. Both are enforced in the shell — mouse-tracking / drag
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdlib> // std::abs (int overload)
|
||||
|
||||
#include "core/instrument/ui/sample_bands.h" // waveformLanes (the band's lane inventory)
|
||||
#include "core/ui/component_geometry.h" // waveformColumnCount (THE draw chain's columns)
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
@@ -21,7 +21,14 @@ std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
|
||||
} // namespace
|
||||
|
||||
OverlayArea waveformOverlayArea(const Rect& band) {
|
||||
return OverlayArea{band.empty() ? Rect{} : band};
|
||||
if (band.empty()) return OverlayArea{};
|
||||
// The overlay IS the drawn column band, so an overlay pixel and a waveform column are the
|
||||
// same pixel. Both the width and the inset are DERIVED from the draw chain's own column
|
||||
// count rather than restated here — a second copy of the inset is how the two drifted
|
||||
// apart, and test_component_geometry pins the symmetry this halving rests on.
|
||||
const int columns = ::reasampler::ui::waveformColumnCount(band);
|
||||
if (columns <= 0) return OverlayArea{};
|
||||
return OverlayArea{Rect{band.x + (band.width - columns) / 2, band.y, columns, band.height}};
|
||||
}
|
||||
|
||||
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels) {
|
||||
@@ -52,9 +59,12 @@ int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t fram
|
||||
const int w = std::max(0, area.rect.width);
|
||||
if (frameCount <= 0 || w <= 0) return area.rect.x;
|
||||
const std::int64_t f = clampFrame(frame, frameCount);
|
||||
// x = left + round(f * w / frameCount); multiply before divide to keep this exact.
|
||||
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
|
||||
return area.rect.x + static_cast<int>(num / frameCount);
|
||||
// The exact inverse of the partition below: the column holding frame f is
|
||||
// ceil((f+1)*w/N) - 1, written as one truncating divide. min(w) covers only the closed
|
||||
// domain's top end — f == frameCount is a span's EXCLUSIVE edge, not a frame, and lands on
|
||||
// the band's right edge. (f+1)*w stays inside int64 for any drawable band.
|
||||
const std::int64_t col = ((f + 1) * static_cast<std::int64_t>(w) - 1) / frameCount;
|
||||
return area.rect.x + static_cast<int>(std::min<std::int64_t>(col, w));
|
||||
}
|
||||
|
||||
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
|
||||
@@ -63,10 +73,11 @@ std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
|
||||
if (frameCount <= 0 || w <= 0) return 0;
|
||||
if (x <= r.x) return 0;
|
||||
if (x >= r.right()) return frameCount;
|
||||
const std::int64_t dx = static_cast<std::int64_t>(x - r.x);
|
||||
// Inverse of frameToX: frame = round(dx * frameCount / w).
|
||||
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
|
||||
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
|
||||
// THE partition, in the same integer form peaks' computeEnvelope/columnMinMax bin with:
|
||||
// column c owns frames [c*N/w, (c+1)*N/w). This answers a column's FIRST frame, so it is
|
||||
// the frame that column actually draws.
|
||||
const std::int64_t col = static_cast<std::int64_t>(x - r.x);
|
||||
return clampFrame(col * frameCount / static_cast<std::int64_t>(w), frameCount);
|
||||
}
|
||||
|
||||
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame) {
|
||||
@@ -158,13 +169,13 @@ std::int64_t resolveDragFrame(const OverlayArea& area, std::int64_t frameCount,
|
||||
if (dxPixels == 0) return start;
|
||||
const int w = std::max(0, area.rect.width);
|
||||
if (frameCount <= 0 || w <= 0) return start; // no room to move
|
||||
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
|
||||
const std::int64_t magnitude =
|
||||
(static_cast<std::int64_t>(std::abs(dxPixels)) * frameCount +
|
||||
static_cast<std::int64_t>(w) / 2) /
|
||||
static_cast<std::int64_t>(w);
|
||||
const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude;
|
||||
return clampFrame(start + shift, frameCount);
|
||||
// THE unified mapping, not a second one: find the start frame's own column (frameToX),
|
||||
// walk it by dxPixels, and read the frame back off the resulting pixel (xToFrame) — so a
|
||||
// drag always resolves to the column under the cursor, never a proportional approximation
|
||||
// of it. A prior independent linear map here could disagree with frameToX/xToFrame's
|
||||
// truncating column partition, most visibly when frames < columns.
|
||||
const int grabX = frameToX(area, frameCount, start);
|
||||
return xToFrame(area, frameCount, grabX + dxPixels);
|
||||
}
|
||||
|
||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||
|
||||
@@ -21,15 +21,15 @@ using audio::AudioSample;
|
||||
// What the waveform band actually draws: the channel lane(s), and THE rect every overlay
|
||||
// riding the waveform occupies.
|
||||
//
|
||||
// OVERLAY CONTRACT — `overlay` is the whole band in BOTH modes, never a lane. The amp
|
||||
// envelope trace and its node handles, the start/loop markers, and the loop region draw
|
||||
// OVERLAY CONTRACT — `overlay` spans the whole band's HEIGHT in BOTH modes, never a lane. The
|
||||
// amp envelope trace and its node handles, the start/loop markers, and the loop region draw
|
||||
// ONCE into `overlay`, spanning both stacked lanes in stereo. Hit-testing reads the same
|
||||
// rect, so a grab in the lower lane resolves to the same overlay item as one in the upper.
|
||||
// Anything that draws per lane is a duplicate and a defect.
|
||||
struct WaveformSurface {
|
||||
Rect upper; // lane 0 -> channel 0 (LEFT); the whole band when single-lane
|
||||
Rect lower; // lane 1 -> channel 1 (RIGHT); empty() when single-lane
|
||||
OverlayArea overlay; // the full band, both modes
|
||||
OverlayArea overlay; // the band's drawn column span, full height, both modes
|
||||
int laneCount = 0; // 0 on a degenerate band, else 1 or 2 — matches `lower`'s emptiness
|
||||
// (2 iff lower non-empty). For a non-empty band <= 2px tall, `upper`
|
||||
// can be empty too while this still reports 1 — unreachable through
|
||||
@@ -51,6 +51,11 @@ WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceCha
|
||||
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
|
||||
// paths that have no channel count to hand. An overlay's rect never depends on the lane
|
||||
// split, which is exactly the contract.
|
||||
//
|
||||
// It is the band's DRAWN COLUMN SPAN, not the band: same inset, same width as
|
||||
// component_geometry's waveformColumnCount, so overlay pixel i and waveform column i are one
|
||||
// pixel. Full band HEIGHT still — the inset is horizontal only. Empty when the band is too
|
||||
// narrow to hold a column.
|
||||
OverlayArea waveformOverlayArea(const Rect& band);
|
||||
|
||||
// The single-channel envelope lane `lane` draws, taken from a multi-channel envelope
|
||||
@@ -64,13 +69,37 @@ audio::Envelope laneEnvelope(const audio::Envelope& env, int lane);
|
||||
// edge-grab idiom.
|
||||
inline constexpr int kMarkerGrabWidth = 5;
|
||||
|
||||
// x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right().
|
||||
// Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins
|
||||
// every frame to area.x. Takes the overlay (not a lane) — see the OVERLAY CONTRACT above.
|
||||
// --- THE frame<->pixel mapping ---------------------------------------------------------------
|
||||
//
|
||||
// ONE map, shared with the waveform draw: column c owns frames [c*N/w, (c+1)*N/w), which is
|
||||
// peaks' own bin partition, over an area that is exactly the drawn column span. So a mark, the
|
||||
// loop fill, a wedge and the contour all land on the column that draws the frame they name.
|
||||
// A SECOND frame->pixel map anywhere is the defect this pair exists to prevent.
|
||||
//
|
||||
// SAMPLE-ACCURATE WHEN frames < columns: one frame then spans several columns, and frameToX
|
||||
// answers the LAST of them — that end is what the exact inverse of the partition above yields
|
||||
// in BOTH regimes, so it needs one formula rather than a regime branch. The mark is inside its
|
||||
// own frame's span either way, and xToFrame round-trips it exactly (xToFrame(frameToX(f)) == f
|
||||
// for every f whenever frames <= columns, i.e. exactly where the choice of end is observable).
|
||||
// Above that, several frames share a column and the round trip snaps to the column's first
|
||||
// frame, which is the quantization the shared column already is.
|
||||
//
|
||||
// KNOWN CONVENTION MISMATCH, not a bug: in the frames < columns regime a mark lands on the
|
||||
// LAST column of its frame's span (above), while the spline contour and the staged AHD
|
||||
// polyline map normalized t in [0,1] straight across the same box and so land on a span's
|
||||
// FIRST column instead. Both land inside the frame's own span, so the mapping contract holds,
|
||||
// but the two conventions can disagree by up to a span's width for captures under ~1000
|
||||
// frames. Unifying them is a real option, left alone here since it touches the spline/AHD draw
|
||||
// path rather than this pair.
|
||||
|
||||
// x pixel of `frame`: the column that draws it. `frameCount` itself is a span's EXCLUSIVE end,
|
||||
// not a frame, and maps to area.right(). Frame is clamped to [0, frameCount] first;
|
||||
// frameCount <= 0 or a zero-width area pins every frame to area.x. Takes the overlay (not a
|
||||
// lane) — see the OVERLAY CONTRACT above.
|
||||
int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame);
|
||||
|
||||
// Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of
|
||||
// area.x yields 0; right of area.right() yields frameCount.
|
||||
// The FIRST frame the column at `x` draws — the partition's own begin, so this is the frame
|
||||
// that column shows. A point left of area.x yields 0; right of area.right() yields frameCount.
|
||||
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x);
|
||||
|
||||
// A marker's grab HANDLE — THE CAP, in the mark grammar's vocabulary: a tab riding the top of
|
||||
@@ -148,9 +177,11 @@ int crossfadeWedgeHeight(int x0, int x1, int x);
|
||||
int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount /
|
||||
// areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g.
|
||||
// start <= loopEnd) after this per-marker resolve.
|
||||
// Resolves a drag to a new frame: locates `startFrame`'s own column via frameToX, walks it by
|
||||
// dxPixels, and reads the frame back via xToFrame — the SAME partition, so the result is always
|
||||
// the frame the cursor's column actually draws, never a proportional approximation of it.
|
||||
// Clamped to [0, frameCount]. The shell applies between-marker clamps (e.g. start <= loopEnd)
|
||||
// after this per-marker resolve.
|
||||
std::int64_t resolveDragFrame(const OverlayArea& area, std::int64_t frameCount,
|
||||
std::int64_t startFrame, int dxPixels);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key
|
||||
#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint
|
||||
#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell)
|
||||
#include "shell/view/view_fx_park.h" // drainDeferredFxParks — see persistViewState
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
@@ -133,11 +134,24 @@ std::vector<RetagItem> selectedRetagItems() {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Which keys the following persist has to write. A mode switch changes `view_state`
|
||||
// and nothing else, and it is the one action here a user fires repeatedly — so it
|
||||
// alone narrows, and pays neither the bank-book nor the tracking-ledger
|
||||
// serialization. Every other action keeps the full save.
|
||||
enum class PersistScope { Full, ViewOnly };
|
||||
|
||||
// Persists the bank + Design-View model after every state-changing action so the
|
||||
// view model is not lost across save/close/reopen. If membership is non-empty and
|
||||
// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) —
|
||||
// DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed.
|
||||
void persistViewState() {
|
||||
void persistViewState(PersistScope scope) {
|
||||
// BEFORE the model is serialized, and before the Save-As below can write a
|
||||
// .rpp: a deferred FX restore leaves the chain offline while the model has
|
||||
// already dropped the snapshot that would replan it. Why that combination is
|
||||
// unrecoverable on reopen is at drainDeferredFxParks. Runs for BOTH scopes —
|
||||
// the narrowed save still writes the key the half-applied park contradicts.
|
||||
drainDeferredFxParks();
|
||||
|
||||
if (!g_session->view().membership().empty()) {
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (proj) {
|
||||
@@ -158,7 +172,8 @@ void persistViewState() {
|
||||
}
|
||||
}
|
||||
}
|
||||
g_session->saveToActiveProject();
|
||||
if (scope == PersistScope::ViewOnly) g_session->saveViewStateOnly();
|
||||
else g_session->saveToActiveProject();
|
||||
}
|
||||
|
||||
// The footer segment already reads disabled while the transport runs, but an action can
|
||||
@@ -178,7 +193,7 @@ void doToggleMode() {
|
||||
nextModeId(g_session->view().modes(), g_session->view().activeModeId());
|
||||
if (target.empty()) return; // no modes to cycle to (degenerate)
|
||||
if (!applyMode(g_session->view(), target, nullptr)) { reportModeSwitchRefused(); return; }
|
||||
persistViewState();
|
||||
persistViewState(PersistScope::ViewOnly);
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
@@ -186,7 +201,7 @@ void doToggleMode() {
|
||||
// the id is unregistered or the transport is running, so both fail safe.
|
||||
void doActivateMode(const std::string& modeId) {
|
||||
if (!applyMode(g_session->view(), modeId, nullptr)) { reportModeSwitchRefused(); return; }
|
||||
persistViewState();
|
||||
persistViewState(PersistScope::ViewOnly);
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
@@ -196,7 +211,7 @@ void doTag(const std::string& modeId) {
|
||||
for (const std::string& g : selectedTrackGuids())
|
||||
g_session->view().membership().tag(g, modeId);
|
||||
reapplyActiveMode();
|
||||
persistViewState();
|
||||
persistViewState(PersistScope::Full);
|
||||
}
|
||||
|
||||
// Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence
|
||||
@@ -205,7 +220,7 @@ void doUntag() {
|
||||
for (const std::string& g : selectedTrackGuids())
|
||||
g_session->view().membership().untag(g);
|
||||
reapplyActiveMode();
|
||||
persistViewState();
|
||||
persistViewState(PersistScope::Full);
|
||||
}
|
||||
|
||||
// Flips each track's pin independently — the honest semantics of a toggle on a
|
||||
@@ -215,7 +230,7 @@ void doShowBoth() {
|
||||
for (const std::string& g : selectedTrackGuids())
|
||||
m.setShowBoth(g, !m.isShowBoth(g));
|
||||
reapplyActiveMode();
|
||||
persistViewState();
|
||||
persistViewState(PersistScope::Full);
|
||||
}
|
||||
|
||||
// Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange
|
||||
@@ -247,7 +262,7 @@ void doMoveItems(const std::string& targetMode) {
|
||||
: std::string("ReaSampler: move selected items -> ") + targetMode;
|
||||
Undo_EndBlock2(nullptr, label.c_str(), -1);
|
||||
|
||||
persistViewState();
|
||||
persistViewState(PersistScope::Full);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "shell/panel/panel_input.h" // bankPanelTailSetting
|
||||
#include "shell/persist/session.h"
|
||||
#include "shell/view/view.h" // applyMode / mintManagedLanes
|
||||
#include "shell/view/view_fx_park.h" // drainDeferredFxParks
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||
@@ -210,6 +211,7 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
|
||||
// Persist outside the block. The offline render's own save gate already forced a
|
||||
// saved project, so the Save-As-guarded persist the Design View actions need
|
||||
// cannot have anything to prompt for here.
|
||||
drainDeferredFxParks(); // the reapply above may have deferred a restore — see the contract there
|
||||
session.saveToActiveProject();
|
||||
|
||||
if (!placed) {
|
||||
|
||||
@@ -260,21 +260,39 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
|
||||
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
|
||||
- **The MASTER meter has its OWN 16 ms timer, beside the 500 ms sync poll.** It runs outside
|
||||
that poll's in-flight-drag guard on purpose — a drag suppresses the reload poll, but the bus
|
||||
keeps sounding. Elapsed time is measured (`steady_clock`, whose resolution the rate needs —
|
||||
`GetTickCount64`'s ~15.6 ms quantized a frame's delta to 0 or 16), never assumed from the
|
||||
timer's period, a zero delta skips the advance, and the tick repaints only when
|
||||
`meterDrawEqual` says the picture changed. **The published block state is ACCUMULATED, not
|
||||
sampled**: the processor folds a per-channel max and a min limiter gain across every block
|
||||
since the last read and `masterBusMeter()` clears the accumulators as it reads. A plain
|
||||
overwriting store displayed one block per window 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 — which is why the meter block MOVED to the
|
||||
meter tick rather than being copied there; 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.
|
||||
latch is not discarded with them — it is a latch the user clears.
|
||||
- **A meter frame repaints the bar FIELD, not the client area**, which is what makes the rate
|
||||
affordable: `paint` honours `ps.rcPaint` against the field rect `paintDeck` cached, composes
|
||||
into a RETAINED back buffer (so the rest of the face is still there from the last full
|
||||
compose) and blits the dirty region alone. The fast path is chosen GEOMETRICALLY, never by a
|
||||
flag — Windows unions a meter invalidate with any other pending one into a single rcPaint, so
|
||||
a flag would paint a meter frame over a face that had really changed. What stays outside the
|
||||
field stays on the full path: the static numeral gutter (AA text re-blended onto itself every
|
||||
frame thickens) and the GR lamp (drawn straight onto the deck group's gradient, which a
|
||||
sub-rect fill cannot reproduce), so a lamp transition takes a whole-client repaint. **When the
|
||||
meter is covered or absent** (Browse, the empty state) `invalidateMeter` skips invalidating
|
||||
anything at all rather than falling back to a whole-client repaint — the ballistics still
|
||||
advance on `onMeterTimer`'s own clock, but nothing visible changed, so a 60 FPS whole-client
|
||||
repaint under a modal sheet would be pure cost for zero pixels shown. **The curve popup does
|
||||
NOT cover the meter** — its centered sheet clamps to 520x380 (`curve_popup.h`) against the
|
||||
meter's fixed right-anchored slot, at any resizable window size — so the fast path stays live
|
||||
under it; `paintMeterField` reapplies the popup's own 0.50-alpha wash to the field alone so a
|
||||
meter-only frame doesn't flash through it at full brightness.
|
||||
- The bake's availability probe runs on the SAME tick that paints the button, so the
|
||||
control can never be enabled on one tick and refuse on the next. The bake Hold control's
|
||||
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
|
||||
|
||||
@@ -150,24 +150,26 @@ instrument::ui::WaveMarks ReaSamplerEditor::waveMarksFor(const SetupMarkers& m)
|
||||
w.frame[static_cast<int>(WaveMark::kLoopEnd)] = m.loopEnd;
|
||||
// The crossfade grows LEFT from the seam it closes, which is where it is audible.
|
||||
w.frame[static_cast<int>(WaveMark::kCrossfade)] = m.loopEnd - m.crossfade;
|
||||
// Trigger has no loop at all, so the pair and the fade are ABSENT rather than shown in an
|
||||
// off state — a mark whose gesture the mode does not offer was read as broken, not as off.
|
||||
// Gate keeps the pair whatever the enable says: that is the drag-to-set-loop affordance.
|
||||
// The crossfade mark belongs to an ACTIVE loop: with the enable off there is no seam for it
|
||||
// to sit on, and no length to drag.
|
||||
const bool gate = loopControlsLive();
|
||||
w.present[static_cast<int>(WaveMark::kStart)] = true;
|
||||
w.present[static_cast<int>(WaveMark::kLoopStart)] = true;
|
||||
w.present[static_cast<int>(WaveMark::kLoopEnd)] = true;
|
||||
w.present[static_cast<int>(WaveMark::kCrossfade)] = m.hasLoop;
|
||||
w.present[static_cast<int>(WaveMark::kLoopStart)] = gate;
|
||||
w.present[static_cast<int>(WaveMark::kLoopEnd)] = gate;
|
||||
w.present[static_cast<int>(WaveMark::kCrossfade)] = gate && m.hasLoop;
|
||||
return w;
|
||||
}
|
||||
|
||||
instrument::ui::WaveMarks ReaSamplerEditor::grabbableMarks(const SetupMarkers& m) const {
|
||||
using instrument::ui::WaveMark;
|
||||
instrument::ui::WaveMarks w = waveMarksFor(m);
|
||||
if (!loopControlsLive()) {
|
||||
w.present[static_cast<int>(WaveMark::kLoopStart)] = false;
|
||||
w.present[static_cast<int>(WaveMark::kLoopEnd)] = false;
|
||||
w.present[static_cast<int>(WaveMark::kCrossfade)] = false;
|
||||
}
|
||||
return w;
|
||||
// Drawn IFF grabbable is the product rule, so this is an exact alias of waveMarksFor and
|
||||
// cannot currently diverge from it. Kept as its own seam anyway because paint and hit-test
|
||||
// are separate questions in principle — but do NOT re-add a suppression here: the
|
||||
// Gate-with-loop-off marks are drawn grey precisely so they can still be dragged, and
|
||||
// dragging one is what turns the enable on.
|
||||
return waveMarksFor(m);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::setLoopEnabled(bool on) {
|
||||
|
||||
@@ -34,6 +34,16 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
// Band order matters only where bands can overlap on a degenerate window; each branch
|
||||
// reports whether it consumed the click so the next band gets a clean shot.
|
||||
const FaceLayout fl = faceLayout(w, h);
|
||||
// The overlay focus follows the deck (mouseDownDeck sets it), and clicking a control
|
||||
// surface OUTSIDE the envelope decks clears it. The waveform band is deliberately not one
|
||||
// of those surfaces: it IS the overlay, so editing what you selected must not deselect it.
|
||||
// Chrome's own dead space (the title band, the gaps between its controls) is not a control
|
||||
// surface either — hoverChrome answers only for chrome's actual interactive elements.
|
||||
if (drag_ == DragKind::kNone && hoverChrome(fl, x, y).kind != HoverKind::kNone &&
|
||||
overlayEnv_ != OverlayEnv::kNone) {
|
||||
overlayEnv_ = OverlayEnv::kNone;
|
||||
invalidate(); // the chrome branches below repaint only what THEY changed
|
||||
}
|
||||
if (mouseDownChrome(fl, x, y)) return;
|
||||
if (selectedId_.empty()) return; // empty state — chrome nav only
|
||||
if (mouseDownDeck(fl, x, y)) return;
|
||||
|
||||
@@ -75,28 +75,19 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
|
||||
}
|
||||
// The loop enable. Inert (not hidden) outside Gate: that refusal comes from the engine and
|
||||
// no click can talk it out of it — unlike the user's own off, which the marks themselves
|
||||
// still offer to reverse.
|
||||
if (loopControlsLive()) {
|
||||
if (contains(cr.loopOff, x, y)) {
|
||||
setLoopEnabled(false);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(cr.loopOn, x, y)) {
|
||||
setLoopEnabled(true);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (contains(cr.chanMono, x, y)) {
|
||||
channelMode_ = ChannelMode::Mono;
|
||||
processor_->setChannelMode(ChannelMode::Mono);
|
||||
// still offer to reverse. One button, so the click's meaning is "the other state": read the
|
||||
// current enable off the markers, which is the same source the button's paint reads.
|
||||
if (loopControlsLive() && contains(cr.loop, x, y)) {
|
||||
// setLoopEnabled owns the frames <= 0 guard (empty capture) — no need to repeat it here.
|
||||
const auto frames = static_cast<std::int64_t>(monoPcmFor(selectedId_).size());
|
||||
setLoopEnabled(!pickedMarkers(frames).hasLoop);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(cr.chanStereo, x, y)) {
|
||||
channelMode_ = ChannelMode::Stereo;
|
||||
processor_->setChannelMode(ChannelMode::Stereo);
|
||||
if (contains(cr.channel, x, y)) {
|
||||
channelMode_ =
|
||||
(channelMode_ == ChannelMode::Stereo) ? ChannelMode::Mono : ChannelMode::Stereo;
|
||||
processor_->setChannelMode(channelMode_);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
@@ -161,12 +152,8 @@ HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
|
||||
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
|
||||
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
|
||||
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
|
||||
if (loopControlsLive()) {
|
||||
if (contains(cr.loopOff, x, y)) return {HoverKind::kLoopOff, -1};
|
||||
if (contains(cr.loopOn, x, y)) return {HoverKind::kLoopOn, -1};
|
||||
}
|
||||
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
|
||||
if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1};
|
||||
if (loopControlsLive() && contains(cr.loop, x, y)) return {HoverKind::kLoop, -1};
|
||||
if (contains(cr.channel, x, y)) return {HoverKind::kChannel, -1};
|
||||
if (!cr.rootStrip.empty()) {
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
|
||||
|
||||
@@ -29,10 +29,20 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||
|
||||
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
|
||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||
if (hit.kind == DeckHitKind::CaptionRadio) {
|
||||
overlayEnv_ = nextOverlaySelection(overlayEnv_, hit.id);
|
||||
invalidate(); // view state only: no parameter write, no reload
|
||||
return true;
|
||||
// The deck IS the overlay's click target: anywhere inside an envelope group — panel
|
||||
// background, knob or button — focuses that envelope, and any other group clears it. Set
|
||||
// BEFORE the kind switch and it consumes nothing, so every grab and commit below still
|
||||
// runs. A drag in flight owns the surface, so it refuses the change — always true at this
|
||||
// call site today (mouse capture makes a real WM_LBUTTONDOWN-while-dragging unreachable;
|
||||
// WM_CAPTURECHANGED and WM_LBUTTONUP both reset drag_ before another down can land), but
|
||||
// left explicit rather than assumed so a future capture-handling change fails loud, not
|
||||
// by silently letting a drag's own surface steal its own focus mid-gesture.
|
||||
if (drag_ == DragKind::kNone) {
|
||||
const OverlayEnv focus = overlayEnvForGroup(hit.group);
|
||||
if (focus != overlayEnv_) {
|
||||
overlayEnv_ = focus;
|
||||
invalidate(); // view state only: no parameter write, no reload
|
||||
}
|
||||
}
|
||||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||||
switch (static_cast<ParamControl>(hit.id)) {
|
||||
@@ -65,29 +75,29 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||
commitAndReload();
|
||||
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);
|
||||
}
|
||||
// One button, so its next state is the opposite of the current one — which is
|
||||
// also why the old "did it actually change" guard is gone: it always does.
|
||||
const bool 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: {
|
||||
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable,
|
||||
// and the three env-mode toggles).
|
||||
applyParamControl(hit.id, 0.0, hit.segment);
|
||||
// and the three env-mode selectors). A single button carries no segment, so
|
||||
// its next state is derived from the parameter set rather than read off the
|
||||
// click — deck_values owns that derivation.
|
||||
const int segment =
|
||||
hit.segment >= 0
|
||||
? hit.segment
|
||||
: nextToggleSegment(static_cast<ParamControl>(hit.id), params_.play);
|
||||
applyParamControl(hit.id, 0.0, segment);
|
||||
commitAndReload();
|
||||
// Flipping an EG's Staged|Spline toggle makes THAT envelope's overlay active,
|
||||
// so the contour (or the staged shape you just returned to) is what's drawn.
|
||||
// overlayEnvForModeToggle answers kNone for every other toggle this default
|
||||
// case handles, which is why the assignment is conditional.
|
||||
const OverlayEnv modeEnv = overlayEnvForModeToggle(hit.id);
|
||||
if (modeEnv != OverlayEnv::kNone) overlayEnv_ = modeEnv;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -215,7 +225,6 @@ HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
|
||||
// The meter reports its own state continuously; a hover on it would only mean "the clip
|
||||
// cap is clearable", which the cap's presence already says.
|
||||
if (dh.kind == DeckHitKind::Column) return {};
|
||||
if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id};
|
||||
if (dh.kind == DeckHitKind::Knob && dh.inner &&
|
||||
curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) {
|
||||
// Indexed by the OUTER cell id so the paint side can find the cell it belongs to.
|
||||
|
||||
@@ -26,16 +26,13 @@ enum class HoverKind {
|
||||
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
|
||||
kChannel, // the channel-mode selector button
|
||||
kLoop, // the loop enable button
|
||||
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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry, the Sample
|
||||
// face's band composition (chrome / waveform / decks, each drawn by its own TU), the empty
|
||||
// state, and the drop-affordance banner. Windows-only; draws through the shared kit by
|
||||
// palette role. All layout math is pure (sample_bands) — this TU only sequences.
|
||||
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry over a retained
|
||||
// back buffer (with the meter's dirty-rect fast path), the Sample face's band composition
|
||||
// (chrome / waveform / decks, each drawn by its own TU), the empty state, and the
|
||||
// drop-affordance banner. Windows-only; draws through the shared kit by palette role. All
|
||||
// layout math is pure (sample_bands) — this TU only sequences.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -18,37 +19,76 @@ namespace reasampler::vst {
|
||||
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
|
||||
using namespace reasampler::instrument::ui; // pure geometry (bands / chrome)
|
||||
|
||||
void ReaSamplerEditor::paint(HDC hdc) {
|
||||
LICE_IBitmap* ReaSamplerEditor::ensureBackBuffer(int w, int h) {
|
||||
if (w <= 0 || h <= 0) return nullptr;
|
||||
// resize() is a no-op at the current size, so the steady-state paint allocates nothing.
|
||||
if (!backBuffer_) backBuffer_ = new LICE_SysBitmap(w, h);
|
||||
else backBuffer_->resize(w, h);
|
||||
return backBuffer_;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::releaseBackBuffer() {
|
||||
delete backBuffer_;
|
||||
backBuffer_ = nullptr;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paint(HDC hdc, const RECT& dirty) {
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
LICE_SysBitmap bmp(w, h);
|
||||
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
|
||||
LICE_IBitmap* bmp = ensureBackBuffer(w, h);
|
||||
if (!bmp) return;
|
||||
|
||||
// Sample is home; Browse is a full-window modal overlay drawn over it, so the Sample
|
||||
// face draws first and the modal reads as a sheet layered on top.
|
||||
paintSample(&bmp, w, h);
|
||||
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
|
||||
// The host's update rect, clipped to the client area.
|
||||
const int dx = (std::max)(0, static_cast<int>(dirty.left));
|
||||
const int dy = (std::max)(0, static_cast<int>(dirty.top));
|
||||
const int dr = (std::min)(w, static_cast<int>(dirty.right));
|
||||
const int db = (std::min)(h, static_cast<int>(dirty.bottom));
|
||||
if (dr <= dx || db <= dy) return;
|
||||
|
||||
// A transient banner flashed after a file was dropped on this window. It reiterates the
|
||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
if (dropHintTicks_ > 0) {
|
||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||||
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
|
||||
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
|
||||
// hue with a dark label so it reads as "attention, not action".
|
||||
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
|
||||
kitTextCentered(&bmp, banner,
|
||||
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
||||
Font::Label, Role::BgBase);
|
||||
// A meter frame's dirty rect lies wholly inside the bar field, and nothing else is drawn
|
||||
// there — so the field is all that needs redrawing, and the rest of the face is still in the
|
||||
// back buffer from the last full compose. Decided GEOMETRICALLY rather than by a flag — see
|
||||
// meterFastPathEligible (master_meter) for why.
|
||||
if (meterFastPathEligible(meterRects_, Rect::ltrb(dx, dy, dr, db))) {
|
||||
paintMeterField(bmp);
|
||||
} else {
|
||||
// Repopulated by paintDeck when the meter is on screen; left empty by the empty state.
|
||||
meterRects_ = {};
|
||||
LICE_Clear(bmp, toLice(roleColor(Role::BgBase)));
|
||||
|
||||
// Sample is home; Browse is a full-window modal overlay drawn over it, so the Sample
|
||||
// face draws first and the modal reads as a sheet layered on top.
|
||||
paintSample(bmp, w, h);
|
||||
if (view_ == View::kBrowse) paintBrowse(bmp, w, h);
|
||||
|
||||
// A transient banner flashed after a file was dropped on this window. It reiterates the
|
||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
if (dropHintTicks_ > 0) {
|
||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||||
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
|
||||
// A transient notice, not the live layer — draw it on the accent-tertiary
|
||||
// categorical hue with a dark label so it reads as "attention, not action".
|
||||
fillSurface(bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
|
||||
kitTextCentered(bmp, banner,
|
||||
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
||||
Font::Label, Role::BgBase);
|
||||
}
|
||||
|
||||
// Browse is a full-window modal that covers the meter, so the fast path must not paint
|
||||
// through it even if something else invalidates a rect that happens to match the stale
|
||||
// field bounds — invalidateMeter itself already skips while covered (see there). The
|
||||
// curve popup's sheet never reaches the meter column (see invalidateMeter), so its
|
||||
// meterRects_ stays valid and the fast path stays live under the popup's wash.
|
||||
if (view_ == View::kBrowse) meterRects_ = {};
|
||||
}
|
||||
|
||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||
BitBlt(hdc, dx, dy, dr - dx, db - dy, bmp->getDC(), dx, dy, SRCCOPY);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title +
|
||||
// live readout, then the control run — preview, preview-velocity knob, Loop Off|On,
|
||||
// Mono|Stereo, Browse) over the strip row, which the piano strip has to itself. Windows-only;
|
||||
// live readout, then the control run — preview, preview-velocity knob, Loop, the channel mode,
|
||||
// Browse) over the strip row, which the piano strip has to itself. Windows-only;
|
||||
// all rects come from the pure sample_chrome interior and the pure keyboard_strip geometry.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
@@ -212,44 +212,51 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
|
||||
}
|
||||
}
|
||||
|
||||
// Loop Off | On. A two-segment toggle in the same primitive as Mono|Stereo because it is
|
||||
// the same class of control: a playback mode of the loaded capture. Outside Gate both
|
||||
// segments draw Disabled and neither accepts a click — the state is preserved, not cleared,
|
||||
// so the return to Gate restores it.
|
||||
// The two single-button chrome controls, in the deck's own grammar: Loop is an ENABLE
|
||||
// (Primary on, dim gray off), the channel button is a MODE SELECTOR whose label reads the
|
||||
// current mode and so has no off state. Outside Gate the loop button draws Disabled and
|
||||
// accepts no click — a third state, not an off: the enable's own state is preserved, not
|
||||
// cleared, so the return to Gate restores it.
|
||||
{
|
||||
const bool live = loopControlsLive();
|
||||
const bool on = marks.hasLoop;
|
||||
const auto segState = [&](bool active, HoverKind hk) {
|
||||
if (!live) return InteractionState::Disabled;
|
||||
if (active) return InteractionState::Active;
|
||||
return isHovered(hk, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
const auto drawChromeToggle = [&](const Rect& r, const char* label, HoverKind hk,
|
||||
bool active, bool disabled) {
|
||||
const bool hov = !disabled && isHovered(hk, -1);
|
||||
const InteractionState st =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
fillSurface(bmp, toKitBox(r), Role::BgCell, st);
|
||||
// Disabled's wash over bg/cell is barely a shade off Rest's — the third state needs
|
||||
// its own mark, not just a slightly-quieter fill, so it draws the one outline this
|
||||
// control ever gets. See PLAN.md's Off-vs-Disabled ruling: Off is live and
|
||||
// clickable, Disabled is not, and the two must not read as the same thing.
|
||||
// Outlined in text/dim rather than line/hairline: hairline over this fill composites
|
||||
// to ~1.36:1, under the 3:1 state-indicator floor; text/dim clears it at ~5:1.
|
||||
if (disabled) {
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::TextDim)), 1.0f, 0);
|
||||
}
|
||||
kitTextCentered(bmp, r, label, kToolbarFont,
|
||||
active && !disabled ? Role::BgBase
|
||||
: (hov ? Role::TextPrimary : Role::TextDim));
|
||||
};
|
||||
const InteractionState offState = segState(!on, HoverKind::kLoopOff);
|
||||
const InteractionState onState = segState(on, HoverKind::kLoopOn);
|
||||
fillSurface(bmp, toKitBox(cr.loopOff), Role::BgCell, offState);
|
||||
fillSurface(bmp, toKitBox(cr.loopOn), Role::BgCell, onState);
|
||||
const Role dim = live ? Role::TextPrimary : Role::TextDim;
|
||||
kitTextCentered(bmp, cr.loopOff, "Loop Off", kToolbarFont,
|
||||
(live && !on) ? Role::BgBase : dim);
|
||||
kitTextCentered(bmp, cr.loopOn, "Loop On", kToolbarFont,
|
||||
(live && on) ? Role::BgBase : dim);
|
||||
}
|
||||
drawChromeToggle(cr.loop, "Loop", HoverKind::kLoop, marks.hasLoop,
|
||||
!loopControlsLive());
|
||||
|
||||
// Mono | Stereo output-mode toggle.
|
||||
{
|
||||
// The channel button is a MODE selector like the deck's Stage|Spline buttons — always
|
||||
// "on", so InteractionState::Active would otherwise swallow every hover. Filled by
|
||||
// accent/primary rather than bg/cell so Hover's mix-toward-accent/hot actually moves the
|
||||
// surface instead of nudging bg/cell by a few percent (roleColorState's Active case
|
||||
// always answers accent/primary regardless of the role passed in, so Rest is unreachable
|
||||
// here and this is purely which color Hover mixes FROM).
|
||||
const auto drawModeChromeToggle = [&](const Rect& r, const char* label, HoverKind hk) {
|
||||
const bool hov = isHovered(hk, -1);
|
||||
const InteractionState st = hov ? InteractionState::Hover : InteractionState::Active;
|
||||
fillSurface(bmp, toKitBox(r), Role::AccentPrimary, st);
|
||||
kitTextCentered(bmp, r, label, kToolbarFont, Role::BgBase);
|
||||
};
|
||||
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
|
||||
const InteractionState monoState = !isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
const InteractionState stereoState = isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
fillSurface(bmp, toKitBox(cr.chanMono), Role::BgCell, monoState);
|
||||
fillSurface(bmp, toKitBox(cr.chanStereo), Role::BgCell, stereoState);
|
||||
kitTextCentered(bmp, cr.chanMono, "Mono", kToolbarFont,
|
||||
!isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
kitTextCentered(bmp, cr.chanStereo, "Stereo", kToolbarFont,
|
||||
isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
drawModeChromeToggle(cr.channel, isStereo ? "Stereo" : "Mono", HoverKind::kChannel);
|
||||
}
|
||||
|
||||
// The strip row: the full 128-key piano with the root lit. The loaded capture responds
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -36,15 +37,33 @@ std::string tickLabel(int 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);
|
||||
// The numeral gutter, left of the bars: static content, so it is drawn on a full paint only —
|
||||
// re-blending AA text onto itself every meter frame would thicken it.
|
||||
void drawMeterNumerals(LICE_IBitmap* bmp, const MeterRects& m) {
|
||||
if (m.field.empty()) return;
|
||||
for (int db = static_cast<int>(instrument::engine::kMeterTopDb);
|
||||
db >= static_cast<int>(instrument::engine::kMeterFloorDb);
|
||||
db -= static_cast<int>(kMeterTickStepDb)) {
|
||||
if (!meterTickNumeralled(db)) continue;
|
||||
kitText(bmp, meterNumeralRect(m.labels, meterDbToY(m.field, db)), tickLabel(db).c_str(),
|
||||
Font::Micro, Role::TextDim, Align::Right);
|
||||
}
|
||||
}
|
||||
|
||||
// The bar field: the dB rules, one or two bars, the held peak tick and the latched clip cap —
|
||||
// everything in the column that moves at meter rate. SELF-CONTAINED on purpose: the BgCell fill
|
||||
// covers every pixel the rest of it then draws, so a meter frame can redraw this rect alone.
|
||||
// Two bars or one is read off the rects (barB is empty exactly when the split is Single), so
|
||||
// the lane decision has one representation here rather than two.
|
||||
// This self-containment depends on Role::BgCell being fully OPAQUE at InteractionState::Rest
|
||||
// (theme.cpp: alpha 255) — fillGradient blends rather than overwrites at alpha < 255, so a
|
||||
// translucent BgCell would make every meter frame re-blend over whatever the last frame left,
|
||||
// the exact AA-thickening the numeral gutter comment below is guarding against.
|
||||
void drawMeterField(LICE_IBitmap* bmp, const MeterRects& m, const MasterMeterUi& state) {
|
||||
// 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;
|
||||
assert(meterBarsWithinField(m)); // the self-containment invariant this draw rests on
|
||||
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
|
||||
@@ -57,10 +76,6 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
|
||||
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
|
||||
@@ -80,7 +95,7 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
|
||||
LICE_FillRect(bmp, bar.x, hold, bar.width, 2, holdInk, 1.0f, 0);
|
||||
}
|
||||
};
|
||||
if (split == LaneSplit::Single) {
|
||||
if (m.barB.empty()) {
|
||||
drawBar(m.barA, meterSingleLaneState(state));
|
||||
} else {
|
||||
drawBar(m.barA, state.left);
|
||||
@@ -96,6 +111,18 @@ void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi
|
||||
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerEditor::paintMeterField(LICE_IBitmap* bmp) {
|
||||
drawMeterField(bmp, meterRects_, masterMeter_);
|
||||
// A full paint under the curve popup washes the whole client at 0.50 alpha AFTER the deck
|
||||
// draws (paintCurvePopup); a meter-only fast-path frame draws fresh opaque bars into that
|
||||
// same back buffer, so it must reapply the same wash to the field alone or the meter would
|
||||
// flash through at full brightness against the dimmed rest of the face.
|
||||
if (curvePopup_ != CurveTarget::kNone && !meterRects_.field.empty()) {
|
||||
const Rect& f = meterRects_.field;
|
||||
LICE_FillRect(bmp, f.x, f.y, f.width, f.height, toLice(roleColor(Role::BgBase)), 0.50f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
const Rect& deckArea = fl.bands.decks;
|
||||
if (deckArea.width <= 0 || deckArea.height <= 0) return;
|
||||
@@ -135,6 +162,43 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
disabled ? Role::TextDim
|
||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
};
|
||||
// The single-button ENABLE form. Reads Primary on / dim gray off; a control the ENABLE
|
||||
// gates draws Disabled, a third state and not a synonym for off (off is live and
|
||||
// clickable). No deck ENABLE currently drives `disabled` true through this path — the
|
||||
// deck's one Disabled control (FILTER's Band|Notch law when the filter itself is off)
|
||||
// stays segmented and draws through `drawToggle` above — but the parameter stays for
|
||||
// parity with that segmented form and because a future ENABLE could plausibly gate on
|
||||
// something else the way Loop (chrome) already does outside this deck.
|
||||
const auto drawButtonToggle = [&](const DeckToggleLayout& t, const char* label, bool active,
|
||||
bool disabled) {
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
|
||||
// Hover outranks Active here (the reverse of the old order) so an enabled button still
|
||||
// gets a hover cue — same gap drawModeToggle's comment above explains, just on the
|
||||
// ENABLE form's own base role: mixing toward accent/hot FROM accent/primary (rather
|
||||
// than bg/cell) is what makes the already-lit button visibly brighten on hover.
|
||||
const InteractionState st =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (hov ? InteractionState::Hover
|
||||
: (active ? InteractionState::Active : InteractionState::Rest));
|
||||
fillSurface(bmp, toKitBox(t.seg0), active ? Role::AccentPrimary : Role::BgCell, st);
|
||||
kitTextCentered(bmp, t.seg0, label, Font::Micro,
|
||||
active && !disabled ? Role::BgBase
|
||||
: (hov ? Role::TextPrimary : Role::TextDim));
|
||||
};
|
||||
// The single-button MODE form: the label reads the current mode, so there is no off state
|
||||
// and no dim-gray rest — it is always "on". Filled by accent/primary rather than routed
|
||||
// through drawButtonToggle (which would force InteractionState::Active regardless of hov,
|
||||
// leaving MODE with no hover cue at all): Hover mixes toward accent/hot FROM whatever base
|
||||
// roleColorState is handed, so passing accent/primary here is what lets the already-lit
|
||||
// button visibly brighten on hover instead of a hover mix nobody would notice against
|
||||
// bg/cell's dark base.
|
||||
const auto drawModeToggle = [&](const DeckToggleLayout& t, EnvMode mode) {
|
||||
const char* label = mode == EnvMode::Spline ? "Spline" : "Stage";
|
||||
const bool hov = isHovered(HoverKind::kControl, t.id);
|
||||
const InteractionState st = hov ? InteractionState::Hover : InteractionState::Active;
|
||||
fillSurface(bmp, toKitBox(t.seg0), Role::AccentPrimary, st);
|
||||
kitTextCentered(bmp, t.seg0, label, Font::Micro, Role::BgBase);
|
||||
};
|
||||
const bool anySpline = splineActive(play);
|
||||
|
||||
// The knob's short name label (swapped for the live value during hover/drag — no third
|
||||
@@ -182,10 +246,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
};
|
||||
|
||||
for (const DeckGroupLayout& g : dl.groups) {
|
||||
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
|
||||
// The fence: a bg/panel box with a hairline border, caption micro-caps left. An
|
||||
// envelope deck whose overlay is the one on the waveform takes the primary accent
|
||||
// instead — the deck itself is the selection affordance, so the whole box says so.
|
||||
const OverlayEnv groupEnv = overlayEnvForGroup(g.id);
|
||||
const bool focused = groupEnv != OverlayEnv::kNone && groupEnv == overlayEnv_;
|
||||
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1,
|
||||
hairline, 1.0f, 0);
|
||||
focused ? toLice(roleColor(Role::AccentPrimary)) : hairline, 1.0f, 0);
|
||||
const char* caption = "";
|
||||
switch (g.id) {
|
||||
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
|
||||
@@ -200,8 +268,9 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
}
|
||||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||||
|
||||
// The gain-reduction lamp. ROUND, where the overlay radios in this same slot are
|
||||
// square, so it reads as a lamp rather than a control.
|
||||
// The gain-reduction lamp — MASTER's own corner slot, the one the env decks' overlay
|
||||
// radios used to share before focus-by-click replaced them. ROUND, not square, so it
|
||||
// reads as a passive readout 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;
|
||||
@@ -210,26 +279,6 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
: Role::LineHairline)),
|
||||
1.0f, 0, true);
|
||||
}
|
||||
// The overlay-select radio: filled in the tertiary accent (the colour the overlay
|
||||
// traces in) when this group's envelope is the one on the waveform, hollow otherwise.
|
||||
if (g.captionRadio.id >= 0 && !g.captionRadio.passive) {
|
||||
// overlayEnvForRadio returns kNone for BOTH "not a radio id" and "no selection" —
|
||||
// a non-radio id must never read as lit just because nothing is selected, so the
|
||||
// picked env has to be checked against kNone itself, not just matched by equality.
|
||||
const OverlayEnv picked = overlayEnvForRadio(g.captionRadio.id);
|
||||
const bool on = picked != OverlayEnv::kNone && overlayEnv_ == picked;
|
||||
const bool hov = isHovered(HoverKind::kEnvRadio, g.captionRadio.id);
|
||||
const Rect& rb = g.captionRadio.box;
|
||||
LICE_DrawRect(bmp, rb.x, rb.y, rb.width - 1, rb.height - 1,
|
||||
toLice(roleColor(on || hov ? Role::AccentTertiary
|
||||
: Role::LineHairline)),
|
||||
1.0f, 0);
|
||||
if (on) {
|
||||
LICE_FillRect(bmp, rb.x + 3, rb.y + 3, rb.width - 6, rb.height - 6,
|
||||
toLice(roleColor(Role::AccentTertiary)), 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// The compact caption toggles (right-anchored in the caption row, never full-width).
|
||||
for (const DeckToggleLayout* tp : {&g.captionToggle, &g.captionToggle2}) {
|
||||
if (tp->id < 0) continue;
|
||||
@@ -244,13 +293,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
play.pitchEngine == PitchEngine::Preserve, false);
|
||||
break;
|
||||
case ParamControl::kPitchEnvEnable:
|
||||
drawToggle(t, "Off", "On", play.pitchEnv.enabled, false);
|
||||
drawButtonToggle(t, "Envelope", play.pitchEnv.enabled, false);
|
||||
break;
|
||||
case ParamControl::kVoiceMode:
|
||||
drawToggle(t, "Poly", "Mono", isMono, false);
|
||||
break;
|
||||
case ParamControl::kFilterEnable:
|
||||
drawToggle(t, "Off", "On", play.filter.enabled, false);
|
||||
drawButtonToggle(t, "Filter", play.filter.enabled, false);
|
||||
break;
|
||||
case ParamControl::kFilterLaw:
|
||||
drawToggle(t, "Band", "Notch",
|
||||
@@ -259,16 +308,18 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
!play.filter.enabled);
|
||||
break;
|
||||
case ParamControl::kLimiterEnable:
|
||||
drawToggle(t, "Off", "On", params_.limiterEnabled, false);
|
||||
drawButtonToggle(t, "Limiter", params_.limiterEnabled, false);
|
||||
break;
|
||||
// A mode SELECTOR: the label is the state, so it is drawn Active either way —
|
||||
// there is nothing here for a dim-gray off to mean.
|
||||
case ParamControl::kAmpEnvMode:
|
||||
drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false);
|
||||
drawModeToggle(t, play.ampSpline.mode);
|
||||
break;
|
||||
case ParamControl::kPitchEnvMode:
|
||||
drawToggle(t, "Stg", "Spl", play.pitchSpline.mode == EnvMode::Spline, false);
|
||||
drawModeToggle(t, play.pitchSpline.mode);
|
||||
break;
|
||||
case ParamControl::kFilterEnvMode:
|
||||
drawToggle(t, "Stg", "Spl", play.filterSpline.mode == EnvMode::Spline, false);
|
||||
drawModeToggle(t, play.filterSpline.mode);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
@@ -279,7 +330,12 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
||||
}
|
||||
|
||||
if (g.column.id >= 0) paintMeterColumn(bmp, g.column.box, masterMeter_, meterSplit);
|
||||
if (g.column.id >= 0) {
|
||||
// Cached so the meter's own tick repaints the field without re-laying out the deck.
|
||||
meterRects_ = meterRects(g.column.box, meterSplit);
|
||||
drawMeterNumerals(bmp, meterRects_);
|
||||
drawMeterField(bmp, meterRects_, masterMeter_);
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
|
||||
#include "core/audio/peaks.h" // Envelope (the binned waveform)
|
||||
#include "core/instrument/ui/curve_tessellate.h" // buildEnvelopeTrace (the staged trace)
|
||||
#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box)
|
||||
#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
|
||||
@@ -24,7 +24,6 @@ namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // lanes + waveform geometry
|
||||
using audio::computeEnvelope;
|
||||
|
||||
namespace {
|
||||
// Marker roles — semantic, drawn through the kit's palette. The loop family is teal
|
||||
@@ -44,13 +43,18 @@ namespace {
|
||||
// cap, never a curve.
|
||||
constexpr Role kRoleStartMarker = Role::OverlayTrace;
|
||||
constexpr Role kRoleLoopMarker = Role::AccentSecondary;
|
||||
// A loop mark whose enable is off keeps its position and its cap — and its full weight, since
|
||||
// it is still draggable. It changes HUE, not opacity: the dim teal it replaces read as broken
|
||||
// rather than as off. Grey against the lime deliberately sits under the 3:1 state-indicator
|
||||
// floor the two-neighbour rule (core/ui/CLAUDE.md) sets — on a mark that stays LIVE and still
|
||||
// drives the loop enable, so this is NOT the WCAG 1.4.11 inactive-component carve-out. It is a
|
||||
// deliberate trade (the off cue reads as lower contrast on a control that can still be
|
||||
// grabbed), pending Daniel's eye in the DAW. Do not change the colour to chase it.
|
||||
constexpr Role kRoleLoopMarkerOff = Role::TextDim;
|
||||
|
||||
// Mark weights. A Disabled mark (loop off, or Trigger) keeps its position and its cap so the
|
||||
// information survives the state; the crossfade is a SOFT boundary and rides below the loop
|
||||
// pair's weight at rest.
|
||||
// Mark weights. The crossfade is a SOFT boundary and rides below the loop pair's weight at rest.
|
||||
constexpr float kMarkAlpha = 1.0f;
|
||||
constexpr float kMarkAlphaXfade = 0.7f;
|
||||
constexpr float kMarkAlphaDisabled = 0.4f;
|
||||
|
||||
// The dashed crossfade line: a 3 px stroke every 6 px down the band.
|
||||
constexpr int kDashOn = 3;
|
||||
@@ -198,18 +202,18 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
|
||||
const std::size_t bins =
|
||||
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
||||
|
||||
// Binned once per (capture, bin count) rather than once per paint — a full scan of the
|
||||
// decoded PCM is what a frame-rate repaint cannot afford. See heroEnvelope.
|
||||
if (surface.laneCount == 2) {
|
||||
// ONE pass over the interleaved source: computeEnvelope already envelopes each
|
||||
// channel independently, so the second lane costs no second scan of the PCM.
|
||||
const Envelope env =
|
||||
computeEnvelope(src.interleaved, static_cast<std::size_t>(src.channelCount),
|
||||
static_cast<std::size_t>(src.frameCount()), bins);
|
||||
const Envelope& env = heroEnvelope(bins, 2);
|
||||
drawEnvelope(bmp, surface.upper, laneEnvelope(env, 0));
|
||||
drawEnvelope(bmp, surface.lower, laneEnvelope(env, 1));
|
||||
} else {
|
||||
// One lane draws what one lane plays: the downmix, not channel 0 of a stereo
|
||||
// source.
|
||||
drawEnvelope(bmp, surface.upper, computeEnvelope(mono, 1, mono.size(), bins));
|
||||
drawEnvelope(bmp, surface.upper, heroEnvelope(bins, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,9 +221,11 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
|
||||
// stereo view reads one loop region rather than two.
|
||||
const OverlayArea& overlay = surface.overlay;
|
||||
const Rect& overlayRect = overlay.rect;
|
||||
const bool loopLive = loopControlsLive();
|
||||
const bool loopOn = m.hasLoop && loopLive;
|
||||
const WaveMarks marks = waveMarksFor(m);
|
||||
// The mark set is the single source for whether this face has a loop to say anything about
|
||||
// — in Trigger the pair is absent, so the span, the wedges and the caption go with it.
|
||||
const bool loopShown = marks.present[static_cast<int>(WaveMark::kLoopStart)];
|
||||
const bool loopOn = m.hasLoop && loopShown;
|
||||
const LICE_pixel loopInk = toLice(roleColor(kRoleLoopMarker));
|
||||
|
||||
const int lx = frameToX(overlay, frames, m.loopStart);
|
||||
@@ -249,10 +255,10 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
|
||||
}
|
||||
|
||||
// The state caption, centred in the span: the two OFF states say different things because
|
||||
// they mean different things, and Trigger's refusal names its own reason.
|
||||
const char* caption = nullptr;
|
||||
if (!loopLive) caption = "LOOP \xe2\x80\x94 GATE ONLY"; // "LOOP — GATE ONLY" (em dash, UTF-8)
|
||||
else if (!m.hasLoop) caption = m.parked ? "DRAG TO SET LOOP" : "LOOP OFF";
|
||||
// they mean different things, and the grey marks alone cannot say WHICH off state this is
|
||||
// or that dragging will fix it. Trigger has no span to centre anything in.
|
||||
const char* caption =
|
||||
(loopShown && !m.hasLoop) ? (m.parked ? "DRAG TO SET LOOP" : "LOOP OFF") : nullptr;
|
||||
if (caption != nullptr && rx > lx) {
|
||||
// Tight box (kMarkLabelHeight, not the whole overlay) centered on the same midline the
|
||||
// full-height rect already centered DT_VCENTER text on, so the scrim darkens only the
|
||||
@@ -280,16 +286,16 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band,
|
||||
Role::TextDim);
|
||||
}
|
||||
|
||||
// Line + shaped cap per mark, one grammar. A mark whose gesture is refused draws Disabled
|
||||
// rather than hidden — the position is information the user put there.
|
||||
// Line + shaped cap per mark, one grammar. A loop mark with the enable off draws grey but
|
||||
// full weight — the position is information the user put there, and it is still draggable.
|
||||
for (int i = 0; i < kWaveMarkCount; ++i) {
|
||||
if (!marks.present[i]) continue;
|
||||
const WaveMark which = static_cast<WaveMark>(i);
|
||||
const bool isStart = (which == WaveMark::kStart);
|
||||
const bool dim = !isStart && !loopOn;
|
||||
const LICE_pixel ink = isStart ? toLice(roleColor(kRoleStartMarker)) : loopInk;
|
||||
const float alpha = dim ? kMarkAlphaDisabled
|
||||
: (which == WaveMark::kCrossfade ? kMarkAlphaXfade : kMarkAlpha);
|
||||
const bool off = !isStart && !loopOn;
|
||||
const LICE_pixel ink = isStart ? toLice(roleColor(kRoleStartMarker))
|
||||
: (off ? toLice(roleColor(kRoleLoopMarkerOff)) : loopInk);
|
||||
const float alpha = (which == WaveMark::kCrossfade) ? kMarkAlphaXfade : kMarkAlpha;
|
||||
const int mx = frameToX(overlay, frames, marks.frame[i]);
|
||||
if (which == WaveMark::kCrossfade) {
|
||||
// Dashed: a soft boundary, not a hard one.
|
||||
|
||||
@@ -31,6 +31,15 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
|
||||
// a per-window SetTimer id (any nonzero).
|
||||
constexpr UINT_PTR kSyncTimerId = 1;
|
||||
constexpr UINT kSyncTimerIntervalMs = 500;
|
||||
|
||||
// The meter's own clock on the same child window, because the bus meter is the one surface
|
||||
// whose value changes every block. Raising the poll above to frame rate instead is the REJECTED
|
||||
// alternative: its body costs a bridge read plus a bank parse, and its two tick-counted banners
|
||||
// (the bake message, the drop hint) are calibrated in ticks, so they would silently shorten by
|
||||
// the same factor. 16 ms is 60 FPS; what a frame costs is the meter's bar field, not the client
|
||||
// area — the whole-client invalidate is what made this rate unaffordable before.
|
||||
constexpr UINT_PTR kMeterTimerId = 2;
|
||||
constexpr UINT kMeterTimerIntervalMs = 16;
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
@@ -66,6 +75,32 @@ void ReaSamplerEditor::invalidate() {
|
||||
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::invalidateMeter() {
|
||||
if (!childHwnd_) return;
|
||||
// The meter is covered (Browse) or has nothing to draw (empty state) — ballistics still
|
||||
// advance in onMeterTimer, but nothing on screen changed, so invalidating anything here
|
||||
// would only buy a whole-client repaint of chrome/waveform/deck the meter never touches.
|
||||
// Distinct from "bounds not resolved yet" below: this is a fact about what the face is
|
||||
// showing, not about whether meterRects_ happens to be populated. The curve popup does NOT
|
||||
// cover the meter — its centered sheet tops out at 520x380 (curve_popup.h) against the
|
||||
// meter's fixed right-anchored slot, at any resizable size — so the meter stays on screen
|
||||
// (dimmed by the popup's wash, which paintMeterField reapplies to the field alone) and keeps
|
||||
// its own rate rather than freezing under the sheet.
|
||||
const bool meterOnScreen = view_ == View::kSample && !selectedId_.empty();
|
||||
if (!meterOnScreen) return;
|
||||
|
||||
const Rect& f = meterRects_.field;
|
||||
if (f.empty()) {
|
||||
// On screen, but no full paint has resolved its bounds yet (first paint, or a resize
|
||||
// just dropped the cache) — the whole-client fallback is cheap here because the window
|
||||
// is already fully invalid from the resize/creation that caused this.
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
RECT r{f.x, f.y, f.right(), f.bottom()};
|
||||
InvalidateRect(childHwnd_, &r, FALSE);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::attachedToParent() {
|
||||
HWND parent = static_cast<HWND>(systemWindow);
|
||||
if (!parent) return;
|
||||
@@ -111,6 +146,9 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
// created here, killed in removedFromParent — so an instance whose editor is closed
|
||||
// does not poll.
|
||||
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
|
||||
// Bound to the same window for the same reason: an instance with no editor open runs
|
||||
// neither clock, and the meter's accumulators simply pile up until one opens.
|
||||
SetTimer(childHwnd_, kMeterTimerId, kMeterTimerIntervalMs, nullptr);
|
||||
// Poll once immediately so a pending assignment (an ingest fired while this editor was
|
||||
// closed) or a bank change applies the instant the editor opens, rather than waiting up
|
||||
// to one timer interval. refreshFromBank above already primed the view; this folds in
|
||||
@@ -127,9 +165,15 @@ void ReaSamplerEditor::removedFromParent() {
|
||||
if (processor_) processor_->endParamGesture();
|
||||
if (childHwnd_) {
|
||||
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
||||
KillTimer(childHwnd_, kMeterTimerId);
|
||||
DestroyWindow(childHwnd_);
|
||||
childHwnd_ = nullptr;
|
||||
}
|
||||
releaseBackBuffer(); // a client-area bitmap outlives nothing here
|
||||
// Unreachable today (WM_PAINT outranks WM_TIMER, so a reopen's first paint resolves this
|
||||
// before any meter tick can read it stale) — cleared anyway so the invariant is structural,
|
||||
// not a timing accident, the same reason onSize clears it on resize.
|
||||
meterRects_ = {};
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
||||
@@ -137,6 +181,10 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
|
||||
if (childHwnd_ && newSize) {
|
||||
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
|
||||
thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them
|
||||
ensureBackBuffer(newSize->getWidth(), newSize->getHeight());
|
||||
// The cached meter rects name the OLD layout; drop them so the meter tick takes the
|
||||
// whole-client path until the resize's own full paint resolves them again.
|
||||
meterRects_ = {};
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -149,7 +197,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
case WM_PAINT: {
|
||||
PAINTSTRUCT ps{};
|
||||
HDC hdc = BeginPaint(hwnd, &ps);
|
||||
if (self) self->paint(hdc);
|
||||
if (self) self->paint(hdc, ps.rcPaint);
|
||||
EndPaint(hwnd, &ps);
|
||||
return 0;
|
||||
}
|
||||
@@ -293,7 +341,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
return 0;
|
||||
}
|
||||
case WM_TIMER:
|
||||
if (self && wParam == kSyncTimerId) self->onSyncTimer();
|
||||
if (self) {
|
||||
if (wParam == kSyncTimerId) self->onSyncTimer();
|
||||
else if (wParam == kMeterTimerId) self->onMeterTimer();
|
||||
}
|
||||
return 0;
|
||||
case WM_ERASEBKGND:
|
||||
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
|
||||
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
|
||||
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync and meter ticks, the
|
||||
// commit-and-reload seam, selection loading, the loaded capture's marker resolution, and
|
||||
// the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
|
||||
// thread via the processor's reloadInstrument.
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -108,31 +109,6 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
// tick picks up the change after release.
|
||||
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
|
||||
|
||||
// A parameter commit that flipped the limiter already changed the sound; what waits for this
|
||||
@@ -202,6 +178,49 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMeterTimer() {
|
||||
// UI thread (WM_TIMER). Deliberately outside the sync tick's in-flight-drag guard rather
|
||||
// than merely ahead of it: a drag suppresses the reload poll, but the bus keeps sounding
|
||||
// and a frozen bar would misreport it.
|
||||
if (!processor_) return;
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const bool first = meterTick_ == std::chrono::steady_clock::time_point{};
|
||||
const double elapsed =
|
||||
first ? 0.0 : std::chrono::duration<double>(now - meterTick_).count();
|
||||
// A zero delta advances nothing, and the drain below is CONSUMING — returning ahead of it
|
||||
// keeps the window intact for the next tick instead of spending it on a frame that would
|
||||
// move no pixel. GetTickCount64's ~15.6 ms granularity made that the common case at this
|
||||
// rate; a steady clock makes it rare, not impossible.
|
||||
if (!first && elapsed <= 0.0) return;
|
||||
meterTick_ = now;
|
||||
|
||||
// THE one call site. masterBusMeter() exchanges the accumulators to identity as it reads,
|
||||
// so a second clock reading it would steal windows from this one.
|
||||
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 (first) return;
|
||||
|
||||
const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter(
|
||||
masterMeter_, {bus.peakL, bus.peakR, bus.minGain, bus.clip}, elapsed);
|
||||
const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_);
|
||||
// The GR lamp sits in the deck group's caption row, outside the bar field a meter frame
|
||||
// repaints, and is drawn straight onto the group's own gradient — so its rare transitions
|
||||
// take the whole-client repaint rather than growing the fast path a second rect.
|
||||
const bool lampMoved =
|
||||
instrument::ui::grLampLit(advanced) != instrument::ui::grLampLit(masterMeter_);
|
||||
masterMeter_ = advanced;
|
||||
if (!changed) return;
|
||||
if (lampMoved) invalidate();
|
||||
else invalidateMeter();
|
||||
}
|
||||
#endif // _WIN32
|
||||
|
||||
void ReaSamplerEditor::commitAndReload() {
|
||||
@@ -437,6 +456,25 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
return ins.first->second;
|
||||
}
|
||||
|
||||
const Envelope& ReaSamplerEditor::heroEnvelope(std::size_t bins, int laneCount) {
|
||||
// One lane draws the downmix binned at the band's width, which is exactly what the
|
||||
// thumbnail cache already answers — so the single-lane hero costs no second slot and
|
||||
// inherits that cache's busting (refreshFromBank, and a resize's clear).
|
||||
if (laneCount != 2) return thumbnailFor(selectedId_, static_cast<int>(bins));
|
||||
|
||||
// Two lanes bin the INTERLEAVED source instead, which no thumbnail ever asks for. A resize
|
||||
// changes `bins` and misses the memo; a capture change drops the decode it rides in.
|
||||
const int channels = channelPcmFor(selectedId_).channelCount;
|
||||
if (channelPcm_.heroBins != bins) {
|
||||
channelPcm_.heroBins = bins;
|
||||
channelPcm_.heroEnv = computeEnvelope(channelPcm_.interleaved,
|
||||
static_cast<std::size_t>(channels),
|
||||
static_cast<std::size_t>(channelPcm_.frameCount()),
|
||||
bins);
|
||||
}
|
||||
return channelPcm_.heroEnv;
|
||||
}
|
||||
|
||||
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
|
||||
// Key through the pure ThumbnailKey (bank_grid, length-prefixed id — collision-proof) so
|
||||
// both thumbnail pipelines share one tested key grammar. The editor invalidates by
|
||||
@@ -468,6 +506,7 @@ ReaSamplerEditor::~ReaSamplerEditor() {
|
||||
DestroyWindow(childHwnd_);
|
||||
childHwnd_ = nullptr;
|
||||
}
|
||||
releaseBackBuffer(); // removedFromParent normally did; this is the un-detached path
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
|
||||
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules
|
||||
// (sample_bands, sample_chrome, capture_browser, keyboard_strip, sample_map). The Sample
|
||||
// face is a three-band stack — chrome, waveform, decks — and the shell TUs split on that
|
||||
// same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in the
|
||||
// pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules. The
|
||||
// Sample face is a three-band stack — chrome, waveform, decks — and the shell TUs split on
|
||||
// that same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in
|
||||
// the pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
@@ -64,6 +64,9 @@ public:
|
||||
// always supplies one).
|
||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||
~ReaSamplerEditor() override;
|
||||
// backBuffer_ owns a raw LICE bitmap with no refcount behind it — a copy would double-free.
|
||||
ReaSamplerEditor(const ReaSamplerEditor&) = delete;
|
||||
ReaSamplerEditor& operator=(const ReaSamplerEditor&) = delete;
|
||||
|
||||
Steinberg::tresult PLUGIN_API isPlatformTypeSupported(
|
||||
Steinberg::FIDString type) override;
|
||||
@@ -118,7 +121,13 @@ private:
|
||||
FaceLayout faceLayout(int w, int h) const;
|
||||
|
||||
#ifdef _WIN32
|
||||
void paint(HDC hdc);
|
||||
// `dirty` is the host's WM_PAINT update rect: a region inside the meter's bar field redraws
|
||||
// that field alone, anything else re-composes the face, and either way only the dirty region
|
||||
// is blitted out of the retained back buffer (a per-paint client-area LICE_SysBitmap is what
|
||||
// the meter's rate would otherwise cost).
|
||||
void paint(HDC hdc, const RECT& dirty);
|
||||
LICE_IBitmap* ensureBackBuffer(int w, int h);
|
||||
void releaseBackBuffer();
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face (band composition)
|
||||
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
|
||||
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||
@@ -137,6 +146,7 @@ private:
|
||||
// Decks: the group fence + caption + compact caption toggles + radial knobs with
|
||||
// label<->value swap on hover/drag.
|
||||
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
|
||||
void paintMeterField(LICE_IBitmap* bmp); // the bar field alone, off meterRects_
|
||||
|
||||
// A deck cell's mini curve thumbnail (the VELOCITY group) and the modal editor it summons.
|
||||
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target, bool disabled,
|
||||
@@ -231,10 +241,16 @@ private:
|
||||
// never yanks the edit surface.
|
||||
void onSyncTimer();
|
||||
|
||||
// The meter tick (its own WM_TIMER id, UI thread only): drains the bus, advances the
|
||||
// ballistics, repaints the bar field. Its own clock — see editor_platform.cpp's timer ids.
|
||||
void onMeterTimer();
|
||||
|
||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
void invalidate();
|
||||
void invalidateMeter(); // the bar field only; falls back to invalidate() before first paint
|
||||
|
||||
HWND childHwnd_ = nullptr;
|
||||
LICE_IBitmap* backBuffer_ = nullptr; // owned; see ensureBackBuffer
|
||||
#endif
|
||||
|
||||
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
|
||||
@@ -280,10 +296,16 @@ private:
|
||||
// thread only (file I/O); cleared with the thumbnail cache on refresh.
|
||||
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
|
||||
|
||||
// The hero waveform's binned envelope: the band's most expensive draw, and a function of the
|
||||
// capture and the bin count alone. UI thread only (may decode).
|
||||
const Envelope& heroEnvelope(std::size_t bins, int laneCount);
|
||||
|
||||
// The interleaved source PCM behind the stereo waveform lanes.
|
||||
struct ChannelPcm {
|
||||
std::vector<AudioSample> interleaved; // frame-interleaved source frames
|
||||
int channelCount = 0; // 0 = nothing decoded
|
||||
Envelope heroEnv; // binned from `interleaved`; cleared whenever it is
|
||||
std::size_t heroBins = 0; // 0 = not computed
|
||||
std::int64_t frameCount() const {
|
||||
return channelCount > 0
|
||||
? static_cast<std::int64_t>(interleaved.size()) / channelCount
|
||||
@@ -313,10 +335,9 @@ private:
|
||||
// fold, so a drawn envelope disables them through the same predicate.
|
||||
bool loopControlsLive() const;
|
||||
|
||||
// Which marks the band DRAWS, and which of those accept a grab. They differ in exactly one
|
||||
// place — Trigger, where the loop marks stay drawn (hiding a set loop on a mode flip would
|
||||
// destroy information the user put there) but refuse every gesture, because that refusal
|
||||
// comes from the engine and no drag can talk it out of it.
|
||||
// Which marks the band DRAWS, and which of those accept a grab. Trigger has no loop at all,
|
||||
// so the pair and crossfade are ABSENT rather than drawn inert (an offered-but-refused
|
||||
// gesture reads as broken) — drawn iff grabbable, so the two cannot currently diverge.
|
||||
instrument::ui::WaveMarks waveMarksFor(const SetupMarkers& m) const;
|
||||
instrument::ui::WaveMarks grabbableMarks(const SetupMarkers& m) const;
|
||||
|
||||
@@ -447,9 +468,7 @@ private:
|
||||
// up. One note at a time — a fresh press releases the prior.
|
||||
int previewingNote_ = -1;
|
||||
|
||||
// Resample bake. The click only ARMS it; the sync tick runs it. Running it inline
|
||||
// would nest a synchronous REAPER action — which re-points this very instance — inside
|
||||
// a mouse handler with the capture held.
|
||||
// Resample bake — the click only ARMS it (see this directory's CLAUDE.md Gotchas for why).
|
||||
bool bakePending_ = false;
|
||||
// Whether the extension's bake action is registered, resolved on the same tick that
|
||||
// governs the button's paint, so the control is never enabled and then refusing.
|
||||
@@ -471,11 +490,14 @@ private:
|
||||
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||
bool searchFocused_ = false; // whether the search box has keyboard focus
|
||||
|
||||
// The MASTER deck's meter, advanced from the published block magnitudes on the sync tick
|
||||
// (see onSyncTimer for why it runs mid-drag too, and why the first read is discarded).
|
||||
// meterTickMs_ 0 = never ticked.
|
||||
// The MASTER deck's meter, advanced from the published block magnitudes on its own timer
|
||||
// (see onMeterTimer for why it runs mid-drag too, and why the first read is discarded).
|
||||
// A default-constructed meterTick_ means never ticked. meterRects_ is the column interior
|
||||
// resolved at the last FULL paint; an empty field is ambiguous alone (no layout yet, or
|
||||
// genuinely off screen) — invalidateMeter asks view_/curvePopup_/selectedId_ to tell which.
|
||||
instrument::ui::MasterMeterUi masterMeter_;
|
||||
unsigned long long meterTickMs_ = 0;
|
||||
std::chrono::steady_clock::time_point meterTick_{};
|
||||
instrument::ui::MeterRects meterRects_;
|
||||
|
||||
// Hover state (transient, never persisted).
|
||||
HoverTarget hover_; // the interactive element under the pointer
|
||||
|
||||
@@ -53,7 +53,7 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
|
||||
|
||||
## Modules
|
||||
|
||||
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` returns whether the writes were ISSUED — false means no active/saved project and NOTHING was written, which is the only reading its callers' discard-the-undo-point branch is safe under; it must never grow an observational third failure mode (the contract lives at its declaration in `session.h`). `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
|
||||
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` returns whether the writes were ISSUED — false means no active/saved project and NOTHING was written, which is the only reading its callers' discard-the-undo-point branch is safe under; it must never grow an observational third failure mode (the contract lives at its declaration in `session.h`). `saveViewStateOnly` is its narrowed sibling for a caller that changed only the Design-View model — `view_state` plus the dirty mark, no other key — and returns on that same rule; the full save stays the default and narrowing is opt-in per call site. `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
|
||||
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
|
||||
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
|
||||
|
||||
|
||||
@@ -137,51 +137,61 @@ namespace reasampler {
|
||||
using persist_detail::getProjExtStateString;
|
||||
using persist_detail::readActiveProject;
|
||||
|
||||
bool ReaSamplerSession::saveToActiveProject() {
|
||||
namespace {
|
||||
|
||||
// The ONE guard behind every ext-state write entry point here, so the two conditions
|
||||
// that produce a false stay a single rule rather than a copy per entry point.
|
||||
// session.h owns what that false has to mean to callers.
|
||||
ReaProject* activeSavedProject() {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return false; // no active project — nothing to persist
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
if (!proj || rppPath.empty()) return nullptr;
|
||||
return static_cast<ReaProject*>(proj);
|
||||
}
|
||||
|
||||
void writeViewState(ReaProject* proj, const ViewModeModel& view) {
|
||||
const std::string viewJson = view.serialize();
|
||||
SetProjExtState(proj, projExtNamespace(), kProjExtViewKey, viewJson.c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ReaSamplerSession::saveToActiveProject() {
|
||||
ReaProject* proj = activeSavedProject();
|
||||
if (!proj) return false;
|
||||
|
||||
const std::string banksJson = book_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBanksKey, banksJson.c_str());
|
||||
SetProjExtState(proj, projExtNamespace(), kProjExtBanksKey, banksJson.c_str());
|
||||
|
||||
// Retire the legacy single-bank key: SetProjExtState with an empty value
|
||||
// deletes it. Idempotent when already absent.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtIndexKey, "");
|
||||
SetProjExtState(proj, projExtNamespace(), kProjExtIndexKey, "");
|
||||
|
||||
// Each of the following rides in its own key, independent of `banks`.
|
||||
const std::string viewJson = view_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtViewKey, viewJson.c_str());
|
||||
writeViewState(proj, view_);
|
||||
|
||||
const std::string tailJson = capture::serializeTailSetting(tail_);
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtTailKey, tailJson.c_str());
|
||||
SetProjExtState(proj, projExtNamespace(), kProjExtTailKey, tailJson.c_str());
|
||||
|
||||
// Never write over a blob this build could not read (see this directory's
|
||||
// CLAUDE.md for why the suppression, not a rewrite, is the safe direction).
|
||||
if (!tracking::ledgerDegraded(trackingStatus_)) {
|
||||
const std::string ledgerJson = tracking_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtOwnedKey, ledgerJson.c_str());
|
||||
SetProjExtState(proj, projExtNamespace(), kProjExtOwnedKey, ledgerJson.c_str());
|
||||
}
|
||||
|
||||
// stampVersion() (not appVersion()) is the numeric triple only, no "-beta"
|
||||
// suffix, so the stamp is byte-identical to stable regardless of channel
|
||||
// — the channel is already carried by the isolated namespace.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtVersionKey, version::stampVersion().c_str());
|
||||
SetProjExtState(proj, projExtNamespace(), kProjExtVersionKey,
|
||||
version::stampVersion().c_str());
|
||||
|
||||
// Whatever bumpBankGeneration() advanced the counter to since the last
|
||||
// save (0 if never bumped). Shared encoder so writer/reader agree byte-for-byte.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtBankGenKey,
|
||||
SetProjExtState(proj, projExtNamespace(), kProjExtBankGenKey,
|
||||
instrument::map::formatBankGeneration(bankGeneration_).c_str());
|
||||
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
MarkProjectDirty(proj);
|
||||
|
||||
// The writes were ISSUED into a saved active project — all this call can observe, and
|
||||
// deliberately all it claims. Do not "prove" them with a read-back; session.h states
|
||||
@@ -189,17 +199,26 @@ bool ReaSamplerSession::saveToActiveProject() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The version stamp and the degraded-ledger suppression deliberately do NOT appear
|
||||
// here: neither the bank book nor the ledger is rewritten, so there is nothing for
|
||||
// this build to claim authorship of and nothing to suppress.
|
||||
bool ReaSamplerSession::saveViewStateOnly() {
|
||||
ReaProject* proj = activeSavedProject();
|
||||
if (!proj) return false;
|
||||
|
||||
writeViewState(proj, view_);
|
||||
MarkProjectDirty(proj);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj) return false; // no active project — nothing to signal
|
||||
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
|
||||
ReaProject* proj = activeSavedProject();
|
||||
if (!proj) return false;
|
||||
|
||||
// One-shot write under its own key: a transient signal to the instrument,
|
||||
// not session state that rides every save.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
|
||||
kProjExtAssignKey, wire.c_str());
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
SetProjExtState(proj, projExtNamespace(), kProjExtAssignKey, wire.c_str());
|
||||
MarkProjectDirty(proj);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,19 @@ public:
|
||||
// remove the Ctrl-Z for a bank mutation that landed.
|
||||
bool saveToActiveProject();
|
||||
|
||||
// The narrowed sibling, for a caller that changed ONLY the Design-View model:
|
||||
// writes `view_state`, marks the project dirty, and touches no other key. The
|
||||
// full save stays the default — this is opt-in per call site.
|
||||
//
|
||||
// Opt-in rather than a per-key dirty flag on the session: a call site that
|
||||
// forgets to opt in merely pays the old cost, whereas a mutation site that
|
||||
// forgets to mark its key dirty would silently stop persisting it, and the
|
||||
// dirty flags would have to be threaded through every writer of book_/tail_/
|
||||
// tracking_ across the capture, bank-op and ingest layers.
|
||||
//
|
||||
// Returns on the SAME rule as saveToActiveProject above, prohibition included.
|
||||
bool saveViewStateOnly();
|
||||
|
||||
// Report-only prune dry-run: feeds the pure core with (present, referenced,
|
||||
// owned) — `present` from the folder enumeration, the other two from the
|
||||
// tracking authority. FAIL-SAFE: tracking state the authority cannot read
|
||||
|
||||
@@ -45,9 +45,30 @@ decide membership or mode rules.
|
||||
mode ids — no absolute paths, no index positions).
|
||||
- **Documented caveat:** offlined FX re-instantiate when a track returns to the
|
||||
active mode — stateful plugins (convolution, loaded samplers, tail-holding
|
||||
effects) re-initialize on return (possible load hitch, un-persisted internal
|
||||
state lost). Accepted cost of the CPU reclaim; surfaced at the toggle affordance
|
||||
(tooltip).
|
||||
effects) re-initialize on return (load hitch, un-persisted internal state lost).
|
||||
Accepted cost of the CPU reclaim; surfaced at the toggle affordance (tooltip).
|
||||
**The hitch no longer sits on the switch's synchronous path:** per-FX
|
||||
offline/online is enqueued and applied on a later idle tick (`view_fx_park`),
|
||||
so the new mode paints first. The deferral changes only WHEN the plugins move —
|
||||
they still unload and re-instantiate, and un-persisted internal state is still
|
||||
lost. What it does change is the undo record: the offline writes land outside
|
||||
the switch's undo block, so the tool no longer re-drives them on an undo or a
|
||||
redo — what a Ctrl-Z then leaves the chain at is REAPER's own FX-state record,
|
||||
`[verify — DAW]`. **The idle tick is not the only drain point.** Any path that
|
||||
serializes the view model drains synchronously first (`persistViewState`,
|
||||
`render_in_place`), because a save landing between a restore's synchronous flag
|
||||
writes and its drain would record offline FX beside a model that no longer
|
||||
carries the snapshot to replan them — unrecoverable on reopen. So an
|
||||
action-driven switch does pay the FX hitch before it returns; the repaint and
|
||||
the undo block have both closed by then, which is what the deferral was for.
|
||||
Any new caller that reapplies a mode and then persists inherits this obligation.
|
||||
- **Stated DEVIATION — the undo mask does not keep FX out of a real switch.** The
|
||||
apply mask (`kApplyUndoMask`) drops `UNDO_STATE_FX` and ORs it back in when a
|
||||
driven flag in that domain moved; the only such flag is `I_FXEN`, which every
|
||||
park writes. So any switch that parks at least one track still makes REAPER
|
||||
marshal the project-wide FX chunk into its undo record. The saving is real only
|
||||
on a reapply and on a no-op switch. Narrowing it further would mean not carrying
|
||||
`I_FXEN` in the undo record at all, which would break "one switch is one Ctrl-Z."
|
||||
- **Show-both semantics:** a per-track "pin visible across modes" flag re-enables
|
||||
processing whenever shown. A show-both leaf appears in every mode's visible set
|
||||
and is never parked — its driven flags stay at snapshot/restored values, FX
|
||||
@@ -85,7 +106,8 @@ applies the resulting lane state to live tracks.
|
||||
|
||||
## Modules
|
||||
|
||||
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.**
|
||||
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN`, with per-FX offline deferred to `view_fx_park`), restores from snapshot. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.**
|
||||
- `view_fx_park` — the per-FX offline surface: the `TrackFX_GetFXGUID` identity read snapshot/park/restore share, the deferred intent queue that keeps `TrackFX_SetOffline` off the switch's synchronous path (at most one intent per track GUID, latest wins, an intent landing on its own pending inverse cancels it), and the idle-tick drain `main.cpp`'s `OnTimer` calls. **The drain owns no model state.** A snapshot is dropped where the restore is PLANNED — the flags are back at their captured values from that moment, and a model that still described the track as parked would let a persist or a reapply inside the drain window replan a restore over whatever the user changed since. What the deferral costs instead is that the live FX chain stops being a trustworthy snapshot source while an intent is pending: a park that CANCELS a pending restore takes the pre-park FX states from that restore's ops (`preParkFxFromCancelledRestore`), because the chain still reads the parked values and the cancel means no drain will ever fix them.
|
||||
- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`.
|
||||
|
||||
## Gotchas
|
||||
@@ -103,9 +125,15 @@ applies the resulting lane state to live tracks.
|
||||
under whatever mode id is active at that point, not the one the user undid back
|
||||
to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
|
||||
the solo cache inherits it rather than introducing it. Not fixed here.
|
||||
- An undo/redo also DISCARDS every pending FX intent (`discardDeferredFxParks`),
|
||||
which is not the pure loss it reads as: the same tick reapplies the active mode
|
||||
over the reloaded model, re-planning a park for every inactive leaf, so parked
|
||||
FX converge on the following drain. The one case that does not self-heal is a
|
||||
track whose reloaded model carries no snapshot — nothing plans its restore, so
|
||||
FX left offline stay offline. Full contract at `discardDeferredFxParks`.
|
||||
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
|
||||
attached to its plugin across a chain mutation while parked. That is
|
||||
`[verify — DAW]` (see `fxGuidString` in `view.cpp`) and SWS issue #802 is a
|
||||
`[verify — DAW]` (see `fxGuidString` in `view_fx_park.cpp`) and SWS issue #802 is a
|
||||
known reason it might not hold: `SNM_MoveOrRemoveTrackFX` reportedly leaves
|
||||
the FXID lines behind on reorder rather than moving them with the plugin. If
|
||||
confirmed, an SWS-driven reorder of a parked track's chain — not a native
|
||||
|
||||
+172
-189
@@ -10,6 +10,7 @@
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -18,6 +19,7 @@
|
||||
#include "core/view/lane_keys.h"
|
||||
#include "core/view/solo_cache.h"
|
||||
#include "shell/capture/track_guid.h"
|
||||
#include "shell/view/view_fx_park.h"
|
||||
#include "shell/view/view_solo.h"
|
||||
#include "core/view/view_tree.h"
|
||||
|
||||
@@ -28,19 +30,13 @@
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXGUID
|
||||
#define REAPERAPI_WANT_TrackFX_GetOffline
|
||||
#define REAPERAPI_WANT_TrackFX_SetOffline
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#define REAPERAPI_WANT_PreventUIRefresh
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#define REAPERAPI_WANT_TrackList_AdjustWindows
|
||||
#define REAPERAPI_WANT_UpdateArrange
|
||||
#define REAPERAPI_WANT_UpdateTimeline
|
||||
// Lane minting (D2 Wave 3): item-side lane reads/writes to assign each item to
|
||||
// its mode's managed lane.
|
||||
// Lane minting: item-side lane reads/writes assigning each item to its mode's lane.
|
||||
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
@@ -64,6 +60,30 @@ constexpr int kFreeModeFixedLanes = 2;
|
||||
// deliberately excluded, nothing is moving there.
|
||||
constexpr int kTransportMoving = 1 | 4;
|
||||
|
||||
// The domains one apply actually writes: track config/routing (visibility,
|
||||
// B_MAINSEND, I_SOLO), item lane assignment, and the ext-state block
|
||||
// (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, which
|
||||
// includes UNDO_STATE_FX and so makes every undo record carry every track's FX
|
||||
// state — a cost that scales with the project's plugin count rather than with
|
||||
// what the switch changed. FX is OR'd back in per apply, only when an FX-domain
|
||||
// flag really moved.
|
||||
// [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads
|
||||
// "track/master vol/pan/routing" (reaper_plugin.h:1541) and names neither
|
||||
// B_SHOWINTCP/B_SHOWINMIXER nor the fixed-lane properties (assumed to ride
|
||||
// UNDO_STATE_ITEMS, :1543). Wrong ⇒ a Ctrl-Z restores less than the switch did.
|
||||
constexpr int kApplyUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG;
|
||||
|
||||
// Holds REAPER's UI refresh off for the write phase; the deliberate rebuild
|
||||
// (TrackList_AdjustWindows + UpdateArrange) runs after it releases. RAII because
|
||||
// an unbalanced pair leaves the user's UI frozen with no way back — the SDK's
|
||||
// own warning at reaper_plugin_functions.h:5581.
|
||||
struct UiRefreshHold {
|
||||
UiRefreshHold() { PreventUIRefresh(1); }
|
||||
~UiRefreshHold() { PreventUIRefresh(-1); }
|
||||
UiRefreshHold(const UiRefreshHold&) = delete;
|
||||
UiRefreshHold& operator=(const UiRefreshHold&) = delete;
|
||||
};
|
||||
|
||||
// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane
|
||||
// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist
|
||||
// but displays as non-fixed-lane).
|
||||
@@ -99,10 +119,10 @@ const char* flagParm(Flag f) {
|
||||
|
||||
// The master track is not enumerated by GetTrack (index space excludes it),
|
||||
// so it can never enter the tree — the master-untouched invariant holds by
|
||||
// construction. Also caches each MediaTrack* by GUID for later resolve().
|
||||
// construction.
|
||||
std::vector<TrackFolderEntry> readFolderEntries(
|
||||
ReaProject* proj,
|
||||
std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||
TrackHandles& handleByGuid) {
|
||||
std::vector<TrackFolderEntry> entries;
|
||||
int count = CountTracks(proj);
|
||||
entries.reserve(static_cast<std::size_t>(count));
|
||||
@@ -119,95 +139,63 @@ std::vector<TrackFolderEntry> readFolderEntries(
|
||||
return entries;
|
||||
}
|
||||
|
||||
MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
|
||||
const std::string& guid) {
|
||||
for (const auto& kv : handleByGuid) {
|
||||
if (kv.first == guid) return kv.second;
|
||||
}
|
||||
return nullptr; // stale/deleted GUID — pruned by being skipped
|
||||
// Every park, restore, parent and lane op resolves its GUID; a linear scan made
|
||||
// that O(T²) on the one path whose cost the user waits on. The vector form
|
||||
// survives beside it because view_solo's writers take one pass over it.
|
||||
using TrackByGuid = std::unordered_map<std::string, MediaTrack*>;
|
||||
|
||||
TrackByGuid indexHandles(const TrackHandles& handleByGuid) {
|
||||
TrackByGuid byGuid;
|
||||
byGuid.reserve(handleByGuid.size());
|
||||
for (const auto& kv : handleByGuid) byGuid.emplace(kv.first, kv.second);
|
||||
return byGuid;
|
||||
}
|
||||
|
||||
// The FX's own durable identity, braced exactly like the track GUID keys. Empty
|
||||
// when REAPER reports none — an FX we cannot name is one we cannot restore, and
|
||||
// fx_offline treats it that way rather than guessing at its slot. Lifetime is
|
||||
// settled: the string copy is taken immediately and the GUID* is never held
|
||||
// past this call (reaper_plugin_functions.h:7348 documents no null contract for
|
||||
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
|
||||
//
|
||||
// [verify — DAW] STABILITY across a chain mutation is not settled the same way:
|
||||
// confirm the GUID for one FX instance survives a native drag-reorder, an SWS
|
||||
// move (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not
|
||||
// follow the plugin after that call, i.e. wrong-plugin restores or mass drops
|
||||
// through fx_offline on that path specifically), a save/reload round trip, and
|
||||
// two live instances of one plugin type staying distinguishable. See
|
||||
// src/shell/view/CLAUDE.md's Gotchas for the SWS-path risk this leaves open.
|
||||
std::string fxGuidString(MediaTrack* tr, int fx) {
|
||||
GUID* g = TrackFX_GetFXGUID(tr, fx);
|
||||
if (!g) return {};
|
||||
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
|
||||
guidToString(g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// The chain as it stands now: identity by current slot. Snapshot, park and
|
||||
// restore all address FX through this one plain 0..TrackFX_GetCount-1
|
||||
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
|
||||
// whatever it covers, all three cover identically.
|
||||
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
|
||||
const int fxCount = TrackFX_GetCount(tr);
|
||||
std::vector<std::string> guids;
|
||||
guids.reserve(static_cast<std::size_t>(fxCount));
|
||||
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
|
||||
return guids;
|
||||
MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) {
|
||||
auto it = byGuid.find(guid);
|
||||
return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped
|
||||
}
|
||||
|
||||
// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO;
|
||||
// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract).
|
||||
TrackSnapshot snapshotTrack(MediaTrack* tr) {
|
||||
// The FX half is snapshotFxOffline's — only it knows when the live chain has
|
||||
// stopped being a trustworthy source.
|
||||
TrackSnapshot snapshotTrack(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelledRestore) {
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP"));
|
||||
snap.showInMixer = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINMIXER"));
|
||||
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
|
||||
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
|
||||
|
||||
const std::vector<std::string> guids = liveFxGuids(tr);
|
||||
snap.fxOffline.reserve(guids.size());
|
||||
for (std::size_t fx = 0; fx < guids.size(); ++fx) {
|
||||
snap.fxOffline.push_back(
|
||||
FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast<int>(fx)) ? 1 : 0});
|
||||
}
|
||||
return snap; // fxKeying stays Identity — a live capture always knows the chain
|
||||
PreParkFx fx = snapshotFxOffline(tr, cancelledRestore);
|
||||
snap.fxOffline = std::move(fx.states);
|
||||
snap.fxKeying = fx.keying;
|
||||
return snap;
|
||||
}
|
||||
|
||||
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) {
|
||||
// A reapply (tag/untag, project load) re-plans every flag it already applied, so
|
||||
// most of what it writes is a value the track already holds. The read is ASSUMED
|
||||
// cheaper than the write it elides (unmeasured); what it certainly does is keep
|
||||
// those no-op writes out of the undo record's mask.
|
||||
bool writeIfChanged(MediaTrack* tr, const char* parm, double value) {
|
||||
if (GetMediaTrackInfo_Value(tr, parm) == value) return false;
|
||||
SetMediaTrackInfo_Value(tr, parm, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags, int& undoMask) {
|
||||
for (const TrackFlagOp& op : flags) {
|
||||
SetMediaTrackInfo_Value(tr, flagParm(op.flag), static_cast<double>(op.value));
|
||||
if (!writeIfChanged(tr, flagParm(op.flag), static_cast<double>(op.value))) continue;
|
||||
// I_FXEN is the only FX-domain flag driven here, so it is the only one
|
||||
// whose undo record has to carry UNDO_STATE_FX.
|
||||
if (op.flag == Flag::FxEnable) undoMask |= UNDO_STATE_FX;
|
||||
}
|
||||
}
|
||||
|
||||
// The pure park plan leaves fxOffline empty by design; expand it here from the
|
||||
// live FX count.
|
||||
void parkFxOffline(MediaTrack* tr) {
|
||||
int fxCount = TrackFX_GetCount(tr);
|
||||
for (int fx = 0; fx < fxCount; ++fx) {
|
||||
TrackFX_SetOffline(tr, fx, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Restores per-FX offline from the snapshot verbatim — never a blanket "online".
|
||||
// Which live FX each captured state belongs to is resolveFxRestore's call, and
|
||||
// what it could not place comes back for the caller to report.
|
||||
FxRestoreDrops restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
|
||||
const FxRestoreResolution res = resolveFxRestore(fxOffline, liveFxGuids(tr));
|
||||
for (const FxOfflineWrite& w : res.writes) TrackFX_SetOffline(tr, w.fxIndex, w.offline);
|
||||
return res.drops;
|
||||
}
|
||||
|
||||
// Managed-lane application: the pure planner keys LanePlayOps by the lane's
|
||||
// DURABLE name; REAPER's C_LANEPLAYS:N is keyed by current ordinal, which
|
||||
// renumbers on reorder. So every write here re-resolves durable key -> current
|
||||
// ordinal first. A lane whose name lacks the managed prefix never enters this
|
||||
// map and so can never be driven.
|
||||
// Managed-lane application: LanePlayOps are keyed by the lane's DURABLE name but
|
||||
// C_LANEPLAYS:N by current ordinal, which renumbers on reorder — so every write
|
||||
// re-resolves durable key -> ordinal first, and a lane whose name lacks the
|
||||
// managed prefix never enters the map and so can never be driven.
|
||||
|
||||
// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed /
|
||||
// unavailable (non-fixed-lane track).
|
||||
@@ -238,7 +226,7 @@ std::map<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
|
||||
void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
|
||||
char parm[32];
|
||||
std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx);
|
||||
SetMediaTrackInfo_Value(tr, parm, static_cast<double>(lanePlays));
|
||||
writeIfChanged(tr, parm, static_cast<double>(lanePlays));
|
||||
}
|
||||
|
||||
// Groups ops by track, reconciles each op's durable laneKey to the track's
|
||||
@@ -246,7 +234,7 @@ void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
|
||||
// enables fixed-lane mode on any track carrying a managed lane, and drives
|
||||
// C_LANEPLAYS. UpdateTimeline() is the caller's job when this returns true
|
||||
// (SDK: required after an I_FREEMODE change).
|
||||
bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
|
||||
bool applyLaneOps(const TrackByGuid& handleByGuid,
|
||||
const std::vector<LanePlayOp>& lanes) {
|
||||
if (lanes.empty()) return false;
|
||||
|
||||
@@ -280,12 +268,10 @@ bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handle
|
||||
return touchedFreeMode;
|
||||
}
|
||||
|
||||
// Managed-lane minting: the DECISION (which tracks split, which lanes, which
|
||||
// item goes where) is planLaneMinting; this shell only reads live per-item
|
||||
// mode+lane state, calls it, and applies the resulting writes.
|
||||
// Managed-lane minting: the DECISION (which tracks split, which lanes, which item
|
||||
// goes where) is planLaneMinting's; this shell only reads, calls and applies.
|
||||
|
||||
// Maps every item GUID on `tr` to its handle in one pass (avoids a per-item
|
||||
// re-scan in the assign loop).
|
||||
// One pass, so the assign loop needs no per-item re-scan.
|
||||
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
|
||||
std::map<std::string, MediaItem*> byGuid;
|
||||
const int itemCount = CountTrackMediaItems(tr);
|
||||
@@ -307,12 +293,11 @@ std::string itemModeFromMembership(const ViewModeModel& model, const std::string
|
||||
return *modes.begin();
|
||||
}
|
||||
|
||||
// Builds the per-track LaneItem picture the pure decision consumes. Manual-
|
||||
// lane reads are skipped on a non-fixed-lane track (isOnManualLane is false
|
||||
// there regardless of name).
|
||||
// The per-track LaneItem picture the pure decision consumes. Manual-lane reads
|
||||
// are skipped on a non-fixed-lane track (isOnManualLane is false there anyway).
|
||||
std::vector<LaneTrack> readLaneTracks(
|
||||
const ViewModeModel& model,
|
||||
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||
const TrackHandles& handleByGuid) {
|
||||
std::vector<LaneTrack> tracks;
|
||||
tracks.reserve(handleByGuid.size());
|
||||
for (const auto& [guid, tr] : handleByGuid) {
|
||||
@@ -341,9 +326,8 @@ std::vector<LaneTrack> readLaneTracks(
|
||||
return tracks;
|
||||
}
|
||||
|
||||
// Idempotent: writes I_FIXEDLANE only when it differs from the item's current
|
||||
// lane. Non-destructive — only this reversible flag is written, never a move
|
||||
// in time or across tracks.
|
||||
// Idempotent, and non-destructive: only this reversible flag is written, never
|
||||
// a move in time or across tracks.
|
||||
bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) {
|
||||
const int current = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
|
||||
if (current == laneOrdinal) return false; // already there — no-op
|
||||
@@ -358,7 +342,7 @@ bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) {
|
||||
// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk,
|
||||
// so a user's existing manual lanes are never renamed or reassigned.
|
||||
bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||
const TrackByGuid& handleByGuid) {
|
||||
bool changed = false;
|
||||
|
||||
std::map<std::string, std::vector<const LaneMint*>> mintsByTrack;
|
||||
@@ -436,8 +420,9 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
return false; // same fail-closed shape as the mode-exists guard above
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
TrackHandles handleByGuid;
|
||||
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||
const TrackByGuid trackByGuid = indexHandles(handleByGuid);
|
||||
FolderTree tree = buildFolderTree(entries);
|
||||
|
||||
// Prune snapshots for tracks no longer in the live enumeration before
|
||||
@@ -455,81 +440,6 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
|
||||
TogglePlan plan = model.planToggle(tree, targetModeId);
|
||||
|
||||
Undo_BeginBlock2(proj);
|
||||
|
||||
// DISJOIN THE SOLO SURFACES. Both this clear and the replay after setActiveMode
|
||||
// ride the existing undo block — one mode toggle stays one Ctrl-Z.
|
||||
if (realSwitch) {
|
||||
model.soloCache().store(outgoingModeId, outgoingSolo);
|
||||
clearTrackSolos(handleByGuid, outgoingSolo);
|
||||
}
|
||||
|
||||
// PARK: snapshot before mutating, store into the model, then apply.
|
||||
for (const TrackPlan& tp : plan.park) {
|
||||
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
|
||||
const std::string& guid = tp.flags.front().guid;
|
||||
MediaTrack* tr = resolve(handleByGuid, guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
// Snapshot ONCE, at first park: a snapshot already present means the
|
||||
// track is still parked from a prior apply, so its live flags are the
|
||||
// parked values — recapturing would overwrite the true pre-park state
|
||||
// with zeros and a later restore would hide it for good. Restore
|
||||
// clears the snapshot, so the next genuine park recaptures fresh state.
|
||||
if (model.snapshot(guid) == nullptr)
|
||||
model.storeSnapshot(guid, snapshotTrack(tr));
|
||||
applyFlags(tr, tp.flags);
|
||||
parkFxOffline(tr);
|
||||
}
|
||||
|
||||
// RESTORE: apply verbatim, then drop the consumed snapshot.
|
||||
FxRestoreDrops fxDrops;
|
||||
int fxDropTracks = 0;
|
||||
for (const TrackPlan& tp : plan.restore) {
|
||||
if (tp.flags.empty()) continue;
|
||||
const std::string& guid = tp.flags.front().guid;
|
||||
MediaTrack* tr = resolve(handleByGuid, guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
applyFlags(tr, tp.flags);
|
||||
const FxRestoreDrops drops = restoreFxOffline(tr, tp.fxOffline);
|
||||
if (drops.total() > 0) {
|
||||
fxDrops.add(drops);
|
||||
++fxDropTracks;
|
||||
}
|
||||
model.clearSnapshot(guid);
|
||||
}
|
||||
|
||||
// Captured FX state that could not be applied is REPORTED. Silence here would
|
||||
// read to the user as "restore worked" while an FX sat at whatever state the
|
||||
// park left it in. Sent with the "!SHOW:" prefix (reaper_plugin_functions.h:6536)
|
||||
// so it never force-opens the console window: applyMode's reapply path also
|
||||
// runs unattended on project load (see the reconcile comment above), and this
|
||||
// one call site can't tell that case apart from an interactive toggle/tag-edit
|
||||
// reapply — both call in with target == active — so splitting loud-on-toggle
|
||||
// from quiet-on-load would need a flag threaded from every caller, several of
|
||||
// which are outside this change. Quiet-always is the safe default: the message
|
||||
// still lands in the console for whoever opens it, on every path.
|
||||
const std::string fxDropMsg = describeFxRestoreDrops(fxDrops, fxDropTracks);
|
||||
if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + fxDropMsg).c_str());
|
||||
|
||||
// MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows
|
||||
// and every other managed lane is silenced+hidden. Empty for a D1-only
|
||||
// project, leaving that behavior byte-identical.
|
||||
const bool laneModeChanged = applyLaneOps(handleByGuid, plan.lanes);
|
||||
|
||||
// PARENT VISIBILITY (never parked): recomputed every toggle, never
|
||||
// snapshotted. Only the two visibility flags — never mainSend/FX on a parent.
|
||||
std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
|
||||
for (const FolderNode& node : tree.nodes) {
|
||||
if (!node.isParent) continue;
|
||||
MediaTrack* tr = resolve(handleByGuid, node.guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
double show = visible.count(node.guid) ? 1.0 : 0.0;
|
||||
SetMediaTrackInfo_Value(tr, "B_SHOWINTCP", show);
|
||||
SetMediaTrackInfo_Value(tr, "B_SHOWINMIXER", show);
|
||||
}
|
||||
|
||||
// Target is guaranteed registered (checked at entry); fall back to the id
|
||||
// defensively if that ever changes.
|
||||
const Mode* targetMode = model.modes().query(targetModeId);
|
||||
@@ -537,22 +447,90 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
"ReaSampler: activate " +
|
||||
(targetMode ? targetMode->displayName : targetModeId) + " view";
|
||||
|
||||
model.setActiveMode(targetModeId);
|
||||
int undoMask = kApplyUndoMask;
|
||||
bool laneModeChanged = false;
|
||||
|
||||
// Replay + consume, before the single relayout below picks the change up. Dropped
|
||||
// against `visible` (computed above for parent visibility), not the park plan: a
|
||||
// folder parent can be hidden without being parked, and a hidden track must not
|
||||
// receive a replayed solo it carries no visible control to undo.
|
||||
if (realSwitch) {
|
||||
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
|
||||
restoreTrackSolos(handleByGuid, *cached, liveGuids, visible);
|
||||
model.soloCache().clear(targetModeId);
|
||||
Undo_BeginBlock2(proj);
|
||||
{
|
||||
UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen
|
||||
|
||||
// DISJOIN THE SOLO SURFACES. Both this clear and the replay after
|
||||
// setActiveMode ride the existing undo block — one mode toggle stays one Ctrl-Z.
|
||||
if (realSwitch) {
|
||||
model.soloCache().store(outgoingModeId, outgoingSolo);
|
||||
clearTrackSolos(handleByGuid, outgoingSolo);
|
||||
}
|
||||
|
||||
// PARK: snapshot before mutating. The per-FX offline half is deferred
|
||||
// (view_fx_park) — the expensive half, and the new mode's appearance
|
||||
// does not wait on it.
|
||||
for (const TrackPlan& tp : plan.park) {
|
||||
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
|
||||
const std::string& guid = tp.flags.front().guid;
|
||||
MediaTrack* tr = resolve(trackByGuid, guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
// Enqueued FIRST: a park landing on this track's own pending restore
|
||||
// cancels it, and those ops are then the only surviving record of the
|
||||
// pre-park FX state. Snapshot ONCE — a snapshot already present means
|
||||
// the track is still parked, so recapturing would overwrite the true
|
||||
// pre-park state with zeros and a later restore would hide it for good.
|
||||
const std::vector<FxOfflineOp> cancelled = deferFxPark(proj, guid);
|
||||
if (model.snapshot(guid) == nullptr)
|
||||
model.storeSnapshot(guid, snapshotTrack(tr, cancelled));
|
||||
applyFlags(tr, tp.flags, undoMask);
|
||||
}
|
||||
|
||||
// RESTORE: flags verbatim now, per-FX offline on the drain.
|
||||
for (const TrackPlan& tp : plan.restore) {
|
||||
if (tp.flags.empty()) continue;
|
||||
const std::string& guid = tp.flags.front().guid;
|
||||
MediaTrack* tr = resolve(trackByGuid, guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
|
||||
applyFlags(tr, tp.flags, undoMask);
|
||||
deferFxRestore(proj, guid, tp.fxOffline);
|
||||
// Consumed HERE, not on the drain: from this line the flags are back
|
||||
// at their captured values, and a persist or reapply landing inside
|
||||
// the drain window must not replan a restore over what the user has
|
||||
// changed since.
|
||||
model.clearSnapshot(guid);
|
||||
}
|
||||
|
||||
// MANAGED LANES: the active mode's lane plays+shows, every other managed
|
||||
// lane is silenced+hidden. Empty on a D1-only project — byte-identical there.
|
||||
laneModeChanged = applyLaneOps(trackByGuid, plan.lanes);
|
||||
|
||||
// PARENT VISIBILITY (never parked): recomputed every toggle, never
|
||||
// snapshotted. Only the two visibility flags — never mainSend/FX on a parent.
|
||||
std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
|
||||
for (const FolderNode& node : tree.nodes) {
|
||||
if (!node.isParent) continue;
|
||||
MediaTrack* tr = resolve(trackByGuid, node.guid);
|
||||
if (!tr) continue; // stale GUID — prune
|
||||
const double show = visible.count(node.guid) ? 1.0 : 0.0;
|
||||
writeIfChanged(tr, "B_SHOWINTCP", show);
|
||||
writeIfChanged(tr, "B_SHOWINMIXER", show);
|
||||
}
|
||||
|
||||
model.setActiveMode(targetModeId);
|
||||
|
||||
// Replay + consume, before the single relayout below picks the change up.
|
||||
// Dropped against `visible` (computed above for parent visibility), not the
|
||||
// park plan: a folder parent can be hidden without being parked, and a
|
||||
// hidden track must not receive a replayed solo it carries no visible
|
||||
// control to undo.
|
||||
if (realSwitch) {
|
||||
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
|
||||
restoreTrackSolos(handleByGuid, *cached, liveGuids, visible);
|
||||
model.soloCache().clear(targetModeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force REAPER to rebuild the TCP/MCP now rather than on the next user
|
||||
// interaction: TrackList_AdjustWindows(false) does the full relayout owed
|
||||
// when tracks appear/disappear; UpdateArrange() repaints.
|
||||
// interaction — AdjustWindows(false) is the full relayout tracks appearing
|
||||
// and disappearing owes.
|
||||
TrackList_AdjustWindows(false);
|
||||
UpdateArrange();
|
||||
|
||||
@@ -560,12 +538,12 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
// fixed lanes this apply (SDK requirement for I_FREEMODE changes).
|
||||
if (laneModeChanged) UpdateTimeline();
|
||||
|
||||
Undo_EndBlock2(proj, undoLabel.c_str(), -1);
|
||||
Undo_EndBlock2(proj, undoLabel.c_str(), undoMask);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
TrackHandles handleByGuid;
|
||||
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||
// The tree is needed to detect a content-bearing folder derived-visible in
|
||||
// >1 mode, exactly as applyMode builds it.
|
||||
@@ -575,8 +553,10 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||
const LaneMintPlan plan = planLaneMinting(model, tree, tracks);
|
||||
if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick
|
||||
|
||||
const TrackByGuid trackByGuid = indexHandles(handleByGuid);
|
||||
|
||||
Undo_BeginBlock2(proj);
|
||||
const bool changed = applyMintPlan(model, plan, handleByGuid);
|
||||
const bool changed = applyMintPlan(model, plan, trackByGuid);
|
||||
|
||||
if (!changed) {
|
||||
// Plan was non-empty but every write was already satisfied — discard
|
||||
@@ -599,17 +579,20 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||
// here — it would re-park/restore whole tracks and recompute parent
|
||||
// visibility, which a lane-only mint must not touch.
|
||||
const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId());
|
||||
applyLaneOps(handleByGuid, togglePlan.lanes);
|
||||
applyLaneOps(trackByGuid, togglePlan.lanes);
|
||||
|
||||
UpdateTimeline(); // a split happened this call — refresh is owed
|
||||
UpdateArrange();
|
||||
|
||||
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1);
|
||||
// Lane minting writes track lane config and item lane assignment and nothing
|
||||
// else — never an FX state — so it takes the same honest mask applyMode does
|
||||
// (kApplyUndoMask, whose [verify — DAW] on lane-property domain binds here too).
|
||||
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kApplyUndoMask);
|
||||
return true;
|
||||
}
|
||||
|
||||
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
TrackHandles handleByGuid;
|
||||
readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here)
|
||||
|
||||
// Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// See view_fx_park.h. Compiled into the reaper_reasampler module; includes
|
||||
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
|
||||
// The queue's rule is pure (header, test_view_fx_park.cpp); this file owns the
|
||||
// REAPER reads/writes and the ordering against the live enumeration.
|
||||
|
||||
#include "shell/view/view_fx_park.h"
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "shell/capture/track_guid.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||
#define REAPERAPI_WANT_TrackFX_GetFXGUID
|
||||
#define REAPERAPI_WANT_TrackFX_GetOffline
|
||||
#define REAPERAPI_WANT_TrackFX_SetOffline
|
||||
#define REAPERAPI_WANT_ValidatePtr2
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
FxParkQueue g_queue;
|
||||
ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against
|
||||
bool g_draining = false;
|
||||
|
||||
// Makes "one drain at a time" explicit rather than implied by the call sites.
|
||||
// RAII because an apply can throw and a stuck flag would silence the queue for
|
||||
// the rest of the session.
|
||||
struct DrainScope {
|
||||
DrainScope() { g_draining = true; }
|
||||
~DrainScope() { g_draining = false; }
|
||||
DrainScope(const DrainScope&) = delete;
|
||||
DrainScope& operator=(const DrainScope&) = delete;
|
||||
};
|
||||
|
||||
ReaProject* currentProject() { return EnumProjects(-1, nullptr, 0); }
|
||||
|
||||
void adoptOwner(ReaProject* proj) {
|
||||
ReaProject* p = proj ? proj : currentProject();
|
||||
if (p == g_owner) return;
|
||||
g_queue.clear(); // intents planned against another project are never replayed here
|
||||
g_owner = p;
|
||||
}
|
||||
|
||||
// [verify — DAW] TrackFX_SetOffline unloads and re-instantiates the plugin, so
|
||||
// writing a state that already holds is assumed to cost the same unload/reload
|
||||
// as a real change — the header documents no internal short-circuit either way.
|
||||
// The compare is cheap and correct regardless; only the size of what it saves is
|
||||
// unconfirmed.
|
||||
void setOfflineIfChanged(MediaTrack* tr, int fx, bool offline) {
|
||||
if (TrackFX_GetOffline(tr, fx) == offline) return;
|
||||
TrackFX_SetOffline(tr, fx, offline);
|
||||
}
|
||||
|
||||
void applyPark(MediaTrack* tr) {
|
||||
const int fxCount = TrackFX_GetCount(tr);
|
||||
for (int fx = 0; fx < fxCount; ++fx) setOfflineIfChanged(tr, fx, true);
|
||||
}
|
||||
|
||||
// Restores per-FX offline from the plan verbatim — never a blanket "online".
|
||||
// Which live FX each captured state belongs to is resolveFxRestore's call, and
|
||||
// what it could not place comes back for the caller to report.
|
||||
FxRestoreDrops applyRestore(MediaTrack* tr, const std::vector<FxOfflineOp>& ops) {
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, liveFxGuids(tr));
|
||||
for (const FxOfflineWrite& w : res.writes) setOfflineIfChanged(tr, w.fxIndex, w.offline);
|
||||
return res.drops;
|
||||
}
|
||||
|
||||
// The FX's own durable identity, braced exactly like the track GUID keys. Empty
|
||||
// when REAPER reports none — an FX we cannot name is one we cannot restore, and
|
||||
// fx_offline treats it that way rather than guessing at its slot. Lifetime is
|
||||
// settled: the string copy is taken immediately and the GUID* is never held
|
||||
// past this call (reaper_plugin_functions.h:7348 documents no null contract for
|
||||
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
|
||||
//
|
||||
// [verify — DAW] STABILITY across a chain mutation is not settled the same way:
|
||||
// confirm the GUID for one FX instance survives a native drag-reorder, an SWS
|
||||
// move (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not
|
||||
// follow the plugin after that call, i.e. wrong-plugin restores or mass drops
|
||||
// through fx_offline on that path specifically), a save/reload round trip, and
|
||||
// two live instances of one plugin type staying distinguishable. See
|
||||
// src/shell/view/CLAUDE.md's Gotchas for the SWS-path risk this leaves open.
|
||||
std::string fxGuidString(MediaTrack* tr, int fx) {
|
||||
GUID* g = TrackFX_GetFXGUID(tr, fx);
|
||||
if (!g) return {};
|
||||
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
|
||||
guidToString(g, buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
|
||||
const int fxCount = TrackFX_GetCount(tr);
|
||||
std::vector<std::string> guids;
|
||||
guids.reserve(static_cast<std::size_t>(fxCount));
|
||||
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
|
||||
return guids;
|
||||
}
|
||||
|
||||
PreParkFx snapshotFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelled) {
|
||||
PreParkFx carried = preParkFxFromCancelledRestore(cancelled);
|
||||
if (!carried.states.empty()) return carried;
|
||||
|
||||
// Nothing was cancelled, so the chain reads true. fxKeying stays Identity —
|
||||
// a live capture always knows the chain.
|
||||
PreParkFx live;
|
||||
const std::vector<std::string> guids = liveFxGuids(tr);
|
||||
live.states.reserve(guids.size());
|
||||
for (std::size_t fx = 0; fx < guids.size(); ++fx)
|
||||
live.states.push_back(
|
||||
FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast<int>(fx)) ? 1 : 0});
|
||||
return live;
|
||||
}
|
||||
|
||||
std::vector<FxOfflineOp> deferFxPark(ReaProject* proj, const std::string& guid) {
|
||||
adoptOwner(proj);
|
||||
return g_queue.park(guid);
|
||||
}
|
||||
|
||||
void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOfflineOp> ops) {
|
||||
adoptOwner(proj);
|
||||
g_queue.restore(guid, std::move(ops));
|
||||
}
|
||||
|
||||
void discardDeferredFxParks() {
|
||||
g_queue.clear();
|
||||
g_owner = nullptr;
|
||||
}
|
||||
|
||||
void drainDeferredFxParks() {
|
||||
if (g_queue.empty()) return;
|
||||
if (g_draining) return; // re-entered mid-apply: those intents are the outer drain's next pass
|
||||
|
||||
if (g_owner != currentProject()) {
|
||||
// The project the intents were planned against was closed or switched
|
||||
// away from: its tracks are not ours to write and its handles may be
|
||||
// gone. Discard rather than apply. This catches only a DIFFERENT live
|
||||
// pointer; a REAPER-recycled address is caught upstream, by the caller
|
||||
// discarding on the session's own load transition.
|
||||
discardDeferredFxParks();
|
||||
return;
|
||||
}
|
||||
|
||||
ReaProject* const proj = g_owner;
|
||||
const DrainScope scope;
|
||||
|
||||
// Detached before the first write: [verify — DAW] applying is ASSUMED to pump
|
||||
// the message loop (plugins load and unload), so a re-entrant switch can
|
||||
// enqueue while this runs. Everything defensive below rests on that one
|
||||
// assumption; each piece is correct regardless of whether it holds.
|
||||
const std::vector<FxParkIntent> draining = g_queue.take();
|
||||
|
||||
std::unordered_map<std::string, MediaTrack*> byGuid;
|
||||
const int count = CountTracks(proj);
|
||||
byGuid.reserve(static_cast<std::size_t>(count));
|
||||
for (int i = 0; i < count; ++i) {
|
||||
MediaTrack* tr = GetTrack(proj, i);
|
||||
if (!tr) continue;
|
||||
std::string guid = guidString(tr);
|
||||
if (!guid.empty()) byGuid.emplace(std::move(guid), tr);
|
||||
}
|
||||
|
||||
FxRestoreDrops drops;
|
||||
int dropTracks = 0;
|
||||
for (const FxParkIntent& intent : draining) {
|
||||
auto it = byGuid.find(intent.guid);
|
||||
if (it == byGuid.end()) continue; // track deleted since the switch — prune
|
||||
|
||||
// Re-validated PER INTENT, not once above: under the same pumping
|
||||
// assumption, a project closed or a track deleted between two applies
|
||||
// leaves the handle resolved above dangling — a use-after-free, not a
|
||||
// pruned intent. Same gate capture_realtime_shell's teardown uses; a null
|
||||
// first argument validates the ReaProject* itself (SDK: proj is ignored
|
||||
// when the pointer is a project).
|
||||
if (!ValidatePtr2(nullptr, proj, "ReaProject*")) return;
|
||||
if (!ValidatePtr2(proj, it->second, "MediaTrack*")) continue;
|
||||
|
||||
if (intent.park) {
|
||||
applyPark(it->second);
|
||||
continue;
|
||||
}
|
||||
const FxRestoreDrops d = applyRestore(it->second, intent.restoreOps);
|
||||
if (d.total() > 0) {
|
||||
drops.add(d);
|
||||
++dropTracks;
|
||||
}
|
||||
}
|
||||
|
||||
// Captured FX state that could not be applied is REPORTED — silence would read
|
||||
// to the user as "restore worked" while an FX sat at whatever state the park
|
||||
// left it in. The "!SHOW:" prefix (reaper_plugin_functions.h:6536) keeps it from
|
||||
// force-opening the console: this drain also runs behind an unattended
|
||||
// project-load reapply, and cannot tell that case from an interactive toggle.
|
||||
const std::string fxDropMsg = describeFxRestoreDrops(drops, dropTracks);
|
||||
if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + fxDropMsg).c_str());
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,179 @@
|
||||
#pragma once
|
||||
// Design View's per-FX offline surface: the FX-identity read that snapshot,
|
||||
// park and restore all address FX through, the deferred intent queue that keeps
|
||||
// TrackFX_SetOffline off the mode switch's synchronous path, and the idle-tick
|
||||
// drain that applies it. See src/shell/view/CLAUDE.md's FX-parking caveat.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/view/fx_offline.h"
|
||||
|
||||
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
|
||||
class MediaTrack;
|
||||
class ReaProject;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The chain as it stands now: identity by current slot. Snapshot, park and
|
||||
// restore all address FX through this one plain 0..TrackFX_GetCount-1
|
||||
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
|
||||
// whatever it covers, all three cover identically.
|
||||
std::vector<std::string> liveFxGuids(MediaTrack* tr);
|
||||
|
||||
// One deferred per-FX intent for one track. A park carries no ops (every live
|
||||
// slot goes offline); a restore carries the planned ops verbatim.
|
||||
struct FxParkIntent {
|
||||
std::string guid;
|
||||
bool park = false;
|
||||
std::vector<FxOfflineOp> restoreOps;
|
||||
};
|
||||
|
||||
// The queue's re-entrancy rule, pure so it can be asserted without a DAW: at
|
||||
// most ONE intent per track GUID, and the latest one wins. Park and restore are
|
||||
// inverses, so an intent landing on its own pending inverse CANCELS it rather
|
||||
// than stacking — the queued work never ran, so the track already holds the
|
||||
// state the newcomer asks for, and replaying both would be both slower and
|
||||
// observably wrong.
|
||||
class FxParkQueue {
|
||||
public:
|
||||
// Returns the ops of a pending restore this park CANCELLED, empty otherwise.
|
||||
// The caller needs them: that restore never ran, so the live chain still
|
||||
// reads the PARKED offline states and is no longer a source for a fresh
|
||||
// pre-park snapshot (see preParkFxFromCancelledRestore).
|
||||
//
|
||||
// The empty return is a LOAD-BEARING sentinel that deliberately conflates
|
||||
// "nothing was cancelled" with "cancelled a restore carrying no ops": a
|
||||
// zero-op restore held no FX state to hand back, so falling through to a
|
||||
// live chain read is the same answer, not a worse one.
|
||||
std::vector<FxOfflineOp> park(const std::string& guid) {
|
||||
FxParkIntent* held = find(guid);
|
||||
if (!held) {
|
||||
pending_.push_back(FxParkIntent{guid, true, {}});
|
||||
return {};
|
||||
}
|
||||
if (held->park) return {};
|
||||
std::vector<FxOfflineOp> cancelled = std::move(held->restoreOps);
|
||||
erase(held);
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
// A restore cancelling a pending park is COMPLETE at that point — the park
|
||||
// never ran, so no drain will ever come for this GUID.
|
||||
void restore(const std::string& guid, std::vector<FxOfflineOp> ops) {
|
||||
FxParkIntent* held = find(guid);
|
||||
if (!held) {
|
||||
pending_.push_back(FxParkIntent{guid, false, std::move(ops)});
|
||||
} else if (held->park) {
|
||||
erase(held);
|
||||
} else {
|
||||
held->restoreOps = std::move(ops);
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue order, which is apply order: park before restore within one
|
||||
// switch, as the synchronous body already orders them.
|
||||
const std::vector<FxParkIntent>& pending() const { return pending_; }
|
||||
bool empty() const { return pending_.empty(); }
|
||||
void clear() { pending_.clear(); }
|
||||
|
||||
// Detaches everything pending, leaving the queue able to accept intents
|
||||
// enqueued WHILE the caller applies what it took. [verify — DAW] applying
|
||||
// loads/unloads plugins, which is ASSUMED to pump the message loop, so a
|
||||
// re-entrant switch can enqueue mid-apply: iterating the live queue would
|
||||
// dangle on the push_back, and clearing it afterwards would discard
|
||||
// whatever arrived during the apply. The detach is correct either way; only
|
||||
// the need for it is unconfirmed.
|
||||
std::vector<FxParkIntent> take() {
|
||||
std::vector<FxParkIntent> taken;
|
||||
taken.swap(pending_);
|
||||
return taken;
|
||||
}
|
||||
|
||||
private:
|
||||
FxParkIntent* find(const std::string& guid) {
|
||||
for (FxParkIntent& i : pending_)
|
||||
if (i.guid == guid) return &i;
|
||||
return nullptr;
|
||||
}
|
||||
void erase(FxParkIntent* held) {
|
||||
pending_.erase(pending_.begin() + (held - pending_.data()));
|
||||
}
|
||||
|
||||
std::vector<FxParkIntent> pending_;
|
||||
};
|
||||
|
||||
// The FX half of a snapshot, with the keying it must be read back under.
|
||||
struct PreParkFx {
|
||||
std::vector<FxOfflineState> states;
|
||||
FxKeying keying = FxKeying::Identity;
|
||||
};
|
||||
|
||||
// The FX half a fresh pre-park snapshot must carry when the park CANCELLED a
|
||||
// pending restore: those ops are the only surviving record of the pre-park
|
||||
// state, because the chain still reads the parked values until that restore
|
||||
// drains — and it never will, the cancel dropped it. Empty in (nothing was
|
||||
// cancelled) means the caller reads the live chain instead. The restore's own
|
||||
// keying travels with it so a slot-keyed snapshot lifted from a legacy
|
||||
// view_state does not silently become an identity-keyed one with no identities.
|
||||
//
|
||||
// FAITHFUL TO THE SNAPSHOT, NOT THE CHAIN. The ops describe the chain as it was
|
||||
// at the ORIGINAL park; applyPark enumerates it again at drain time. So an FX
|
||||
// added while the track was parked (a floating FX-chain window, ReaScript) is
|
||||
// absent from this reconstruction yet IS offlined by the cancelling park's
|
||||
// drain — and so never comes back online. Rare, and recoverable by hand in the
|
||||
// FX chain, but specific to the deferral.
|
||||
//
|
||||
// `offline` is already boolean by the time it arrives: makeRestorePlan narrowed
|
||||
// FxOfflineState's defensive int to FxOfflineOp's bool, so this widening back to
|
||||
// int restores the type, not lost information.
|
||||
inline PreParkFx preParkFxFromCancelledRestore(const std::vector<FxOfflineOp>& cancelled) {
|
||||
PreParkFx out;
|
||||
if (cancelled.empty()) return out;
|
||||
out.keying = cancelled.front().keying;
|
||||
out.states.reserve(cancelled.size());
|
||||
for (const FxOfflineOp& op : cancelled)
|
||||
out.states.push_back(FxOfflineState{op.fxGuid, op.offline ? 1 : 0});
|
||||
return out;
|
||||
}
|
||||
|
||||
// The FX half of a fresh pre-park snapshot for `tr`: whatever the accompanying
|
||||
// park cancelled, else a live read of the chain.
|
||||
PreParkFx snapshotFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelled);
|
||||
|
||||
// Enqueue against `proj` (nullptr = current project). An enqueue naming a
|
||||
// different project than the pending intents discards those unapplied. The park
|
||||
// returns whatever pending restore it cancelled, per FxParkQueue::park.
|
||||
std::vector<FxOfflineOp> deferFxPark(ReaProject* proj, const std::string& guid);
|
||||
void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOfflineOp> ops);
|
||||
|
||||
// Applies every pending intent. Touches NO model state — a restore's snapshot is
|
||||
// dropped where the restore is planned (applyMode). Discards the queue unapplied
|
||||
// if the project it was enqueued against is no longer current (close / switch);
|
||||
// an intent whose track is gone is pruned. Idle cost is one empty-queue test.
|
||||
// Re-entrant calls early-out: one drain at a time, the outer one owns the queue.
|
||||
//
|
||||
// Called on the idle tick AND synchronously before the view model is serialized
|
||||
// (persistViewState, render_in_place). The second call is not an optimization:
|
||||
// between a restore's synchronous flag writes and its drain the FX are still
|
||||
// offline while the model has already dropped the snapshot that would replan
|
||||
// them, so a save inside that window — deterministic under a custom action chain
|
||||
// like "toggle mode; save project" — records offline FX beside a snapshot-free
|
||||
// model, and nothing on reopen brings them back online. The cost is that an
|
||||
// action-driven switch pays the FX hitch before it returns; its repaint and undo
|
||||
// block have both closed by then, which is what the deferral was for.
|
||||
void drainDeferredFxParks();
|
||||
|
||||
// Drops every pending intent without applying it. Called when the model the
|
||||
// intents were planned against has been replaced (project load/switch, undo/redo
|
||||
// state restore) — applying them then would write the pre-reload plan over the
|
||||
// project that replaced it.
|
||||
//
|
||||
// Not pure loss: the caller reapplies the active mode over the reloaded model
|
||||
// immediately after, which re-plans a park for every inactive leaf, so parked FX
|
||||
// converge on the following drain. The case that does NOT self-heal is a track
|
||||
// whose reloaded model carries no snapshot — nothing plans a restore for it, so
|
||||
// FX left offline stay offline.
|
||||
void discardDeferredFxParks();
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -192,6 +192,16 @@ static void testWaveformColumnCount() {
|
||||
CHECK(waveformColumnCount(KitBox{0, 0, 0, 40}) == 0);
|
||||
}
|
||||
|
||||
// The instrument's waveform overlay rides this exact column band, and derives its own left
|
||||
// inset by halving what this leaves (waveform_view's waveformOverlayArea) rather than keeping a
|
||||
// second copy of the inset. That halving is only correct while the inset is symmetric, so pin
|
||||
// it: every drawable width loses exactly 4, two per side.
|
||||
static void testTheColumnBandIsInsetSymmetrically() {
|
||||
for (int w = 5; w <= 400; ++w) {
|
||||
CHECK(w - waveformColumnCount(KitBox{0, 0, w, 40}) == 4);
|
||||
}
|
||||
}
|
||||
|
||||
// --- waveform column span ----------------------------------------------------
|
||||
|
||||
// The regression this exists to catch: rounding applied to the resulting y instead of to the
|
||||
@@ -293,6 +303,7 @@ int main() {
|
||||
testListRowHitTestBoundedByCount();
|
||||
testListRowLayoutHitAgreement();
|
||||
testWaveformColumnCount();
|
||||
testTheColumnBandIsInsetSymmetrically();
|
||||
testSymmetricColumnDrawsEqualHeightAboveAndBelowTheZeroLine();
|
||||
testSilentColumnCollapsesOntoTheZeroLine();
|
||||
testTallerAmplitudeNeverDrawsAShorterColumn();
|
||||
|
||||
+155
-54
@@ -125,11 +125,10 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
|
||||
const std::vector<int> expected = {
|
||||
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
|
||||
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
|
||||
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
|
||||
cell(DeckParam::kFilterKeyTrack)};
|
||||
cell(DeckParam::kFilterVel), cell(DeckParam::kFilterKeyTrack)};
|
||||
CHECK(f.cellIds == expected);
|
||||
// Off by default is a state question, but reachability is a layout one: BOTH toggles now
|
||||
// ride the caption row, which is what takes the group from 524 to 432.
|
||||
// ride the caption row, which is what takes the group from 524 to 372.
|
||||
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
|
||||
CHECK(f.captionToggle2.id == cell(DeckParam::kFilterLaw));
|
||||
CHECK(f.rowToggle.id == -1);
|
||||
@@ -138,39 +137,111 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
|
||||
const std::vector<int> env = {
|
||||
cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold),
|
||||
cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain),
|
||||
cell(DeckParam::kFilterEnvRelease)};
|
||||
cell(DeckParam::kFilterEnvRelease), cell(DeckParam::kFilterModAmt)};
|
||||
CHECK(fe.cellIds == env);
|
||||
// The filter envelope has no enable of its own — the FILTER group's toggle governs both.
|
||||
CHECK(fe.captionToggle.id == -1);
|
||||
CHECK(fe.rowToggle.id == -1);
|
||||
}
|
||||
|
||||
// Exactly the three envelope decks carry a SELECTABLE overlay radio, each its own, and no
|
||||
// other group has one — the exclusivity the shell enforces is only meaningful if the id space
|
||||
// is. MASTER occupies the same corner slot with a PASSIVE lamp, which is a different thing:
|
||||
// it must never be counted as, or reachable as, a selector.
|
||||
static void testOnlyTheThreeEnvelopeDecksCarryASelectableRadio() {
|
||||
// The mod DEPTH sits with the envelope it scales, LAST in that group's run, in both faces —
|
||||
// the kPitchEnvDepth shape. And it left FILTER: a control drawn in two groups would be two
|
||||
// controls to the user even though it is one parameter.
|
||||
static void testTheFilterModDepthLivesWithTheFilterEnvelopeInBothFaces() {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
int radios = 0;
|
||||
const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
|
||||
CHECK(fe.cellIds.back() == cell(DeckParam::kFilterModAmt));
|
||||
// Exactly once across the WHOLE deck, and not in FILTER.
|
||||
int seen = 0;
|
||||
for (const DeckGroupDesc& d : g) {
|
||||
for (int c : d.cellIds) {
|
||||
if (c != cell(DeckParam::kFilterModAmt)) continue;
|
||||
++seen;
|
||||
CHECK(d.id == kGroupFilterEnv);
|
||||
}
|
||||
}
|
||||
CHECK(seen == 1);
|
||||
// The depth knob mirrors kPitchEnvDepth: last in its envelope's run, and neither is a
|
||||
// staged segment, so neither carries an inner curve dial.
|
||||
const DeckGroupDesc& pe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupPitchEnv))];
|
||||
CHECK(pe.cellIds.back() == cell(DeckParam::kPitchEnvDepth));
|
||||
CHECK(curveParamFor(DeckParam::kFilterModAmt) == DeckParam::kCount);
|
||||
}
|
||||
}
|
||||
|
||||
// No group carries a selectable overlay radio any more — the deck itself is the target, and a
|
||||
// radio beside it would be a second way to say the same thing. MASTER keeps the corner slot for
|
||||
// its PASSIVE gain-reduction lamp, which is a readout and must never become a selector.
|
||||
static void testNoGroupCarriesASelectableRadioAndMasterKeepsItsLamp() {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
int lamps = 0;
|
||||
for (const DeckGroupDesc& d : g) {
|
||||
if (d.captionRadio.id < 0) continue;
|
||||
if (d.captionRadio.passive) {
|
||||
CHECK(d.id == kGroupMaster);
|
||||
CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr));
|
||||
// A passive slot names no overlay, so no click on it could select one even if
|
||||
// the hit-test ever handed it through.
|
||||
CHECK(overlayEnvForRadio(d.captionRadio.id) == OverlayEnv::kNone);
|
||||
continue;
|
||||
}
|
||||
++radios;
|
||||
const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect)
|
||||
: d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect)
|
||||
: d.id == kGroupFilterEnv ? cell(DeckParam::kFilterEnvSelect)
|
||||
: -1;
|
||||
CHECK(d.captionRadio.id == want);
|
||||
CHECK(d.captionRadio.passive);
|
||||
CHECK(d.id == kGroupMaster);
|
||||
CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr));
|
||||
++lamps;
|
||||
}
|
||||
CHECK(radios == 3);
|
||||
CHECK(lamps == 1);
|
||||
}
|
||||
}
|
||||
|
||||
// The focus map: exactly the three envelope decks name an overlay, every other group and every
|
||||
// off-deck point (-1) names kNone — which is how a click outside them CLEARS the focus. Setting
|
||||
// is idempotent by construction: the map is a function of the group alone, so re-clicking a
|
||||
// focused deck cannot toggle it off.
|
||||
static void testTheOverlayFocusMapNamesTheThreeEnvelopeDecksAndNothingElse() {
|
||||
CHECK(overlayEnvForGroup(kGroupAmpEnv) == OverlayEnv::kAmp);
|
||||
CHECK(overlayEnvForGroup(kGroupPitchEnv) == OverlayEnv::kPitch);
|
||||
CHECK(overlayEnvForGroup(kGroupFilterEnv) == OverlayEnv::kFilter);
|
||||
for (int id : {kGroupPitch, kGroupFilter, kGroupVelocity, kGroupVoice, kGroupMaster}) {
|
||||
CHECK(overlayEnvForGroup(id) == OverlayEnv::kNone);
|
||||
}
|
||||
CHECK(overlayEnvForGroup(-1) == OverlayEnv::kNone); // outside every deck
|
||||
CHECK(overlayEnvForGroup(9999) == OverlayEnv::kNone); // not a group id at all
|
||||
|
||||
// The map is TOTAL over the shipped inventory: every group answers, and exactly three
|
||||
// answer with an envelope, so a group added without a decision here shows up as a miscount.
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
int named = 0;
|
||||
for (const DeckGroupDesc& d : sampleDeckGroups(mode)) {
|
||||
if (overlayEnvForGroup(d.id) != OverlayEnv::kNone) ++named;
|
||||
}
|
||||
CHECK(named == 3);
|
||||
}
|
||||
}
|
||||
|
||||
// Every converted control is ONE button, and the two variants are told apart structurally
|
||||
// rather than by what they are labelled: an enable has an off state, a mode selector's label
|
||||
// IS the state. The five explicitly-not-converted controls keep their two segments — a named
|
||||
// boundary, not an oversight. Eleven toggles ship; the count is asserted so a new one cannot
|
||||
// arrive without a style decision here.
|
||||
static void testTheConvertedTogglesAreSingleButtonsAndTheRestStaySegmented() {
|
||||
const DeckParam enables[] = {DeckParam::kPitchEnvEnable, DeckParam::kFilterEnable,
|
||||
DeckParam::kLimiterEnable};
|
||||
const DeckParam modes[] = {DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode,
|
||||
DeckParam::kFilterEnvMode};
|
||||
const DeckParam segmented[] = {DeckParam::kPlayMode, DeckParam::kPitchEngine,
|
||||
DeckParam::kVoiceMode, DeckParam::kFilterLaw,
|
||||
DeckParam::kMonoTrigger};
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
int seen = 0;
|
||||
for (const DeckGroupDesc& d : sampleDeckGroups(mode)) {
|
||||
for (const DeckToggleDesc* t : {&d.captionToggle, &d.captionToggle2, &d.rowToggle}) {
|
||||
if (t->id < 0) continue;
|
||||
++seen;
|
||||
DeckToggleStyle want = DeckToggleStyle::kSegmented;
|
||||
for (DeckParam p : enables) if (t->id == cell(p)) want = DeckToggleStyle::kEnable;
|
||||
for (DeckParam p : modes) if (t->id == cell(p)) want = DeckToggleStyle::kMode;
|
||||
bool named = want != DeckToggleStyle::kSegmented;
|
||||
for (DeckParam p : segmented) if (t->id == cell(p)) named = true;
|
||||
CHECK(named); // every shipped toggle is one of the eight named above
|
||||
CHECK(t->style == want);
|
||||
}
|
||||
}
|
||||
CHECK(seen == 11);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +266,8 @@ static void testGateAndTriggerFacesCarryTheirOwnShapes() {
|
||||
const DeckGroupDesc& tFe = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupFilterEnv))];
|
||||
const std::vector<int> trigFe = {cell(DeckParam::kFilterTrigAttack),
|
||||
cell(DeckParam::kFilterTrigHold),
|
||||
cell(DeckParam::kFilterTrigDecay), -1, -1};
|
||||
cell(DeckParam::kFilterTrigDecay), -1, -1,
|
||||
cell(DeckParam::kFilterModAmt)};
|
||||
CHECK(tFe.cellIds == trigFe);
|
||||
CHECK(gFe.cellIds != tFe.cellIds);
|
||||
// Same cell count either way, so the group's width — and its neighbours' placement —
|
||||
@@ -306,19 +378,19 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() {
|
||||
}
|
||||
}
|
||||
|
||||
// The gap fix as a property of the shipped descriptors, not a picture: whichever face a
|
||||
// mode-dependent group shows, its knob row still spans the group's whole reserved run. The
|
||||
// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
|
||||
// dropped control was. What the run does not cover is the indivisible residue alone, strictly
|
||||
// under one pixel per cell. Checked at both a tight and a genuinely wider width.
|
||||
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
|
||||
// Every knob on the deck sits at its natural pitch in BOTH faces, and a reduced face pays for
|
||||
// its dropped controls in symmetric end margins rather than in wider cells — the defect this
|
||||
// track closes was Trigger's FILTER ENV at ~100px cells and its AMP at ~75 against the standard
|
||||
// 60. Checked at both a tight and a genuinely wider width, since the group box moves with the
|
||||
// justification but the run inside it must not change shape.
|
||||
static void testEveryCellKeepsItsNaturalPitchInBothFaces() {
|
||||
for (int avail : {kSampleAvail, kSampleAvailWide}) {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
const DeckLayout dl = layoutDeck(g, kSamplePad, 0, avail);
|
||||
CHECK(dl.groups.size() == g.size());
|
||||
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
|
||||
// The spanning deck's slots STACK — the run-division law this pins is the
|
||||
// The spanning deck's slots STACK — the centring law this pins is the
|
||||
// horizontal one, and its vertical guard is its own test.
|
||||
if (g[i].row == DeckRow::Spanning) continue;
|
||||
const DeckGroupLayout& lay = dl.groups[i];
|
||||
@@ -328,20 +400,43 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
|
||||
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);
|
||||
CHECK(c.cell.width == kDeckCellW);
|
||||
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);
|
||||
const int lead = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
|
||||
const int trail =
|
||||
(lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
|
||||
CHECK(lead >= 0 && trail >= 0);
|
||||
// A group whose knob row is not what it measures from (VOICE's row toggle, or
|
||||
// a caption-bound group) has trailing box width beyond the run; the LEAD margin
|
||||
// is the reserve's own half either way.
|
||||
CHECK(lead == (reserved - static_cast<int>(present) * kDeckCellW) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The two mode-dependent groups are where the defect lived: their reserves buy a stable box
|
||||
// width, and after the reflow they buy it without stretching a single knob.
|
||||
static void testTheReducedTriggerFacesAreTheSameKnobsAsGateJustCentred() {
|
||||
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
|
||||
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
|
||||
const DeckLayout gl = layoutDeck(gate, kSamplePad, 0, kSampleAvail);
|
||||
const DeckLayout tl = layoutDeck(trig, kSamplePad, 0, kSampleAvail);
|
||||
for (int id : {kGroupFilterEnv, kGroupAmpEnv}) {
|
||||
const DeckGroupLayout& a = gl.groups[static_cast<std::size_t>(indexOfGroup(gate, id))];
|
||||
const DeckGroupLayout& b = tl.groups[static_cast<std::size_t>(indexOfGroup(trig, id))];
|
||||
CHECK(a.box == b.box); // the box does not move — what the reserves are for
|
||||
CHECK(b.cells.size() < a.cells.size());
|
||||
for (const DeckCellLayout& c : b.cells) CHECK(c.cell.width == kDeckCellW);
|
||||
// Centred: the two margins match, and together they are the dropped cells' width.
|
||||
const int lead = b.cells.front().cell.x - (b.box.x + kDeckGroupPadX);
|
||||
const int trail = (b.box.right() - kDeckGroupPadX) - b.cells.back().cell.right();
|
||||
CHECK(lead == trail);
|
||||
CHECK(lead + trail ==
|
||||
static_cast<int>(a.cells.size() - b.cells.size()) * kDeckCellW);
|
||||
}
|
||||
}
|
||||
|
||||
static void testHitTestResolvesTheNewFilterControls() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
@@ -356,17 +451,17 @@ static void testHitTestResolvesTheNewFilterControls() {
|
||||
CHECK(hit.kind == DeckHitKind::Knob);
|
||||
CHECK(hit.id == c.id);
|
||||
}
|
||||
CHECK(f.cells.size() == 7);
|
||||
CHECK(f.cells.size() == 6);
|
||||
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
|
||||
|
||||
// The enable toggle's two segments and the morph-law row toggle's two.
|
||||
const DeckHit off = hitTestDeck(dl, f.captionToggle.seg0.x + 2,
|
||||
f.captionToggle.seg0.y + 2);
|
||||
CHECK(off.kind == DeckHitKind::CaptionToggle);
|
||||
CHECK(off.id == cell(DeckParam::kFilterEnable) && off.segment == 0);
|
||||
const DeckHit on = hitTestDeck(dl, f.captionToggle.seg1.x + 2,
|
||||
f.captionToggle.seg1.y + 2);
|
||||
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
|
||||
// The enable is ONE button now: both ends of it answer the same hit with no segment, so
|
||||
// the commit has to derive the next state rather than read one off the click.
|
||||
for (int px : {f.captionToggle.seg0.x + 2, f.captionToggle.seg0.right() - 2}) {
|
||||
const DeckHit en = hitTestDeck(dl, px, f.captionToggle.seg0.y + 2);
|
||||
CHECK(en.kind == DeckHitKind::CaptionToggle);
|
||||
CHECK(en.id == cell(DeckParam::kFilterEnable) && en.segment == -1);
|
||||
CHECK(en.group == kGroupFilter);
|
||||
}
|
||||
|
||||
// The morph law answers from its NEW home in the caption row, and as a CaptionToggle —
|
||||
// the shell's toggle branch handles both kinds, so the move must not change the id or the
|
||||
@@ -409,7 +504,7 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
||||
}
|
||||
|
||||
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 && a.style == b.style;
|
||||
}
|
||||
|
||||
static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
|
||||
@@ -449,7 +544,8 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
|
||||
enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through
|
||||
CHECK(p.playMode == PlayMode::Trigger);
|
||||
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 drops a cell and the shorter run re-centres, so its
|
||||
// first knob starts further in than Gate's. (It is not WIDER — the cells hold their pitch.)
|
||||
const DeckGroupLayout& gateAmp =
|
||||
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
|
||||
kGroupAmpEnv))];
|
||||
@@ -457,7 +553,8 @@ static void testGateSplineGateRoundTripsToTheSameLayout() {
|
||||
drawn.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Trigger),
|
||||
kGroupAmpEnv))];
|
||||
CHECK(trigAmp.cells.size() < gateAmp.cells.size());
|
||||
CHECK(trigAmp.cells[0].cell.width > gateAmp.cells[0].cell.width);
|
||||
CHECK(trigAmp.cells[0].cell.width == gateAmp.cells[0].cell.width);
|
||||
CHECK(trigAmp.cells[0].cell.x > gateAmp.cells[0].cell.x);
|
||||
CHECK(!sameLayout(before, drawn));
|
||||
|
||||
p.ampSpline.mode = EnvMode::Staged;
|
||||
@@ -473,13 +570,17 @@ int main() {
|
||||
testCurveTargetNamesEachCellsOwnDestination();
|
||||
testVelocityCellsHitTestWithinTheirGroup();
|
||||
testFilterGroupCarriesItsToneControlsPlusModulation();
|
||||
testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
|
||||
testTheFilterModDepthLivesWithTheFilterEnvelopeInBothFaces();
|
||||
testNoGroupCarriesASelectableRadioAndMasterKeepsItsLamp();
|
||||
testTheOverlayFocusMapNamesTheThreeEnvelopeDecksAndNothingElse();
|
||||
testTheConvertedTogglesAreSingleButtonsAndTheRestStaySegmented();
|
||||
testGateAndTriggerFacesCarryTheirOwnShapes();
|
||||
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
|
||||
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||||
testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
|
||||
testEveryDeckGroupBelongsToExactlyOneRow();
|
||||
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
|
||||
testEveryCellKeepsItsNaturalPitchInBothFaces();
|
||||
testTheReducedTriggerFacesAreTheSameKnobsAsGateJustCentred();
|
||||
testHitTestResolvesTheNewFilterControls();
|
||||
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
||||
testGateSplineGateRoundTripsToTheSameLayout();
|
||||
|
||||
@@ -82,10 +82,10 @@ static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
|
||||
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.
|
||||
// Both rows fit their block, in BOTH play modes. The filter mod depth's move across the rows is
|
||||
// what these two numbers now carry: SOUND loses one cell (980 -> 920) and CONTOUR gains one
|
||||
// (876 -> 936). Row 2's 936 is mode-stable because FILTER ENV's and AMP's reserve slots hold
|
||||
// them at 372/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);
|
||||
@@ -101,9 +101,9 @@ static void testBothRowsAndTheSpanningDeckFitTheBudget() {
|
||||
const int spanning = static_cast<int>(DeckRow::Spanning);
|
||||
|
||||
CHECK(count[sound] == 4);
|
||||
CHECK(width[sound] == 980); // 192 + 432 + 192 + 164
|
||||
CHECK(width[sound] == 920); // 192 + 372 + 192 + 164
|
||||
CHECK(count[contour] == 3);
|
||||
CHECK(width[contour] == 876); // 252 + 312 + 312
|
||||
CHECK(width[contour] == 936); // 252 + 372 + 312
|
||||
CHECK(count[spanning] == 1);
|
||||
CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent
|
||||
|
||||
@@ -115,38 +115,46 @@ static void testBothRowsAndTheSpanningDeckFitTheBudget() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// The gutters the justification law produces at the floor — and the alignment it no longer
|
||||
// buys. THE FILTER TIE-LINE IS GONE, and it is recorded here as a LOSS rather than left to be
|
||||
// rediscovered: moving the mod depth from FILTER to FILTER ENV made the two filter groups
|
||||
// EQUAL in width (372 each), and under space-between two equal groups whose rows carry
|
||||
// different preceding widths can only share a right edge at one block width — which the
|
||||
// arithmetic below shows is far below the width either row needs. It is unreachable, not
|
||||
// merely missed, so kDeckRowBlockW and the editor floor are deliberately NOT moved to chase it.
|
||||
static void testGutterArithmeticAndTheLostFilterTieLineAtTheFloor() {
|
||||
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
|
||||
// Row 1: flush left, flush right on the block, and three EQUAL gutters — 108 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(kGroupFilter).x - box(kGroupPitch).right() == 36);
|
||||
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 36);
|
||||
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 36);
|
||||
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
|
||||
|
||||
// Row 2: flush left, flush right, two gutters exactly equal.
|
||||
// Row 2: flush left, flush right, two gutters exactly equal — 92 over two.
|
||||
CHECK(box(kGroupPitchEnv).x == kPad);
|
||||
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 76);
|
||||
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 76);
|
||||
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 46);
|
||||
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 46);
|
||||
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());
|
||||
// The loss, block-relative and exact: row 1's filter edge lands 70 px LEFT of row 2's.
|
||||
CHECK(box(kGroupFilter).right() - kPad == 600);
|
||||
CHECK(box(kGroupFilterEnv).right() - kPad == 670);
|
||||
CHECK(box(kGroupFilter).right() != box(kGroupFilterEnv).right());
|
||||
|
||||
// And it is unreachable at any block width, which is the part that makes it a loss rather
|
||||
// than a tuning problem. Solving 192 + (W-920)/3 == 252 + (W-936)/2 over the reals gives
|
||||
// W = 608 — narrower than either row's own content (920 and 936), so no block that can
|
||||
// hold the deck at all can also tie the two edges.
|
||||
const double tieAt = 608.0;
|
||||
for (int W : {920, 936, kDeckRowBlockW}) CHECK(static_cast<double>(W) > tieAt);
|
||||
CHECK(192.0 + (tieAt - 920.0) / 3.0 == 252.0 + (tieAt - 936.0) / 2.0);
|
||||
|
||||
// MASTER is right-anchored outside the block, one kDeckGroupGap clear of it.
|
||||
CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap);
|
||||
@@ -155,9 +163,9 @@ static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
|
||||
|
||||
// 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.
|
||||
// The two filter edges SEPARATE monotonically with width, which is accepted and deliberate:
|
||||
// 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 only opens. 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
|
||||
@@ -190,9 +198,8 @@ static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
|
||||
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);
|
||||
// It really does open up, from the −70 the floor already carries.
|
||||
CHECK(lastDrift < -70);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +275,74 @@ static void testTheMasterColumnDoesNotDivideItsRunVertically() {
|
||||
CHECK(m2.column.box == m.column.box);
|
||||
}
|
||||
|
||||
// MASTER's caption row and knob row measure exactly equal (130 == 130) today, so a column
|
||||
// derived from either edge lands in the same place — that balance is what let a left-derived
|
||||
// offset masquerade as right-anchored. Widen the caption reserve alone (as a wider caption or
|
||||
// a limiter-toggle change would) and the column must still land flush against the group's own
|
||||
// right padding, derived from innerRight rather than measured past the cell slots.
|
||||
// MASTER is the group that BINDS the single-button enable width, and it has ZERO slack: its
|
||||
// knob row measures kDeckSpanningW − 2·pad, so the caption row (46 + gap + button + gap + the
|
||||
// GR lamp) may reach exactly that and no more. Past 64 the caption row takes over, the spanning
|
||||
// deck grows, and the growth comes straight out of the 82 px between the editor's floor and its
|
||||
// ceiling. Pinned at the boundary in both directions rather than as an inequality.
|
||||
static void testTheLimiterButtonIsAtMostSixtyFourPxBeforeMasterGrows() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
const DeckGroupDesc& m = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
||||
CHECK(m.captionToggle.id == cell(DeckParam::kLimiterEnable));
|
||||
CHECK(m.captionToggle.style == DeckToggleStyle::kEnable);
|
||||
CHECK(deckGroupWidth(m) == kDeckSpanningW);
|
||||
// The knob row IS the measurement, and it is exactly the group's inner width.
|
||||
CHECK(kDeckCellW + kDeckColumnGap + kMeterColumnW == kDeckSpanningW - 2 * kDeckGroupPadX);
|
||||
|
||||
DeckGroupDesc probe = m;
|
||||
probe.captionToggle.width = 64;
|
||||
CHECK(deckGroupWidth(probe) == kDeckSpanningW); // at the ceiling, still knob-row-driven
|
||||
probe.captionToggle.width = 65;
|
||||
CHECK(deckGroupWidth(probe) > kDeckSpanningW); // one past it, the spanning deck grows
|
||||
// And the shipped width is inside the ceiling, so the budget below stays unspent.
|
||||
CHECK(m.captionToggle.width <= 64);
|
||||
}
|
||||
|
||||
// hitTestKnobFace resolves against the drawn CIRCLES and runs no toggle-precedence pass, so it
|
||||
// is only correct while no toggle rect reaches a dial. The single-button styles made every
|
||||
// button on the deck wider, so the claim is re-checked here over the SHIPPED descriptors in
|
||||
// both faces — test_knob_deck's peer proves the geometry over a synthetic group; this proves it
|
||||
// for the buttons that actually ship. Rect disjointness rather than a pixel sweep: inKnobFace
|
||||
// answers only inside the knob rect, so no overlapping pixel can exist without one.
|
||||
static void testNoShippedToggleReachesADrawnKnobFace() {
|
||||
const auto disjoint = [](const Rect& a, const Rect& b) {
|
||||
return a.empty() || b.empty() || a.right() <= b.x || b.right() <= a.x ||
|
||||
a.bottom() <= b.y || b.bottom() <= a.y;
|
||||
};
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||||
int swept = 0;
|
||||
for (const DeckGroupLayout& lay : dl.groups) {
|
||||
for (const DeckToggleLayout* t : {&lay.captionToggle, &lay.captionToggle2,
|
||||
&lay.rowToggle}) {
|
||||
if (t->id < 0) continue;
|
||||
++swept;
|
||||
for (const Rect& seg : {t->seg0, t->seg1}) {
|
||||
// Against every group's cells, not just this one's: the row toggle anchors
|
||||
// past its own run and a neighbour is what it would reach first.
|
||||
for (const DeckGroupLayout& other : dl.groups) {
|
||||
for (const DeckCellLayout& c : other.cells) CHECK(disjoint(seg, c.knob));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CHECK(swept == 11); // every shipped toggle was actually reached by the sweep
|
||||
}
|
||||
}
|
||||
|
||||
// The 82 px between the floor and the ceiling is untouched by this whole reflow — the mod
|
||||
// depth's move is a swap between the two rows, not a purchase.
|
||||
static void testTheEditorWidthBudgetIsStillUnspent() {
|
||||
CHECK(kEditorMinWidth == 1198);
|
||||
CHECK(kEditorCeilingWidth - kEditorMinWidth == 82);
|
||||
CHECK(kDeckRowBlockW == 1028);
|
||||
CHECK(kDeckSpanningW == 142);
|
||||
}
|
||||
|
||||
// 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 — the bug a balanced caption row once hid.
|
||||
static void testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
DeckGroupDesc probe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
|
||||
@@ -323,11 +393,11 @@ static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
|
||||
CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over
|
||||
}
|
||||
|
||||
// The kEnvModeSegW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 47,
|
||||
// AMP at 55) pinned against the descriptors they derive from, the same way the Pitch/Rate
|
||||
// caption ceiling above is: a change to either group's caption width or its enable toggle
|
||||
// The kEnvModeW ceilings recorded in deck_groups.cpp's own comment (PITCH ENV binds at 122,
|
||||
// AMP at 126) 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 button
|
||||
// would otherwise invalidate the recorded numbers with nothing failing.
|
||||
static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
|
||||
static void testEnvModeCeilingsArePinnedForPitchEnvAndAmp() {
|
||||
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))];
|
||||
@@ -335,26 +405,29 @@ static void testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp() {
|
||||
CHECK(deckGroupWidth(amp) == 312);
|
||||
|
||||
DeckGroupDesc penvProbe = penv;
|
||||
penvProbe.captionToggle2.segWidth = 47;
|
||||
penvProbe.captionToggle2.width = 122;
|
||||
CHECK(deckGroupWidth(penvProbe) == 252); // at the ceiling, still knob-row-driven
|
||||
penvProbe.captionToggle2.segWidth = 48;
|
||||
penvProbe.captionToggle2.width = 123;
|
||||
CHECK(deckGroupWidth(penvProbe) > 252); // one past it, the caption row takes over
|
||||
|
||||
DeckGroupDesc ampProbe = amp;
|
||||
ampProbe.captionToggle2.segWidth = 55;
|
||||
ampProbe.captionToggle2.width = 126;
|
||||
CHECK(deckGroupWidth(ampProbe) == 312);
|
||||
ampProbe.captionToggle2.segWidth = 56;
|
||||
ampProbe.captionToggle2.width = 127;
|
||||
CHECK(deckGroupWidth(ampProbe) > 312);
|
||||
// The two ceilings above are what make PITCH ENV the binding group: 122 < 126, so the
|
||||
// shipped width has to clear PITCH ENV's, and it does.
|
||||
CHECK(penv.captionToggle2.width <= 122);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// reserve slots hold the two mode-dependent groups at 372 (FILTER ENV) and 312 (AMP ENVELOPE)
|
||||
// either way, which is what makes the contour row's 936 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},
|
||||
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 372},
|
||||
{kGroupFilterEnv, 372}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
|
||||
{kGroupVoice, 164}, {kGroupMaster, 142},
|
||||
};
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
@@ -370,29 +443,77 @@ static void testEveryGroupWidthMatchesTheMeasuredLayout() {
|
||||
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.
|
||||
// EVERY cell is kDeckCellW in EITHER mode — the spacing law. Trigger's two reduced
|
||||
// faces keep the same reserved run and spend it on end margins, not on wider knobs.
|
||||
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);
|
||||
}
|
||||
for (const DeckCellLayout& c : lay.cells) CHECK(c.cell.width == kDeckCellW);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE Gate-face regression pin. Every group box and every cell rect at the editor's floor,
|
||||
// block-relative, against the pre-reflow measurements. Three of the eight are the whole point:
|
||||
// FILTER 432 -> 372, one cell narrower — the mod depth left it.
|
||||
// FILTER ENV 312 -> 372, one cell wider — the mod depth arrived.
|
||||
// VELOCITY its box translates 20 px LEFT. Nothing about the group changed; row 1's freed
|
||||
// 60 px is divided over three gutters by the space-between law, and every group
|
||||
// between the narrowed one and the row's flush-right end shifts by the share it
|
||||
// did not absorb. That translation is the law working, not a second edit.
|
||||
// Everything else — PITCH/RATE, PITCH ENV, AMP ENV, VOICE, MASTER — is pinned UNCHANGED to the
|
||||
// pixel, boxes and cells alike, which is the criterion this reflow is measured against.
|
||||
static void testTheGateFaceIsPixelIdenticalApartFromTheTwoFilterGroups() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
|
||||
const auto lay = [&](int id) -> const DeckGroupLayout& {
|
||||
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))];
|
||||
};
|
||||
// {group, block-relative box x, width, cell count} — the pre-reflow numbers for the five
|
||||
// untouched groups, and the derived ones for the three the move implicates.
|
||||
const struct { int id; int x; int w; std::size_t cells; } want[] = {
|
||||
{kGroupPitch, 0, 192, 3}, // unchanged
|
||||
{kGroupFilter, 228, 372, 6}, // was x=208 w=432 with 7 cells
|
||||
{kGroupVelocity, 636, 192, 3}, // unchanged group, box translated from x=656
|
||||
{kGroupVoice, 864, 164, 1}, // unchanged
|
||||
{kGroupPitchEnv, 0, 252, 4}, // unchanged
|
||||
{kGroupFilterEnv, 298, 372, 6}, // was x=328 w=312 with 5 cells
|
||||
{kGroupAmpEnv, 716, 312, 5}, // unchanged
|
||||
{kGroupMaster, 1040, 142, 1}, // unchanged — the claim above actually pins it
|
||||
};
|
||||
for (const auto& w : want) {
|
||||
const DeckGroupLayout& l = lay(w.id);
|
||||
CHECK(l.box.x - kPad == w.x);
|
||||
CHECK(l.box.width == w.w);
|
||||
CHECK(l.cells.size() == w.cells);
|
||||
// Cells: natural pitch, abutting, starting flush at the group's inner left (no Gate
|
||||
// group carries a reserve, so the centring offset is zero everywhere here).
|
||||
CHECK(l.cells.front().cell.x == l.box.x + kDeckGroupPadX);
|
||||
for (std::size_t k = 0; k < l.cells.size(); ++k) {
|
||||
CHECK(l.cells[k].cell.width == kDeckCellW);
|
||||
CHECK(l.cells[k].cell.x - l.box.x == kDeckGroupPadX +
|
||||
static_cast<int>(k) * kDeckCellW);
|
||||
}
|
||||
}
|
||||
// The two filter groups moved by EXACTLY one cell, in opposite directions.
|
||||
CHECK(lay(kGroupFilter).box.width + kDeckCellW == 432);
|
||||
CHECK(lay(kGroupFilterEnv).box.width - kDeckCellW == 312);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDeckFitsInsideTheEnforcedMinimumWindow();
|
||||
testTheGateFaceIsPixelIdenticalApartFromTheTwoFilterGroups();
|
||||
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
|
||||
testBothRowsAndTheSpanningDeckFitTheBudget();
|
||||
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
|
||||
testGutterArithmeticAndTheLostFilterTieLineAtTheFloor();
|
||||
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
|
||||
testTheMasterDeckInteriorLandsOnBothRowBaselines();
|
||||
testTheMasterColumnDoesNotDivideItsRunVertically();
|
||||
testTheLimiterButtonIsAtMostSixtyFourPxBeforeMasterGrows();
|
||||
testNoShippedToggleReachesADrawnKnobFace();
|
||||
testTheEditorWidthBudgetIsStillUnspent();
|
||||
testMasterColumnStaysRightAnchoredWhenCaptionRowOutgrowsTheKnobRow();
|
||||
testTheModeTogglesCostNoGroupWidth();
|
||||
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
|
||||
testEnvModeSegWCeilingsArePinnedForPitchEnvAndAmp();
|
||||
testEnvModeCeilingsArePinnedForPitchEnvAndAmp();
|
||||
testEveryGroupWidthMatchesTheMeasuredLayout();
|
||||
if (g_fail == 0) std::printf("deck_groups_measured: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
|
||||
@@ -120,47 +120,58 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
||||
LiveCommit::Reload);
|
||||
}
|
||||
|
||||
// --- The overlay selection state machine ---------------------------------------
|
||||
// --- The overlay focus 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);
|
||||
// EXCLUSIVITY, and the whole of it: the focus is a function of the clicked GROUP alone, so
|
||||
// wherever it was before, clicking an envelope deck lands on that deck's envelope. Two
|
||||
// envelopes can never be overlay-active at once, and no previous state can change the answer.
|
||||
static void testOverlayFocusIsExclusiveAndIndependentOfThePreviousSelection() {
|
||||
const struct { int group; OverlayEnv env; } decks[] = {
|
||||
{kGroupAmpEnv, OverlayEnv::kAmp},
|
||||
{kGroupPitchEnv, OverlayEnv::kPitch},
|
||||
{kGroupFilterEnv, OverlayEnv::kFilter},
|
||||
};
|
||||
for (const auto& d : decks) CHECK(overlayEnvForGroup(d.group) == d.env);
|
||||
// Distinct answers, so no two decks can select the same overlay.
|
||||
CHECK(overlayEnvForGroup(kGroupAmpEnv) != overlayEnvForGroup(kGroupPitchEnv));
|
||||
CHECK(overlayEnvForGroup(kGroupPitchEnv) != overlayEnvForGroup(kGroupFilterEnv));
|
||||
CHECK(overlayEnvForGroup(kGroupAmpEnv) != overlayEnvForGroup(kGroupFilterEnv));
|
||||
}
|
||||
|
||||
// Focus SETS; it does not toggle. Driven as the SHELL drives it — `focus = f(group)` over a
|
||||
// click sequence starting from every prior focus — because that composition is the thing the
|
||||
// retired re-click-clears branch broke: a second click on the focused deck (which is every
|
||||
// knob tweak on it) landed back on kNone. The map taking no current focus is what makes that
|
||||
// unreachable; this pins the sequence a reader would otherwise have to reconstruct.
|
||||
static void testAClickSequenceOnOneDeckNeverLeavesIt() {
|
||||
for (OverlayEnv prior : {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch,
|
||||
OverlayEnv::kFilter}) {
|
||||
OverlayEnv focus = prior;
|
||||
// Panel, then knob, then button — all three land in the same group, so all three are
|
||||
// the same assignment, whatever the click before them was.
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
focus = overlayEnvForGroup(kGroupFilterEnv);
|
||||
CHECK(focus == OverlayEnv::kFilter);
|
||||
}
|
||||
// And leaving is a click ELSEWHERE, never a repeat of the one that got here.
|
||||
focus = overlayEnvForGroup(kGroupVoice);
|
||||
CHECK(focus == OverlayEnv::kNone);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// kNone is still a reachable resting state — reached by clicking a control surface OUTSIDE the
|
||||
// envelope decks rather than by clicking the active one again.
|
||||
static void testClickingAnyNonEnvelopeDeckClearsTheFocus() {
|
||||
for (int id : {kGroupPitch, kGroupFilter, kGroupVelocity, kGroupVoice, kGroupMaster}) {
|
||||
CHECK(overlayEnvForGroup(id) == OverlayEnv::kNone);
|
||||
}
|
||||
CHECK(overlayEnvForGroup(-1) == OverlayEnv::kNone); // off the deck entirely
|
||||
// A CONTROL id is not a group id: the map keys on groups now, and a stray control id must
|
||||
// never light an overlay by numeric coincidence.
|
||||
CHECK(overlayEnvForGroup(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone);
|
||||
CHECK(overlayEnvForGroup(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone);
|
||||
}
|
||||
|
||||
// The two group gates, spelled the way the predicates read them. Spline flags default off, so
|
||||
@@ -215,25 +226,22 @@ static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
|
||||
// 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() {
|
||||
static void testAModeToggleIsNotALiveControl() {
|
||||
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);
|
||||
// It needs no overlay map of its own: the toggle sits INSIDE its envelope's deck, so the
|
||||
// click that flips it already focuses that envelope through the group map.
|
||||
CHECK(overlayEnvForGroup(kGroupAmpEnv) == OverlayEnv::kAmp);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
|
||||
testClickingTheActiveOverlayRadioClearsToNone();
|
||||
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
||||
testOverlayFocusIsExclusiveAndIndependentOfThePreviousSelection();
|
||||
testAClickSequenceOnOneDeckNeverLeavesIt();
|
||||
testClickingAnyNonEnvelopeDeckClearsTheFocus();
|
||||
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
||||
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
|
||||
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
|
||||
testAModeToggleIsNotALiveControl();
|
||||
testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
|
||||
testOnlyALiveControlsDragTakesTheLiveTier();
|
||||
if (g_fail == 0) std::printf("deck_groups_state: all tests passed\n");
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include "../src/core/instrument/engine/master_gain.h"
|
||||
#include "../src/core/instrument/ui/deck_groups.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
@@ -400,8 +401,84 @@ static void testTheFilterFourKeepTheirIdentityTaper() {
|
||||
}
|
||||
}
|
||||
|
||||
// The single-button commit seam. A one-button toggle carries no segment, so the commit derives
|
||||
// the NEXT state from the parameter set and hands it to setDeckParam's unchanged segment
|
||||
// contract. Driven end-to-end — derive, apply, re-derive — because the property that matters is
|
||||
// that repeated clicks alternate the stored field rather than latching it.
|
||||
static void testASingleButtonsDerivedSegmentFlipsTheFieldItNames() {
|
||||
PlaySeconds p;
|
||||
// Enables: off by default, so the first derived segment must be ON.
|
||||
CHECK(!p.pitchEnv.enabled);
|
||||
CHECK(nextToggleSegment(DeckParam::kPitchEnvEnable, p) == 1);
|
||||
setDeckParam(DeckParam::kPitchEnvEnable, p, 0.0,
|
||||
nextToggleSegment(DeckParam::kPitchEnvEnable, p));
|
||||
CHECK(p.pitchEnv.enabled);
|
||||
CHECK(nextToggleSegment(DeckParam::kPitchEnvEnable, p) == 0);
|
||||
setDeckParam(DeckParam::kPitchEnvEnable, p, 0.0,
|
||||
nextToggleSegment(DeckParam::kPitchEnvEnable, p));
|
||||
CHECK(!p.pitchEnv.enabled);
|
||||
|
||||
CHECK(!p.filter.enabled);
|
||||
CHECK(nextToggleSegment(DeckParam::kFilterEnable, p) == 1);
|
||||
setDeckParam(DeckParam::kFilterEnable, p, 0.0,
|
||||
nextToggleSegment(DeckParam::kFilterEnable, p));
|
||||
CHECK(p.filter.enabled);
|
||||
|
||||
// Mode selectors: Staged by default, so the first derived segment is Spline. Flipping the
|
||||
// amp to Spline also forces Trigger (the drawn-EG rule), which is setDeckParam's own job
|
||||
// and must survive the derived segment reaching it unchanged.
|
||||
CHECK(p.ampSpline.mode == EnvMode::Staged);
|
||||
CHECK(nextToggleSegment(DeckParam::kAmpEnvMode, p) == 1);
|
||||
setDeckParam(DeckParam::kAmpEnvMode, p, 0.0, nextToggleSegment(DeckParam::kAmpEnvMode, p));
|
||||
CHECK(p.ampSpline.mode == EnvMode::Spline);
|
||||
CHECK(p.playMode == PlayMode::Trigger);
|
||||
CHECK(nextToggleSegment(DeckParam::kAmpEnvMode, p) == 0);
|
||||
setDeckParam(DeckParam::kAmpEnvMode, p, 0.0, nextToggleSegment(DeckParam::kAmpEnvMode, p));
|
||||
CHECK(p.ampSpline.mode == EnvMode::Staged);
|
||||
|
||||
for (DeckParam id : {DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode}) {
|
||||
setDeckParam(id, p, 0.0, nextToggleSegment(id, p));
|
||||
}
|
||||
CHECK(p.pitchSpline.mode == EnvMode::Spline);
|
||||
CHECK(p.filterSpline.mode == EnvMode::Spline);
|
||||
|
||||
// Every control that still carries its own segment answers "not mine", so the shell can
|
||||
// tell the two commit paths apart on the answer alone.
|
||||
for (DeckParam id : {DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kFilterLaw,
|
||||
DeckParam::kVoiceMode, DeckParam::kMonoTrigger,
|
||||
DeckParam::kFilterCutoff, DeckParam::kCount}) {
|
||||
CHECK(nextToggleSegment(id, p) == -1);
|
||||
}
|
||||
}
|
||||
|
||||
// The cross-check the hand-maintained list above cannot catch: a control RE-STYLED to a
|
||||
// single button (kEnable/kMode) with no nextToggleSegment entry silently commits segment -1,
|
||||
// which setDeckParam reads as "off" — a latch, not a toggle. Swept over every group
|
||||
// sampleDeckGroups actually ships, in both play modes, rather than a fixed id list, so a
|
||||
// future re-style is caught the moment it lands here with no entry above. kLimiterEnable is
|
||||
// the one shipped kEnable that is excluded: it lives on `InstrumentParams::limiterEnabled`,
|
||||
// outside `PlaySeconds`, and commits through its own handler (editor_input_deck.cpp) rather
|
||||
// than through nextToggleSegment/setDeckParam at all.
|
||||
static void testEveryShippedSingleButtonToggleHasADerivedSegment() {
|
||||
const PlaySeconds p;
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
for (const DeckGroupDesc& g : sampleDeckGroups(mode)) {
|
||||
for (const DeckToggleDesc* t : {&g.captionToggle, &g.captionToggle2, &g.rowToggle}) {
|
||||
if (t->id < 0) continue;
|
||||
if (t->style != DeckToggleStyle::kEnable && t->style != DeckToggleStyle::kMode)
|
||||
continue;
|
||||
const DeckParam id = static_cast<DeckParam>(t->id);
|
||||
if (id == DeckParam::kLimiterEnable) continue;
|
||||
CHECK(nextToggleSegment(id, p) != -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testTheTwoCeilingNamesAreOneNumber();
|
||||
testASingleButtonsDerivedSegmentFlipsTheFieldItNames();
|
||||
testEveryShippedSingleButtonToggleHasADerivedSegment();
|
||||
testNormRoundTripsThroughEveryValueDomain();
|
||||
testRateKnobEndsAreTheStretchersOwnBounds();
|
||||
testRateAndPitchBindTheirOwnFields();
|
||||
|
||||
+159
-90
@@ -4,11 +4,13 @@
|
||||
// * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths.
|
||||
// * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right
|
||||
// inside the box; knob square centered; label band beneath; row toggle after the cells.
|
||||
// * reserves — a -1 id holds the group's width and hands its pixels to the cells present.
|
||||
// * reserves — a -1 id holds the group's width and pays for it in the two end margins, with
|
||||
// the run of present cells centred at their natural width.
|
||||
// * rows — membership comes from the group's own DeckRow, never from a wrap outcome;
|
||||
// space-between justification inside the row block; the right-anchored spanning deck.
|
||||
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
|
||||
// misses, outside-deck misses.
|
||||
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, the single-button
|
||||
// styles' no-segment answer, the group id every hit carries, fence padding misses,
|
||||
// outside-deck misses.
|
||||
// * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring,
|
||||
// both exclusive boundaries, and the points where it deliberately disagrees with the cell.
|
||||
|
||||
@@ -30,25 +32,30 @@ static int g_fail = 0;
|
||||
// toggle + row toggle), MASTER (1 cell, no toggle).
|
||||
static std::vector<DeckGroupDesc> shellLikeDeck() {
|
||||
std::vector<DeckGroupDesc> g;
|
||||
g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}});
|
||||
g.push_back({1, 38, {}, {101, 48}, {}, {6}, {}});
|
||||
g.push_back({2, 58, {}, {102, 32}, {}, {7, 8, 9}, {}});
|
||||
g.push_back({3, 38, {}, {103, 40}, {}, {10}, {104, 44}});
|
||||
g.push_back({0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}});
|
||||
g.push_back({1, 38, {}, {101, 96}, {}, {6}, {}});
|
||||
g.push_back({2, 58, {}, {102, 64}, {}, {7, 8, 9}, {}});
|
||||
g.push_back({3, 38, {}, {103, 80}, {}, {10}, {104, 88}});
|
||||
g.push_back({4, 46, {}, {}, {}, {11}, {}});
|
||||
return g;
|
||||
}
|
||||
|
||||
static void testGroupWidth() {
|
||||
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
|
||||
DeckGroupDesc amp{0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}};
|
||||
// Knob row dominates: 5 cells (300) > caption row (78 + 4 + 88 = 170) -> 300 + 2*6.
|
||||
DeckGroupDesc amp{0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}};
|
||||
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
|
||||
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
|
||||
DeckGroupDesc pitch{1, 38, {}, {101, 48}, {}, {6}, {}};
|
||||
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
|
||||
// Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
|
||||
DeckGroupDesc voice{3, 38, {}, {103, 40}, {}, {10}, {104, 44}};
|
||||
// Caption row dominates: 38 + 4 + 96 = 138 > 60 -> 138 + 12.
|
||||
DeckGroupDesc pitch{1, 38, {}, {101, 96}, {}, {6}, {}};
|
||||
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 96 + 2 * kDeckGroupPadX);
|
||||
// Row toggle counts into the knob row: 60 + 4 + 88 = 152 > caption 38+4+80=122.
|
||||
DeckGroupDesc voice{3, 38, {}, {103, 80}, {}, {10}, {104, 88}};
|
||||
CHECK(deckGroupWidth(voice) ==
|
||||
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
|
||||
kDeckCellW + kDeckToggleGap + 88 + 2 * kDeckGroupPadX);
|
||||
// A toggle's `width` is the WHOLE control either way, so a single button and a segmented
|
||||
// one of the same declared width cost the group exactly the same.
|
||||
DeckGroupDesc single = voice;
|
||||
single.captionToggle.style = DeckToggleStyle::kEnable;
|
||||
CHECK(deckGroupWidth(single) == deckGroupWidth(voice));
|
||||
// No toggles: max(caption, cells) + padding.
|
||||
DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
|
||||
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
|
||||
@@ -64,11 +71,11 @@ static void testGroupWidth() {
|
||||
// widths: two Sound groups, two Contour groups, one Spanning group carrying a column.
|
||||
static std::vector<DeckGroupDesc> tworowDeck() {
|
||||
std::vector<DeckGroupDesc> g;
|
||||
g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}});
|
||||
g.push_back({1, 38, {}, {101, 48}, {}, {6, 7}, {}, DeckRow::Sound, {}});
|
||||
g.push_back({2, 58, {}, {102, 32}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}});
|
||||
g.push_back({3, 38, {}, {103, 40}, {}, {11}, {}, DeckRow::Contour, {}});
|
||||
g.push_back({4, 46, {200, true}, {104, 32}, {}, {12, -1}, {},
|
||||
g.push_back({0, 78, {}, {100, 88}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}});
|
||||
g.push_back({1, 38, {}, {101, 96}, {}, {6, 7}, {}, DeckRow::Sound, {}});
|
||||
g.push_back({2, 58, {}, {102, 64}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}});
|
||||
g.push_back({3, 38, {}, {103, 80}, {}, {11}, {}, DeckRow::Contour, {}});
|
||||
g.push_back({4, 46, {200, true}, {104, 64}, {}, {12, -1}, {},
|
||||
DeckRow::Spanning, {300, 62}});
|
||||
return g;
|
||||
}
|
||||
@@ -291,41 +298,101 @@ static void testHitTest() {
|
||||
h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1);
|
||||
CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1);
|
||||
|
||||
// A reserve (id -1) yields no cell of its own. This fixture's reserve divides its present
|
||||
// cells evenly (5 slots / 3 present -> 240/3, no residue), so every point of the knob row
|
||||
// lands on a real control: no dead rect survives for a grab to fall into. That does NOT
|
||||
// generalize to an indivisible reserve — a residue leaves a few uncovered margin pixels by
|
||||
// design (testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds, below).
|
||||
// A reserve (id -1) yields no cell of its own, and the cells present cover their CENTRED
|
||||
// run contiguously — no dead rect between them for a grab to fall into. What the reserve
|
||||
// buys is margin at the two ends, which is a deliberate miss and pinned as one below.
|
||||
std::vector<DeckGroupDesc> trig;
|
||||
trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}});
|
||||
trig.push_back({0, 78, {}, {100, 88}, {}, {20, 21, 22, -1, -1}, {}});
|
||||
const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
|
||||
const DeckGroupLayout& tg = tl.groups[0];
|
||||
CHECK(tg.cells.size() == 3);
|
||||
for (const DeckCellLayout& c : tg.cells) CHECK(c.id >= 0);
|
||||
// Bound the sweep against the RESERVED run (5 slots, not the 3 present cells) rather than
|
||||
// the cells' own extent — the cells are what's under test, so deriving the bound from them
|
||||
// could never catch a layout that under-covers the run they were reserved out of.
|
||||
const int runStart = tg.box.x + kDeckGroupPadX;
|
||||
const int runEnd = runStart + static_cast<int>(trig[0].cellIds.size()) * kDeckCellW;
|
||||
const int rowY = tg.cells.back().cell.y + 5;
|
||||
for (int px = runStart; px < runEnd; ++px) {
|
||||
for (int px = tg.cells.front().cell.x; px < tg.cells.back().cell.right(); ++px) {
|
||||
const DeckHit rowHit = hitTestDeck(tl, px, rowY);
|
||||
CHECK(rowHit.kind == DeckHitKind::Knob && rowHit.id >= 0);
|
||||
}
|
||||
// The reserve's own pixels answer no control — but they still name the group, which is
|
||||
// what makes the deck panel's background a target for the overlay focus.
|
||||
const DeckHit margin = hitTestDeck(tl, tg.box.x + kDeckGroupPadX + 1, rowY);
|
||||
CHECK(margin.kind == DeckHitKind::None && margin.group == 0);
|
||||
|
||||
// The fence padding inside the box misses; outside the deck misses.
|
||||
// The fence padding inside the box misses as a control and names its group; outside the
|
||||
// deck misses entirely, group included.
|
||||
h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1);
|
||||
CHECK(h.kind == DeckHitKind::None);
|
||||
CHECK(h.kind == DeckHitKind::None && h.id == -1 && h.group == 0);
|
||||
h = hitTestDeck(dl, -50, -50);
|
||||
CHECK(h.kind == DeckHitKind::None);
|
||||
CHECK(h.kind == DeckHitKind::None && h.group == -1);
|
||||
|
||||
// Every hit kind carries the group it landed in, so the shell never has to re-scan the
|
||||
// layout to find out which deck a click belongs to.
|
||||
CHECK(hitTestDeck(dl, c0.cell.x + 1, c0.cell.y + 1).group == 0);
|
||||
CHECK(hitTestDeck(dl, amp.captionToggle.seg1.x, amp.captionToggle.seg1.y + 1).group == 0);
|
||||
CHECK(hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1).group == 3);
|
||||
}
|
||||
|
||||
// A reserve holds the group's WIDTH and hands its pixels to the cells that are present. The
|
||||
// three properties together are what stops a narrower face reading as a hole: the group is
|
||||
// exactly as wide as the full-face one, the cells are uniform and abutting, and what they do
|
||||
// not cover is smaller than one pixel per cell.
|
||||
static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
const DeckGroupDesc full{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24}, {}};
|
||||
// A single-button toggle takes the WHOLE declared width in seg0, leaves seg1 empty, and — the
|
||||
// property the commit seam rests on — answers with NO segment, so a caller cannot mistake it
|
||||
// for the left half of a two-segment control.
|
||||
static void testSingleButtonToggleTakesTheWholeSlotAndCarriesNoSegment() {
|
||||
for (DeckToggleStyle style : {DeckToggleStyle::kEnable, DeckToggleStyle::kMode}) {
|
||||
DeckGroupDesc g{0, 40, {}, {200, 52, style}, {}, {1, 2, 3}, {}};
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 400);
|
||||
const DeckToggleLayout& t = dl.groups[0].captionToggle;
|
||||
CHECK(t.id == 200);
|
||||
CHECK(t.style == style);
|
||||
CHECK(t.seg0.width == 52);
|
||||
CHECK(t.seg1.empty());
|
||||
// Right-anchored in the caption row exactly as a segmented toggle is.
|
||||
CHECK(t.seg0.right() == dl.groups[0].box.right() - kDeckGroupPadX);
|
||||
CHECK(t.seg0.height == kDeckToggleH);
|
||||
|
||||
// Both ends of the button answer the same hit, with segment -1.
|
||||
for (int px : {t.seg0.x, t.seg0.x + 26, t.seg0.right() - 1}) {
|
||||
const DeckHit h = hitTestDeck(dl, px, t.seg0.y + 1);
|
||||
CHECK(h.kind == DeckHitKind::CaptionToggle);
|
||||
CHECK(h.id == 200 && h.segment == -1 && h.group == 0);
|
||||
}
|
||||
// Where the right half of a segmented toggle would have been is now the same button,
|
||||
// not segment 1 — the regression this style exists to make impossible.
|
||||
CHECK(hitTestDeck(dl, t.seg0.right() - 1, t.seg0.y + 1).segment != 1);
|
||||
}
|
||||
}
|
||||
|
||||
// The caption toggles sit in the caption row and the knob circles in the cell row, so no
|
||||
// button rect can overlap a dial. hitTestKnobFace runs NO toggle-precedence pass, and this is
|
||||
// the property that lets it get away with that — re-checked here because the single-button
|
||||
// styles made every one of those rects wider.
|
||||
static void testNoToggleRectOverlapsAKnobCircle() {
|
||||
DeckGroupDesc g{0, 40, {}, {200, 96, DeckToggleStyle::kEnable},
|
||||
{201, 96, DeckToggleStyle::kMode}, {1, 2, 3}, {202, 96}};
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 600);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
const DeckToggleLayout* toggles[] = {&lay.captionToggle, &lay.captionToggle2,
|
||||
&lay.rowToggle};
|
||||
for (const DeckToggleLayout* t : toggles) {
|
||||
for (const Rect& seg : {t->seg0, t->seg1}) {
|
||||
if (seg.empty()) continue;
|
||||
for (const DeckCellLayout& c : lay.cells) {
|
||||
// Sweep the segment's own pixels: none of them may land on a drawn dial.
|
||||
for (int px = seg.x; px < seg.right(); ++px) {
|
||||
for (int py = seg.y; py < seg.bottom(); ++py) {
|
||||
CHECK(!inKnobFace(c.knob, px, py));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reserve holds the group's WIDTH and gives its pixels to the two END MARGINS, never to the
|
||||
// cells: every cell keeps kDeckCellW whatever face the group is showing, and the run of them is
|
||||
// centred. That is the whole spacing law — a reduced face is the same knobs at the same pitch,
|
||||
// sitting in the middle of a box that did not move.
|
||||
static void testAReserveCentresTheRunAndNeverWidensACell() {
|
||||
const DeckGroupDesc full{0, 78, {}, {100, 88}, {}, {20, 21, 22, 23, 24}, {}};
|
||||
// Three, four, and a lone cell against the same five-slot reserve.
|
||||
const std::vector<std::vector<int>> faces = {
|
||||
{20, 21, 22, -1, -1}, {20, 21, 22, 23, -1}, {20, -1, -1, -1, -1}};
|
||||
@@ -340,30 +407,42 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
const int present = static_cast<int>(lay.cells.size());
|
||||
CHECK(present == 5 - static_cast<int>(std::count(ids.begin(), ids.end(), -1)));
|
||||
|
||||
const int run = 5 * kDeckCellW;
|
||||
for (int i = 0; i < present; ++i) {
|
||||
const DeckCellLayout& c = lay.cells[static_cast<std::size_t>(i)];
|
||||
CHECK(c.cell.width == lay.cells[0].cell.width); // uniform
|
||||
CHECK(c.knob.width == kDeckKnobSize); // the dial itself is fixed
|
||||
// Centred as exactly as integers allow: a cell whose spare width is odd cannot
|
||||
// split it evenly, and the layout's integer division gives the odd pixel to the
|
||||
// RIGHT margin. Pinned as a directional identity rather than a tolerance, so a
|
||||
// future off-by-one on the other side would still fail here.
|
||||
const int leftGap = c.knob.x - c.cell.x;
|
||||
const int rightGap = c.cell.right() - c.knob.right();
|
||||
CHECK(rightGap - leftGap == (c.cell.width - kDeckKnobSize) % 2);
|
||||
CHECK(c.cell.width == kDeckCellW); // natural pitch, never the divided run
|
||||
CHECK(c.knob.width == kDeckKnobSize);
|
||||
// The dial sits centred in its cell — 60 and 40 are both even, so exactly so.
|
||||
CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right());
|
||||
if (i > 0) CHECK(c.cell.x == lay.cells[static_cast<std::size_t>(i - 1)].cell.right());
|
||||
}
|
||||
// Uncovered run is the indivisible residue only, split evenly at the two ends.
|
||||
const int covered = lay.cells.back().cell.right() - lay.cells[0].cell.x;
|
||||
CHECK(run - covered < present);
|
||||
const int leadPad = lay.cells[0].cell.x - (lay.box.x + kDeckGroupPadX);
|
||||
CHECK(leadPad == (run - covered) / 2);
|
||||
// What the run does not cover is the reserve, split evenly at the two ends. The
|
||||
// reserve is a whole number of 60px cells, so the split is exact — never off by one.
|
||||
const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
|
||||
const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
|
||||
CHECK(leadPad == trailPad);
|
||||
CHECK(leadPad + trailPad == (5 - present) * kDeckCellW);
|
||||
}
|
||||
|
||||
// Only the reserve COUNT matters, not where a -1 sits: with the run centred, three faces
|
||||
// that reserve two slots in three different places lay out identically.
|
||||
const std::vector<std::vector<int>> sameCount = {
|
||||
{20, 21, 22, -1, -1}, {-1, 20, 21, -1, 22}, {-1, -1, 20, 21, 22}};
|
||||
std::vector<Rect> firstRun;
|
||||
for (const std::vector<int>& ids : sameCount) {
|
||||
DeckGroupDesc d = full;
|
||||
d.cellIds = ids;
|
||||
std::vector<DeckGroupDesc> g{d};
|
||||
const DeckLayout dl = layoutDeck(g, 0, 0, 824);
|
||||
std::vector<Rect> cells;
|
||||
for (const DeckCellLayout& c : dl.groups[0].cells) cells.push_back(c.cell);
|
||||
CHECK(cells.size() == 3);
|
||||
if (firstRun.empty()) firstRun = cells;
|
||||
else CHECK(cells == firstRun);
|
||||
}
|
||||
|
||||
// A reserve does not move the row toggle: it anchors past the whole run, so the FILTER
|
||||
// group's law switch cannot drift when a neighbouring face changes shape.
|
||||
DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 44}};
|
||||
DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 88}};
|
||||
std::vector<DeckGroupDesc> a{withToggle};
|
||||
withToggle.cellIds = {20, 21, -1, -1, -1};
|
||||
std::vector<DeckGroupDesc> b{withToggle};
|
||||
@@ -371,35 +450,23 @@ static void testReservedCellWidthGoesToTheCellsPresent() {
|
||||
layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0);
|
||||
}
|
||||
|
||||
// The three faces above all divide their run evenly, so none of them actually exercises
|
||||
// "residue in symmetric end margins". An 8-slot reserve with 7 present (480/7 = 68 r4) does:
|
||||
// residue 4 is the smallest case that can tell a symmetric split (2/2) apart from a
|
||||
// trailing-only one (0/4) — a residue of 1 can't, since leadPad = residue/2 rounds to 0 either
|
||||
// way, which is exactly why this seam's earlier test passed without pinning the rule it was
|
||||
// named for.
|
||||
static void testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds() {
|
||||
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, 25, 26, -1}, {}};
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
CHECK(lay.cells.size() == 7);
|
||||
|
||||
const int run = 8 * kDeckCellW;
|
||||
const int present = 7;
|
||||
const int cellW = run / present; // 76: the same integer division the layout uses
|
||||
const int expectedResidue = run - cellW * present; // 4
|
||||
CHECK(expectedResidue == 4);
|
||||
|
||||
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
|
||||
CHECK(run - covered == expectedResidue);
|
||||
const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
|
||||
const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
|
||||
// Hard literals, not just the formula: this is the case that actually distinguishes
|
||||
// symmetric (2/2) from trailing-only (0/4) — see the comment above.
|
||||
CHECK(leadPad == 2);
|
||||
CHECK(trailPad == 2);
|
||||
CHECK(leadPad == expectedResidue / 2);
|
||||
CHECK(trailPad == expectedResidue - leadPad); // both ends share it, not one absorbing it
|
||||
// A face with NO reserve is untouched by the centring — the offset is zero by construction, so
|
||||
// the run starts flush against the group's inner padding exactly as it always did. This is what
|
||||
// makes "the Gate deck face is pixel-identical" a structural claim rather than an observation.
|
||||
static void testAFaceWithNoReserveStartsFlushAgainstThePadding() {
|
||||
for (int slots = 1; slots <= 8; ++slots) {
|
||||
DeckGroupDesc g{0, 78, {}, {100, 88}, {}, {}, {}};
|
||||
for (int i = 0; i < slots; ++i) g.cellIds.push_back(20 + i);
|
||||
std::vector<DeckGroupDesc> gs{g};
|
||||
const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
CHECK(static_cast<int>(lay.cells.size()) == slots);
|
||||
CHECK(lay.cells.front().cell.x == lay.box.x + kDeckGroupPadX);
|
||||
// The run covers the whole reserve exactly — no lead margin to absorb, none to leave.
|
||||
// (Not "flush right": a caption-row-bound group's box is wider than its knob row.)
|
||||
CHECK(lay.cells.back().cell.right() - lay.cells.front().cell.x == slots * kDeckCellW);
|
||||
for (const DeckCellLayout& c : lay.cells) CHECK(c.cell.width == kDeckCellW);
|
||||
}
|
||||
}
|
||||
|
||||
// The corner radio widens the caption row, takes the far corner, and pushes the caption
|
||||
@@ -449,7 +516,7 @@ static void testInnerDialHit() {
|
||||
// the caption text stops before the LEFTMOST one), and takes captionToggle's own slot when
|
||||
// captionToggle is absent — the shipped FILTER ENV group's exact shape (deck_groups.cpp).
|
||||
static void testCaptionToggle2() {
|
||||
const DeckGroupDesc both{9, 40, {}, {300, 30}, {301, 20}, {1, 2, 3}, {}};
|
||||
const DeckGroupDesc both{9, 40, {}, {300, 60}, {301, 40}, {1, 2, 3}, {}};
|
||||
std::vector<DeckGroupDesc> g{both};
|
||||
const DeckLayout dl = layoutDeck(g, 0, 0, 800);
|
||||
const DeckGroupLayout& lay = dl.groups[0];
|
||||
@@ -469,7 +536,7 @@ static void testCaptionToggle2() {
|
||||
|
||||
// FILTER ENV's real shape: captionToggle absent, captionToggle2 present with a radio — it
|
||||
// takes the first (rightmost) slot rather than leaving a gap where captionToggle would sit.
|
||||
const DeckGroupDesc filterEnvLike{10, 66, {200}, {}, {302, 23}, {1, 2, 3, 4, 5}, {}};
|
||||
const DeckGroupDesc filterEnvLike{10, 66, {200}, {}, {302, 46}, {1, 2, 3, 4, 5}, {}};
|
||||
std::vector<DeckGroupDesc> g2{filterEnvLike};
|
||||
const DeckLayout dl2 = layoutDeck(g2, 0, 0, 800);
|
||||
const DeckGroupLayout& fe = dl2.groups[0];
|
||||
@@ -558,8 +625,10 @@ int main() {
|
||||
testSpanningOnlyDeckKeepsItsHeight();
|
||||
testGroupInnerGeometry();
|
||||
testHitTest();
|
||||
testReservedCellWidthGoesToTheCellsPresent();
|
||||
testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds();
|
||||
testSingleButtonToggleTakesTheWholeSlotAndCarriesNoSegment();
|
||||
testNoToggleRectOverlapsAKnobCircle();
|
||||
testAReserveCentresTheRunAndNeverWidensACell();
|
||||
testAFaceWithNoReserveStartsFlushAgainstThePadding();
|
||||
testCaptionRadioGeometryAndHit();
|
||||
testInnerDialHit();
|
||||
testKnobFaceResolvesInnerRingOuterRingAndMisses();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// 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 editor's dirty-rect fast path — meterFastPathEligible's wholly-inside test, and
|
||||
// meterBarsWithinField, the self-containment invariant the fast path rests on.
|
||||
// * 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.
|
||||
@@ -287,6 +289,29 @@ static void testDrawEqualityCoversTheDrawnQuantities() {
|
||||
CHECK(meterDrawEqual(a, graze));
|
||||
}
|
||||
|
||||
// The editor's WM_PAINT fast-path test: a dirty rect wholly inside the field takes the
|
||||
// meter-only redraw; anything that pokes outside it, or a field that hasn't been laid out yet,
|
||||
// falls back to the full compose.
|
||||
static void testFastPathEligibleOnlyForADirtyRectInsideTheField() {
|
||||
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
|
||||
CHECK(meterFastPathEligible(m, m.field)); // the whole field
|
||||
CHECK(meterFastPathEligible(
|
||||
m, Rect::ltrb(m.field.x + 2, m.field.y + 2, m.field.right() - 2, m.field.bottom() - 2)));
|
||||
CHECK(!meterFastPathEligible(m, Rect::ltrb(m.field.x - 1, m.field.y, m.field.right(),
|
||||
m.field.bottom()))); // pokes left of the field
|
||||
CHECK(!meterFastPathEligible(
|
||||
m, Rect::ltrb(kColumn.x, kColumn.y, kColumn.right(), kColumn.bottom()))); // whole column
|
||||
CHECK(!meterFastPathEligible(MeterRects{}, m.field)); // no cached layout at all
|
||||
}
|
||||
|
||||
// The self-containment invariant drawMeterField's redraw-the-field-alone shortcut rests on:
|
||||
// every bar meterRects() hands back stays inside the field, in both lane splits.
|
||||
static void testFieldBoundsEveryBarItPaints() {
|
||||
CHECK(meterBarsWithinField(meterRects(kColumn, LaneSplit::Single)));
|
||||
CHECK(meterBarsWithinField(meterRects(kColumn, LaneSplit::Stereo)));
|
||||
CHECK(meterBarsWithinField(MeterRects{})); // degenerate: nothing to bound, nothing drawn
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -300,6 +325,8 @@ int main() {
|
||||
testEveryOtherTickCarriesANumeral();
|
||||
testTheFloorNumeralStaysInsideTheGutter();
|
||||
testAColumnTooNarrowForTheInteriorDrawsNothing();
|
||||
testFastPathEligibleOnlyForADirtyRectInsideTheField();
|
||||
testFieldBoundsEveryBarItPaints();
|
||||
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
|
||||
testPeakHoldSitsForItsFullWindowThenReleases();
|
||||
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
|
||||
|
||||
@@ -54,15 +54,14 @@ static void testRowsTileTheBandExactly() {
|
||||
static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
||||
const Rect band = chromeBand();
|
||||
const ChromeRects r = chromeRects(band, kKnob);
|
||||
// Rightmost first: Browse, stereo, mono, velocity cell, preview, title.
|
||||
// Rightmost first: Browse, channel, loop, velocity cell, preview, title.
|
||||
CHECK(r.navBrowse.right() == band.right() - kPad);
|
||||
CHECK(r.navBrowse.width == kNavButtonWidth);
|
||||
CHECK(r.chanStereo.right() <= r.navBrowse.x);
|
||||
CHECK(r.chanMono.right() == r.chanStereo.x);
|
||||
CHECK(r.loopOn.right() <= r.chanMono.x); // the enable is immediately left of Mono|Stereo
|
||||
CHECK(r.loopOff.right() == r.loopOn.x); // its two segments abut, like the channel pair
|
||||
CHECK(r.loopOff.y == r.chanMono.y && r.loopOff.height == r.chanMono.height);
|
||||
CHECK(r.velCell.right() <= r.loopOff.x);
|
||||
CHECK(r.channel.right() <= r.navBrowse.x);
|
||||
// Both are ONE button now, and they share the run's toggle baseline.
|
||||
CHECK(r.loop.right() <= r.channel.x);
|
||||
CHECK(r.loop.y == r.channel.y && r.loop.height == r.channel.height);
|
||||
CHECK(r.velCell.right() <= r.loop.x);
|
||||
CHECK(r.preview.right() <= r.velCell.x);
|
||||
CHECK(r.bake.right() <= r.preview.x);
|
||||
CHECK(r.bake.width == kBakeButtonWidth);
|
||||
@@ -74,8 +73,8 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
||||
CHECK(r.title.width > 0);
|
||||
|
||||
// Every toolbar rect sits inside the toolbar row.
|
||||
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loopOff,
|
||||
r.loopOn, r.chanMono, r.chanStereo, r.navBrowse};
|
||||
const Rect items[] = {r.title, r.holdCell, r.bake, r.preview, r.velCell, r.loop,
|
||||
r.channel, r.navBrowse};
|
||||
for (const Rect& it : items) {
|
||||
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
|
||||
}
|
||||
@@ -88,8 +87,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
|
||||
// stay inside its own row, clear of every control.
|
||||
CHECK(!overlaps(r.toolbar, r.rootStrip));
|
||||
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
|
||||
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loopOff, r.loopOn,
|
||||
r.chanMono, r.chanStereo, r.navBrowse};
|
||||
const Rect items[] = {r.holdCell, r.bake, r.preview, r.velCell, r.loop,
|
||||
r.channel, r.navBrowse};
|
||||
for (const Rect& it : items) {
|
||||
CHECK(!overlaps(it, r.rootStrip));
|
||||
CHECK(!overlaps(it, r.title));
|
||||
@@ -176,7 +175,7 @@ static void testDegenerateBandYieldsNoInvertedRects() {
|
||||
kKnob);
|
||||
const Rect items[] = {tiny.title, tiny.holdCell, tiny.holdKnob, tiny.holdLabel,
|
||||
tiny.bake, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
|
||||
tiny.loopOff, tiny.loopOn, tiny.chanMono, tiny.chanStereo,
|
||||
tiny.loop, tiny.channel,
|
||||
tiny.navBrowse, tiny.rootStrip};
|
||||
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// Standalone tests for the deferred FX-park queue's re-entrancy rule and the
|
||||
// snapshot-lifecycle contract that rides on it — no REAPER, no test framework.
|
||||
//
|
||||
// The properties under test: a mode switch leaves its per-FX offline work here,
|
||||
// so a second switch arriving before the first drained must leave every track in
|
||||
// the state the SECOND switch specifies — never the first's, never both replayed;
|
||||
// and a cancel must not strand the pre-park FX state it was the last record of.
|
||||
|
||||
#include "../src/shell/view/view_fx_park.h"
|
||||
|
||||
#include "core/view/view_mode_model.h" // makeRestorePlan — the ops' only producer
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// -- helpers -----------------------------------------------------------------
|
||||
|
||||
// The ops applyMode hands a restore: one per FX captured in the track's snapshot.
|
||||
static std::vector<FxOfflineOp> ops(const std::string& fxGuid, bool offline) {
|
||||
return {FxOfflineOp{"{TRACK}", FxKeying::Identity, fxGuid, 0, offline}};
|
||||
}
|
||||
|
||||
static const FxParkIntent* intentFor(const FxParkQueue& q, const std::string& guid) {
|
||||
for (const FxParkIntent& i : q.pending())
|
||||
if (i.guid == guid) return &i;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// -- tests -------------------------------------------------------------------
|
||||
|
||||
static void testParkEnqueuesOneIntentCarryingNoOps() {
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
|
||||
CHECK(q.pending().size() == 1);
|
||||
const FxParkIntent* held = intentFor(q, "{A}");
|
||||
CHECK(held != nullptr);
|
||||
CHECK(held && held->park);
|
||||
CHECK(held && held->restoreOps.empty());
|
||||
}
|
||||
|
||||
static void testParkReportsNothingCancelledWhenNoIntentWasPending() {
|
||||
FxParkQueue q;
|
||||
CHECK(q.park("{A}").empty());
|
||||
}
|
||||
|
||||
static void testParkOnItsOwnPendingParkReportsNothingCancelled() {
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
CHECK(q.park("{A}").empty());
|
||||
}
|
||||
|
||||
static void testRestoreOnAnUndrainedParkCancelsRatherThanStacks() {
|
||||
// The park never ran, so the track's FX still hold their captured state —
|
||||
// exactly what the restore would write. Replaying both would unload every
|
||||
// plugin only to reload it.
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
q.restore("{A}", ops("{FX}", false));
|
||||
|
||||
CHECK(q.empty());
|
||||
}
|
||||
|
||||
static void testParkOnAnUndrainedRestoreCancelsRatherThanStacks() {
|
||||
// The mirror case: the restore never ran, so the FX are still parked offline,
|
||||
// which is where the new park wants them.
|
||||
FxParkQueue q;
|
||||
q.restore("{A}", ops("{FX}", false));
|
||||
q.park("{A}");
|
||||
|
||||
CHECK(q.empty());
|
||||
}
|
||||
|
||||
static void testRepeatedParkStaysOneIntent() {
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
q.park("{A}");
|
||||
q.park("{A}");
|
||||
|
||||
CHECK(q.pending().size() == 1);
|
||||
CHECK(intentFor(q, "{A}") && intentFor(q, "{A}")->park);
|
||||
}
|
||||
|
||||
static void testLaterRestoreReplacesTheEarlierOnesOps() {
|
||||
FxParkQueue q;
|
||||
q.restore("{A}", ops("{OLD}", false));
|
||||
q.restore("{A}", ops("{NEW}", true));
|
||||
|
||||
CHECK(q.pending().size() == 1);
|
||||
const FxParkIntent* held = intentFor(q, "{A}");
|
||||
CHECK(held && !held->park);
|
||||
CHECK(held && held->restoreOps.size() == 1);
|
||||
CHECK(held && held->restoreOps.front().fxGuid == "{NEW}");
|
||||
CHECK(held && held->restoreOps.front().offline);
|
||||
}
|
||||
|
||||
static void testOneTracksCancelLeavesEveryOtherTrackAlone() {
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
q.park("{B}");
|
||||
q.park("{C}");
|
||||
q.restore("{B}", ops("{FX}", false)); // cancels B only
|
||||
|
||||
CHECK(q.pending().size() == 2);
|
||||
CHECK(intentFor(q, "{A}") != nullptr);
|
||||
CHECK(intentFor(q, "{B}") == nullptr);
|
||||
CHECK(intentFor(q, "{C}") != nullptr);
|
||||
// Order survives the middle erase: the drain applies in enqueue order.
|
||||
CHECK(q.pending()[0].guid == "{A}");
|
||||
CHECK(q.pending()[1].guid == "{C}");
|
||||
}
|
||||
|
||||
static void testCancelledTrackCanBeQueuedAgain() {
|
||||
// Two rapid switches then a third: the third is the one that must land.
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
q.restore("{A}", ops("{FX}", false));
|
||||
q.park("{A}");
|
||||
|
||||
CHECK(q.pending().size() == 1);
|
||||
CHECK(intentFor(q, "{A}") && intentFor(q, "{A}")->park);
|
||||
}
|
||||
|
||||
static void testClearDropsEverythingPending() {
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
q.restore("{B}", ops("{FX}", true));
|
||||
q.clear();
|
||||
|
||||
CHECK(q.empty());
|
||||
CHECK(q.pending().empty());
|
||||
}
|
||||
|
||||
// -- snapshot lifecycle ------------------------------------------------------
|
||||
|
||||
static void testParkHandsBackTheOpsOfTheRestoreItCancelled() {
|
||||
// The cancelled restore is the LAST record of the pre-park FX state: the
|
||||
// track's chain still reads the parked values (the restore never ran), and
|
||||
// the cancel means no drain will ever put them back. A park that drops these
|
||||
// snapshots the park's own offline zeros as if they were the user's state.
|
||||
FxParkQueue q;
|
||||
q.restore("{A}", ops("{FX}", false));
|
||||
|
||||
const std::vector<FxOfflineOp> cancelled = q.park("{A}");
|
||||
|
||||
CHECK(cancelled.size() == 1);
|
||||
CHECK(cancelled.size() == 1 && cancelled.front().fxGuid == "{FX}");
|
||||
CHECK(cancelled.size() == 1 && !cancelled.front().offline);
|
||||
CHECK(q.empty()); // annihilated: the chain already holds what the park wants
|
||||
}
|
||||
|
||||
static void testCancelledRestoreOpsBecomeTheFreshSnapshotsFxHalf() {
|
||||
std::vector<FxOfflineOp> cancelled = ops("{ONE}", true);
|
||||
cancelled.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, "{TWO}", 1, false});
|
||||
|
||||
const PreParkFx fx = preParkFxFromCancelledRestore(cancelled);
|
||||
|
||||
CHECK(fx.keying == FxKeying::Identity);
|
||||
CHECK(fx.states.size() == 2);
|
||||
CHECK(fx.states.size() == 2 && fx.states[0].fxGuid == "{ONE}" && fx.states[0].offline == 1);
|
||||
CHECK(fx.states.size() == 2 && fx.states[1].fxGuid == "{TWO}" && fx.states[1].offline == 0);
|
||||
}
|
||||
|
||||
static void testNothingCancelledLeavesTheFxHalfToTheCaller() {
|
||||
const PreParkFx fx = preParkFxFromCancelledRestore({});
|
||||
|
||||
CHECK(fx.states.empty()); // caller reads the live chain instead
|
||||
CHECK(fx.keying == FxKeying::Identity);
|
||||
}
|
||||
|
||||
static void testSlotKeyedRestoreDoesNotBecomeIdentityKeyedWithNoIdentities() {
|
||||
// A snapshot lifted from a pre-identity view_state is slot-keyed and carries
|
||||
// no fxGuid. Re-labelling it Identity would make resolveFxRestore drop every
|
||||
// entry as unidentified instead of writing it by slot.
|
||||
std::vector<FxOfflineOp> cancelled = {
|
||||
FxOfflineOp{"{TRACK}", FxKeying::Slot, "", 0, true},
|
||||
FxOfflineOp{"{TRACK}", FxKeying::Slot, "", 1, false},
|
||||
};
|
||||
|
||||
const PreParkFx fx = preParkFxFromCancelledRestore(cancelled);
|
||||
|
||||
CHECK(fx.keying == FxKeying::Slot);
|
||||
CHECK(fx.states.size() == 2);
|
||||
CHECK(fx.states.size() == 2 && fx.states[0].offline == 1 && fx.states[1].offline == 0);
|
||||
}
|
||||
|
||||
// -- the makeRestorePlan <-> preParkFxFromCancelledRestore round trip ---------
|
||||
//
|
||||
// The cancel path's whole premise is that a planned restore's ops are a LOSSLESS
|
||||
// carrier of the snapshot's FX half. makeRestorePlan is their only producer, so
|
||||
// the real claim is that the pair composes to the identity on (fxOffline,
|
||||
// fxKeying). Asserting it against hand-built ops would let a change to
|
||||
// makeRestorePlan's field mapping or op ordering pass with every test green.
|
||||
|
||||
static void testRestorePlanOpsRebuildTheIdentityKeyedSnapshotVerbatim() {
|
||||
TrackSnapshot snap;
|
||||
snap.fxKeying = FxKeying::Identity;
|
||||
snap.fxOffline = {FxOfflineState{"{ONE}", 1}, FxOfflineState{"{TWO}", 0},
|
||||
FxOfflineState{"{THREE}", 1}};
|
||||
|
||||
const TrackPlan plan = makeRestorePlan("{TRACK}", snap);
|
||||
const PreParkFx rebuilt = preParkFxFromCancelledRestore(plan.fxOffline);
|
||||
|
||||
CHECK(rebuilt.keying == FxKeying::Identity);
|
||||
// Three DISTINCT entries, compared as a sequence: a dropped fxGuid, a flipped
|
||||
// offline, or a reordering each fail here.
|
||||
CHECK(rebuilt.states == snap.fxOffline);
|
||||
}
|
||||
|
||||
static void testRestorePlanOpsRebuildTheSlotKeyedSnapshotVerbatim() {
|
||||
TrackSnapshot snap;
|
||||
snap.fxKeying = FxKeying::Slot;
|
||||
snap.fxOffline = {FxOfflineState{"", 0}, FxOfflineState{"", 1}, FxOfflineState{"", 1}};
|
||||
|
||||
const TrackPlan plan = makeRestorePlan("{TRACK}", snap);
|
||||
|
||||
// Slot keying addresses by POSITION, so the identity holds only while the op
|
||||
// at index i carries slot i.
|
||||
CHECK(plan.fxOffline.size() == 3);
|
||||
CHECK(plan.fxOffline.size() == 3 && plan.fxOffline[0].slot == 0 &&
|
||||
plan.fxOffline[1].slot == 1 && plan.fxOffline[2].slot == 2);
|
||||
|
||||
const PreParkFx rebuilt = preParkFxFromCancelledRestore(plan.fxOffline);
|
||||
|
||||
CHECK(rebuilt.keying == FxKeying::Slot);
|
||||
CHECK(rebuilt.states == snap.fxOffline);
|
||||
}
|
||||
|
||||
// -- re-entrancy -------------------------------------------------------------
|
||||
|
||||
static void testTakeDetachesEverythingAndLeavesTheQueueEmpty() {
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
q.restore("{B}", ops("{FX}", true));
|
||||
|
||||
const std::vector<FxParkIntent> taken = q.take();
|
||||
|
||||
CHECK(taken.size() == 2);
|
||||
CHECK(taken.size() == 2 && taken[0].guid == "{A}" && taken[0].park);
|
||||
CHECK(taken.size() == 2 && taken[1].guid == "{B}" && !taken[1].park);
|
||||
CHECK(q.empty());
|
||||
}
|
||||
|
||||
static void testIntentsArrivingDuringADrainSurviveIt() {
|
||||
// [verify — DAW] applying an intent loads/unloads plugins, which is ASSUMED to
|
||||
// pump the message loop, so a switch can re-enter and enqueue mid-drain. Those
|
||||
// intents belong to the NEXT drain — the one in progress must neither see them
|
||||
// nor discard them.
|
||||
FxParkQueue q;
|
||||
q.park("{A}");
|
||||
|
||||
const std::vector<FxParkIntent> draining = q.take();
|
||||
q.restore("{B}", ops("{FX}", false)); // arrives while {A} is being applied
|
||||
|
||||
CHECK(draining.size() == 1);
|
||||
CHECK(draining.size() == 1 && draining.front().guid == "{A}");
|
||||
CHECK(q.pending().size() == 1);
|
||||
CHECK(intentFor(q, "{B}") != nullptr);
|
||||
}
|
||||
|
||||
static void testAReEntrantParkCancelsOnlyWhatIsStillPending() {
|
||||
// {A}'s restore was already taken for the in-flight drain, so a park arriving
|
||||
// mid-drain has nothing to cancel — it must queue as a fresh park rather than
|
||||
// silently annihilate against an intent that has already been applied.
|
||||
FxParkQueue q;
|
||||
q.restore("{A}", ops("{FX}", false));
|
||||
q.take();
|
||||
|
||||
const std::vector<FxOfflineOp> cancelled = q.park("{A}");
|
||||
|
||||
CHECK(cancelled.empty());
|
||||
CHECK(q.pending().size() == 1);
|
||||
CHECK(intentFor(q, "{A}") && intentFor(q, "{A}")->park);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testParkEnqueuesOneIntentCarryingNoOps();
|
||||
testParkReportsNothingCancelledWhenNoIntentWasPending();
|
||||
testParkOnItsOwnPendingParkReportsNothingCancelled();
|
||||
testRestoreOnAnUndrainedParkCancelsRatherThanStacks();
|
||||
testParkOnAnUndrainedRestoreCancelsRatherThanStacks();
|
||||
testRepeatedParkStaysOneIntent();
|
||||
testLaterRestoreReplacesTheEarlierOnesOps();
|
||||
testOneTracksCancelLeavesEveryOtherTrackAlone();
|
||||
testCancelledTrackCanBeQueuedAgain();
|
||||
testClearDropsEverythingPending();
|
||||
testParkHandsBackTheOpsOfTheRestoreItCancelled();
|
||||
testCancelledRestoreOpsBecomeTheFreshSnapshotsFxHalf();
|
||||
testNothingCancelledLeavesTheFxHalfToTheCaller();
|
||||
testSlotKeyedRestoreDoesNotBecomeIdentityKeyedWithNoIdentities();
|
||||
testRestorePlanOpsRebuildTheIdentityKeyedSnapshotVerbatim();
|
||||
testRestorePlanOpsRebuildTheSlotKeyedSnapshotVerbatim();
|
||||
testTakeDetachesEverythingAndLeavesTheQueueEmpty();
|
||||
testIntentsArrivingDuringADrainSurviveIt();
|
||||
testAReEntrantParkCancelsOnlyWhatIsStillPending();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
+233
-27
@@ -3,12 +3,17 @@
|
||||
// (lane split + the full-height overlay contract) and its frame<->pixel mapping, marker grab
|
||||
// regions, drag-delta frame resolver (with clamps), and zero-crossing snap.
|
||||
//
|
||||
// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width);
|
||||
// Covers: frameToX / xToFrame — the ONE map, asserted against the REAL draw chain
|
||||
// (computeEnvelope + columnMinMax) rather than a restatement of it, at frame 0 / the last frame
|
||||
// / an interior frame and then exhaustively, in both the frames>columns and frames<columns
|
||||
// regimes, plus the exclusive span end, both round trips, edge clamps and degenerate inputs;
|
||||
// waveformOverlayArea (the overlay IS the drawn column band, inset symmetrically);
|
||||
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
|
||||
// markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable);
|
||||
// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
|
||||
// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
|
||||
// no-crossing keeps target, target clamp, degenerate buffers); the four marks (per-mark cap
|
||||
// resolveDragFrame (drag lands on the frameToX/xToFrame column under the cursor, clamp to
|
||||
// [0,frameCount], zero-delta/zero-width no-ops); nearestZeroCrossing (nearest sign-change,
|
||||
// sample-on-zero, equidistant-tie-to-lower, no-crossing keeps target, target clamp, degenerate
|
||||
// buffers); the four marks (per-mark cap
|
||||
// resolve, the reverse cap order that keeps a coincident pair separable, label sides/nudging,
|
||||
// the suppression rule and its promoted-first placement, the crossfade wedge ramp);
|
||||
// waveformSurface (two stacked
|
||||
@@ -17,6 +22,7 @@
|
||||
|
||||
#include "../src/core/instrument/ui/waveform_view.h"
|
||||
#include "../src/core/instrument/ui/sample_bands.h" // kWaveformMinHeight, kLaneGap
|
||||
#include "../src/core/ui/component_geometry.h" // waveformColumnCount (the draw chain's own)
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
@@ -37,10 +43,13 @@ static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 90); } // width 1000
|
||||
|
||||
// --- frameToX / xToFrame ------------------------------------------------------
|
||||
|
||||
// 1000 frames over 1000 columns: each frame owns exactly one column, so the map is the
|
||||
// identity and every endpoint is exact.
|
||||
static void testFrameToXEndpoints() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(frameToX(overlayOf(a), 1000, 0) == a.x); // frame 0 -> left edge
|
||||
CHECK(frameToX(overlayOf(a), 1000, 1000) == a.right()); // frameCount -> right edge
|
||||
CHECK(frameToX(overlayOf(a), 1000, 0) == a.x); // frame 0 -> first column
|
||||
CHECK(frameToX(overlayOf(a), 1000, 999) == a.right() - 1); // last FRAME -> last column
|
||||
CHECK(frameToX(overlayOf(a), 1000, 1000) == a.right()); // the exclusive span end -> past it
|
||||
CHECK(frameToX(overlayOf(a), 1000, 500) == a.x + 500); // midpoint (1:1 here)
|
||||
}
|
||||
|
||||
@@ -71,14 +80,105 @@ static void testXToFrameClampsOutside() {
|
||||
CHECK(xToFrame(overlayOf(a), 0, a.x + 10) == 0); // no frames -> 0
|
||||
}
|
||||
|
||||
static void testFrameToXRoundTrip() {
|
||||
// Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then
|
||||
// xToFrame should land within a couple frames (rounding both directions).
|
||||
const Rect a = Rect::ltrb(0, 0, 800, 60);
|
||||
for (std::int64_t f = 0; f <= 2000; f += 137) {
|
||||
const int x = frameToX(overlayOf(a), 2000, f);
|
||||
const std::int64_t back = xToFrame(overlayOf(a), 2000, x);
|
||||
CHECK(back >= f - 3 && back <= f + 3);
|
||||
// --- The frame<->pixel mapping against the draw chain it must agree with -------
|
||||
//
|
||||
// The whole Ω.6 contract: the overlay reads the SAME frame->column partition the waveform is
|
||||
// binned and drawn through, so these fixtures run the REAL chain (computeEnvelope +
|
||||
// columnMinMax) rather than restating the partition, which would only prove the test agrees
|
||||
// with itself.
|
||||
|
||||
// Which columns the draw chain actually paints frame `f` into: a spike at f over silence, binned
|
||||
// exactly as paintWaveform bins it, read back per column. Inclusive run, or lo < 0 for none.
|
||||
struct ColumnRun { int lo = -1; int hi = -1; };
|
||||
|
||||
static ColumnRun drawnColumnsForFrame(int columns, std::int64_t frameCount, std::int64_t f) {
|
||||
std::vector<AudioSample> pcm(static_cast<std::size_t>(frameCount), 0.0f);
|
||||
pcm[static_cast<std::size_t>(f)] = 1.0f;
|
||||
// paintWaveform's own bin count: one per drawn column, capped at the frames available.
|
||||
const std::int64_t wantBins = static_cast<std::int64_t>(columns);
|
||||
const std::size_t bins =
|
||||
static_cast<std::size_t>(wantBins < frameCount ? wantBins : frameCount);
|
||||
const reasampler::audio::Envelope env =
|
||||
reasampler::audio::computeEnvelope(pcm, 1, static_cast<std::size_t>(frameCount), bins);
|
||||
ColumnRun run;
|
||||
for (int c = 0; c < columns; ++c) {
|
||||
if (reasampler::audio::columnMinMax(env[0], columns, c).max < 1.0f) continue;
|
||||
if (run.lo < 0) run.lo = c;
|
||||
run.hi = c;
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
static void checkMarkLandsOnItsOwnWaveformColumn(const Rect& band, std::int64_t frameCount,
|
||||
std::int64_t f) {
|
||||
const OverlayArea ov = waveformOverlayArea(band);
|
||||
const ColumnRun run = drawnColumnsForFrame(ov.rect.width, frameCount, f);
|
||||
CHECK(run.lo >= 0); // the draw chain paints every frame somewhere
|
||||
const int col = frameToX(ov, frameCount, f) - ov.rect.x;
|
||||
CHECK(col >= run.lo && col <= run.hi);
|
||||
}
|
||||
|
||||
static void testAMarkLandsOnTheWaveformColumnForItsOwnFrame() {
|
||||
const Rect b = Rect{8, 90, 404, 60}; // 400 drawn columns
|
||||
// frames > columns: many frames share one column, and the mark must pick that column.
|
||||
const std::int64_t many = 9973; // prime, so no boundary falls anywhere convenient
|
||||
checkMarkLandsOnItsOwnWaveformColumn(b, many, 0);
|
||||
checkMarkLandsOnItsOwnWaveformColumn(b, many, many - 1);
|
||||
checkMarkLandsOnItsOwnWaveformColumn(b, many, 4001);
|
||||
// frames < columns: one frame spans many columns, and the mark must land inside its own run.
|
||||
const std::int64_t few = 37;
|
||||
checkMarkLandsOnItsOwnWaveformColumn(b, few, 0);
|
||||
checkMarkLandsOnItsOwnWaveformColumn(b, few, few - 1);
|
||||
checkMarkLandsOnItsOwnWaveformColumn(b, few, 19);
|
||||
}
|
||||
|
||||
// Not just the three probe frames: EVERY frame, across both regimes and the 1:1 boundary.
|
||||
static void testTheMappingAgreesWithTheDrawChainAtEveryFrame() {
|
||||
const int widths[] = {21, 64, 104}; // 17 / 60 / 100 drawn columns
|
||||
const std::int64_t counts[] = {7, 60, 100, 251}; // below, equal to and above each
|
||||
for (int w : widths) {
|
||||
for (std::int64_t n : counts) {
|
||||
const Rect b = Rect{3, 0, w, 40};
|
||||
for (std::int64_t f = 0; f < n; ++f) checkMarkLandsOnItsOwnWaveformColumn(b, n, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The closed domain is a SPAN's exclusive end, not a frame: it is what the loop fill and the
|
||||
// crossfade wedge stop at, so it belongs one past the last column and nowhere else.
|
||||
static void testTheExclusiveSpanEndLandsOnTheRightEdge() {
|
||||
const Rect b = Rect{8, 90, 404, 60};
|
||||
const OverlayArea ov = waveformOverlayArea(b);
|
||||
const std::int64_t counts[] = {7, 400, 9973};
|
||||
for (std::int64_t n : counts) {
|
||||
CHECK(frameToX(ov, n, n) == ov.rect.right());
|
||||
CHECK(frameToX(ov, n, n + 5000) == ov.rect.right()); // and clamps there
|
||||
// The last real FRAME is the last real column — one inside that edge.
|
||||
CHECK(frameToX(ov, n, n - 1) == ov.rect.right() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
static void testXToFrameRoundTripsEveryFrameWhileAFrameOwnsAColumn() {
|
||||
// frames <= columns is exactly where a frame spans several columns and the choice of which
|
||||
// one to mark is observable, so it is where the inverse has to be exact.
|
||||
const Rect b = Rect{8, 90, 404, 60};
|
||||
const OverlayArea ov = waveformOverlayArea(b);
|
||||
const std::int64_t counts[] = {1, 37, 399, 400};
|
||||
for (std::int64_t n : counts) {
|
||||
for (std::int64_t f = 0; f < n; ++f) CHECK(xToFrame(ov, n, frameToX(ov, n, f)) == f);
|
||||
}
|
||||
}
|
||||
|
||||
static void testColumnsRoundTripWhereFramesShareThem() {
|
||||
// Above the column count a per-frame round trip cannot exist — several frames share one
|
||||
// column. What must still hold is the COLUMN round trip: every column answers a frame that
|
||||
// maps straight back to that same column, so no column is unreachable or ambiguous.
|
||||
const Rect b = Rect{8, 90, 404, 60};
|
||||
const OverlayArea ov = waveformOverlayArea(b);
|
||||
const std::int64_t n = 9973;
|
||||
for (int c = 0; c < ov.rect.width; ++c) {
|
||||
const int x = ov.rect.x + c;
|
||||
CHECK(frameToX(ov, n, xToFrame(ov, n, x)) == x);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,13 +244,13 @@ static void testResolveDragFrameClamps() {
|
||||
CHECK(resolveDragFrame(ov, 1000, 950, 500) == 1000); // clamp high (== frameCount)
|
||||
}
|
||||
|
||||
static void testResolveDragFrameRounds() {
|
||||
// 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is
|
||||
// at the frame centre. Use a scale where a fractional result appears.
|
||||
const OverlayArea ov = overlayOf(Rect::ltrb(0, 0, 300, 60)); // 1000 frames / 300px = 3.33 frames/px
|
||||
// +3px -> 3*1000/300 = 10.0 -> 10 frames.
|
||||
static void testResolveDragFrameTruncatesAtFractionalScale() {
|
||||
// 300px area over 1000 frames -> 3.33 frames/px, so frameToX(start) -> +dx -> xToFrame
|
||||
// lands on the column's truncating partition rather than a whole multiple of dx.
|
||||
const OverlayArea ov = overlayOf(Rect::ltrb(0, 0, 300, 60));
|
||||
// frameToX(100) = 30; xToFrame(30 + 3) = 33*1000/300 = 110.0 -> 110 frames.
|
||||
CHECK(resolveDragFrame(ov, 1000, 100, 3) == 110);
|
||||
// +1px -> 1000/300 = 3.33 -> rounds to 3.
|
||||
// frameToX(100) = 30; xToFrame(30 + 1) = 31*1000/300 = 103.33 -> truncates to 103.
|
||||
CHECK(resolveDragFrame(ov, 1000, 100, 1) == 103);
|
||||
}
|
||||
|
||||
@@ -164,6 +264,38 @@ static void testResolveDragFrameDegenerate() {
|
||||
CHECK(resolveDragFrame(ov, 1000, 5000, 0) == 1000);
|
||||
}
|
||||
|
||||
// A drag must land on the column under the cursor — the SAME frameToX/xToFrame partition, never
|
||||
// a proportional approximation of it. Pins the contract itself (grabX = frameToX(startFrame),
|
||||
// result = xToFrame(grabX + dxPixels)) rather than a captured number, in the frames < columns
|
||||
// regime where the two disagree: a prior independent linear map here left a marker at frame 10
|
||||
// (1000px/37 frames, start=10, +3px) when a fresh xToFrame(x) at the same cursor column
|
||||
// resolves to frame 11 — exactly the class of drift a second frame<->pixel map produces.
|
||||
static void testResolveDragFrameLandsOnCursorColumn() {
|
||||
const OverlayArea ov = overlayOf(Rect::ltrb(0, 0, 1000, 60));
|
||||
const std::int64_t frameCount = 37;
|
||||
const std::int64_t startFrame = 10;
|
||||
const int dx = 3;
|
||||
const int grabX = frameToX(ov, frameCount, startFrame);
|
||||
const std::int64_t cursorFrame = xToFrame(ov, frameCount, grabX + dx);
|
||||
CHECK(cursorFrame == 11); // the contract's own derivation
|
||||
CHECK(resolveDragFrame(ov, frameCount, startFrame, dx) == cursorFrame);
|
||||
// Structural coverage, not behavioural: this sweep asserts resolveDragFrame's own definition
|
||||
// (frameToX then xToFrame) and cannot fail while it calls those two functions. Its value is
|
||||
// pinning that there is no second, independent mapping hiding in some frames<columns or
|
||||
// frames>columns corner — the single behavioural anchor is the literal 11 above.
|
||||
const std::int64_t counts[] = {5, 37, 251, 9973};
|
||||
const int deltas[] = {-97, -3, -1, 1, 3, 97};
|
||||
for (std::int64_t n : counts) {
|
||||
for (std::int64_t start = 0; start < n; start += (std::max<std::int64_t>)(1, n / 11)) {
|
||||
for (int d : deltas) {
|
||||
const std::int64_t got = resolveDragFrame(ov, n, start, d);
|
||||
const int wantGrabX = frameToX(ov, n, start);
|
||||
CHECK(got == xToFrame(ov, n, wantGrabX + d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- nearestZeroCrossing ------------------------------------------------------
|
||||
|
||||
static void testZeroCrossingNearest() {
|
||||
@@ -190,6 +322,40 @@ static void testZeroCrossingEquidistantTieToLower() {
|
||||
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2);
|
||||
}
|
||||
|
||||
// The snap has to survive the mapping change BEHAVIOUR-IDENTICAL, ties included, so the tie
|
||||
// rule is pinned at every distance rather than at one: the fan-out probes t-d before t+d, so an
|
||||
// equidistant pair always resolves to the LOWER frame. A single spike to 0 is its own isolated
|
||||
// crossing (the sample-on-zero rule), which is what keeps each side's crossing count at one.
|
||||
static void testZeroCrossingTiesAlwaysResolveToTheLowerFrame() {
|
||||
const std::int64_t n = 200, t = 100;
|
||||
for (std::int64_t d = 1; d <= 40; ++d) {
|
||||
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
|
||||
pcm[static_cast<std::size_t>(t - d)] = 0.0f;
|
||||
pcm[static_cast<std::size_t>(t + d)] = 0.0f;
|
||||
CHECK(nearestZeroCrossing(pcm.data(), n, t) == t - d);
|
||||
}
|
||||
}
|
||||
|
||||
// ...and the tie rule is the ONLY asymmetry: wherever one side is strictly nearer, that side
|
||||
// wins, from either direction. Without this, "lower wins" could hide a left-biased search.
|
||||
static void testZeroCrossingTakesTheNearerSideFromEitherDirection() {
|
||||
const std::int64_t n = 200, t = 100;
|
||||
for (std::int64_t d = 2; d <= 40; ++d) {
|
||||
{
|
||||
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
|
||||
pcm[static_cast<std::size_t>(t - d)] = 0.0f;
|
||||
pcm[static_cast<std::size_t>(t + d - 1)] = 0.0f; // right nearer by one
|
||||
CHECK(nearestZeroCrossing(pcm.data(), n, t) == t + d - 1);
|
||||
}
|
||||
{
|
||||
std::vector<AudioSample> pcm(static_cast<std::size_t>(n), 1.0f);
|
||||
pcm[static_cast<std::size_t>(t - d + 1)] = 0.0f; // left nearer by one
|
||||
pcm[static_cast<std::size_t>(t + d)] = 0.0f;
|
||||
CHECK(nearestZeroCrossing(pcm.data(), n, t) == t - d + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testZeroCrossingNoneKeepsTarget() {
|
||||
// All one sign -> no crossing -> the (clamped) target comes back unchanged.
|
||||
std::vector<AudioSample> pcm = {0.5f, 0.6f, 0.7f, 0.8f};
|
||||
@@ -252,23 +418,54 @@ static void testSurfaceOverlayIsFullStackedHeightInBothModes() {
|
||||
const Rect b = band();
|
||||
const WaveformSurface st = waveformSurface(b, /*stereoMode=*/true, 2);
|
||||
const WaveformSurface mo = waveformSurface(b, /*stereoMode=*/false, 2);
|
||||
// Stereo: ONE overlay rect spanning both lanes, not either lane.
|
||||
CHECK(st.overlay.rect == b);
|
||||
// Stereo: ONE overlay rect spanning both lanes, not either lane. The HEIGHT is what the
|
||||
// overlay contract is about, and it is the whole stack in both modes.
|
||||
CHECK(st.overlay.rect.y == b.y && st.overlay.rect.height == b.height);
|
||||
CHECK(st.overlay.rect.height == st.upper.height + kLaneGap + st.lower.height);
|
||||
CHECK(st.overlay.rect != st.upper && st.overlay.rect != st.lower);
|
||||
// Mono: the same rect, which is also the single lane.
|
||||
CHECK(mo.overlay.rect == b);
|
||||
CHECK(mo.overlay.rect == mo.upper);
|
||||
// Mono: the same rect. It is NOT the single lane any more — the lane is the whole band,
|
||||
// the overlay is the band's drawn column span inside it.
|
||||
CHECK(mo.overlay.rect.y == b.y && mo.overlay.rect.height == b.height);
|
||||
CHECK(mo.overlay.rect == st.overlay.rect);
|
||||
CHECK(mo.overlay.rect != mo.upper);
|
||||
// The standalone accessor the hit-test paths use agrees with the resolved surface.
|
||||
CHECK(waveformOverlayArea(b) == st.overlay);
|
||||
CHECK(waveformOverlayArea(b) == mo.overlay);
|
||||
}
|
||||
|
||||
// THE Ω.6 contract at the construction site: the overlay is the band's drawn column span, so
|
||||
// an overlay pixel and a waveform column are the same pixel. Read from the draw chain's own
|
||||
// column count — a hardcoded 2/4 here would be the second copy that let the two drift.
|
||||
static void testTheOverlayIsExactlyTheDrawnColumnBand() {
|
||||
const Rect b = band();
|
||||
const OverlayArea ov = waveformOverlayArea(b);
|
||||
const int columns = reasampler::ui::waveformColumnCount(b);
|
||||
CHECK(columns > 0);
|
||||
CHECK(ov.rect.width == columns);
|
||||
CHECK(ov.rect.x == b.x + (b.width - columns) / 2);
|
||||
// Inset on BOTH sides, and the same amount on each — the halving above is only legitimate
|
||||
// because the draw chain's inset is symmetric.
|
||||
CHECK(ov.rect.x - b.x == b.right() - ov.rect.right());
|
||||
CHECK(ov.rect.x > b.x && ov.rect.right() < b.right());
|
||||
// Held across widths, not just this one.
|
||||
for (int w = 5; w <= 300; ++w) {
|
||||
const Rect band2 = Rect{7, 40, w, 60};
|
||||
const OverlayArea o2 = waveformOverlayArea(band2);
|
||||
CHECK(o2.rect.width == reasampler::ui::waveformColumnCount(band2));
|
||||
CHECK(o2.rect.x - band2.x == band2.right() - o2.rect.right());
|
||||
}
|
||||
}
|
||||
|
||||
static void testSurfaceDegenerateBandDrawsNothing() {
|
||||
const WaveformSurface s = waveformSurface(Rect{10, 10, 0, 0}, true, 2);
|
||||
CHECK(s.laneCount == 0);
|
||||
CHECK(s.upper.empty() && s.lower.empty() && s.overlay.rect.empty());
|
||||
CHECK(waveformOverlayArea(Rect{10, 10, 0, 0}).rect.empty());
|
||||
// A band too narrow to hold a single column has no overlay to draw into, even though the
|
||||
// band itself is not degenerate and still gets a lane.
|
||||
CHECK(reasampler::ui::waveformColumnCount(Rect{0, 0, 4, 40}) == 0);
|
||||
CHECK(waveformOverlayArea(Rect{0, 0, 4, 40}).rect.empty());
|
||||
CHECK(!waveformSurface(Rect{0, 0, 4, 40}, false, 1).upper.empty());
|
||||
}
|
||||
|
||||
static void testSurfaceThinBandRoundsLowerLaneEmpty() {
|
||||
@@ -606,7 +803,12 @@ int main() {
|
||||
testFrameToXDegenerate();
|
||||
testXToFrameInverse();
|
||||
testXToFrameClampsOutside();
|
||||
testFrameToXRoundTrip();
|
||||
|
||||
testAMarkLandsOnTheWaveformColumnForItsOwnFrame();
|
||||
testTheMappingAgreesWithTheDrawChainAtEveryFrame();
|
||||
testTheExclusiveSpanEndLandsOnTheRightEdge();
|
||||
testXToFrameRoundTripsEveryFrameWhileAFrameOwnsAColumn();
|
||||
testColumnsRoundTripWhereFramesShareThem();
|
||||
|
||||
testMarkerAtPointGrabsWithinBand();
|
||||
testMarkerAtPointMissesBetween();
|
||||
@@ -615,12 +817,15 @@ int main() {
|
||||
|
||||
testResolveDragFrameShift();
|
||||
testResolveDragFrameClamps();
|
||||
testResolveDragFrameRounds();
|
||||
testResolveDragFrameTruncatesAtFractionalScale();
|
||||
testResolveDragFrameDegenerate();
|
||||
testResolveDragFrameLandsOnCursorColumn();
|
||||
|
||||
testZeroCrossingNearest();
|
||||
testZeroCrossingSampleOnZero();
|
||||
testZeroCrossingEquidistantTieToLower();
|
||||
testZeroCrossingTiesAlwaysResolveToTheLowerFrame();
|
||||
testZeroCrossingTakesTheNearerSideFromEitherDirection();
|
||||
testZeroCrossingNoneKeepsTarget();
|
||||
testZeroCrossingClampsTarget();
|
||||
testZeroCrossingDegenerate();
|
||||
@@ -629,6 +834,7 @@ int main() {
|
||||
testSurfaceMonoIsOneLane();
|
||||
testSurfaceMonoSourceInStereoModeStaysOneLane();
|
||||
testSurfaceOverlayIsFullStackedHeightInBothModes();
|
||||
testTheOverlayIsExactlyTheDrawnColumnBand();
|
||||
testSurfaceDegenerateBandDrawsNothing();
|
||||
testSurfaceThinBandRoundsLowerLaneEmpty();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user