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

This commit is contained in:
2026-08-02 13:19:58 -04:00
45 changed files with 2771 additions and 950 deletions
+6 -5
View File
@@ -311,7 +311,7 @@ anything for a trigger shape.
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked 0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over 60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws.
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over 60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
### `map/`
@@ -326,11 +326,11 @@ anything for a trigger shape.
### `ui/`
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups_measured.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
- **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition.
@@ -339,7 +339,7 @@ anything for a trigger shape.
- `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
@@ -357,7 +357,8 @@ anything for a trigger shape.
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
labels for them.
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. `kMeterColumnW` is the SUM of those three, exported so `deck_groups`' MASTER descriptor reserves exactly what the interior consumes — the column is banked to grow, and a reserve that did not track it would underfill or overrun silently. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveform_view`'s `resolveLaneSplit` answers** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Also owns `meterTickNumeralled` (the spec-pinned 0/12/24/36/48/60 numeral set, beside the tick step it derives from), `meterNumeralRect` (bottom-clamped, so the floor tick's numeral cannot hang out of the gutter), and `meterSingleLaneState` the one bar folds both channels PER FIELD, never picking a whole channel by level. Composes `engine/meter_ballistics` per channel and gives the gain-reduction lamp the peak tick's own hold-then-release, without which a catch smaller than 20 dB × the UI period is dark again before it has been drawn twice; the audio thread's clip flag is ORed in because it is the only latch that sees every block. `meterDrawEqual` is what lets the UI tick repaint on change alone.
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the 92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
@@ -87,3 +87,9 @@ reasampler_test(limiter LINK limiter)
reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp)
reasampler_test(meter_ballistics LINK meter_ballistics)
# The meter's ACCUMULATE half, beside the ballistics that consume it. Header-only (the folds
# sit on the audio thread's per-block path), hence INTERFACE.
add_library(meter_accumulate INTERFACE)
target_include_directories(meter_accumulate INTERFACE ${REASAMPLER_SRC_DIR})
reasampler_test(meter_accumulate LINK meter_accumulate)
@@ -0,0 +1,69 @@
// meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold
// into the two windows the UI drains, and the drain that starts the next window. The ballistics
// that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio
// thread's per-block path. The folds are templated on the accumulator ONLY so the
// drain-inside-the-fold interleave below can be pinned deterministically instead of raced for.
#pragma once
#include <atomic>
namespace reasampler::instrument::engine {
// A lock-backed std::atomic<float> would put a mutex on the audio thread; assert the freedom
// rather than assume it.
static_assert(std::atomic<float>::is_always_lock_free,
"the meter folds run on the audio thread and must be lock-free");
// The two windows' identity elements: a peak window that has seen nothing reports silence, a
// gain window that has seen nothing reports no reduction. They are what a consume reinstalls,
// so they live beside the folds rather than at the reader.
inline constexpr float kMeterPeakIdentity = 0.f;
inline constexpr float kMeterGainIdentity = 1.f;
// Folds one block's reading into its accumulator — a running max for a peak, a running min for
// the limiter's gain — so the ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512
// all reach the meter instead of the one it happened to sample.
//
// An UNCONDITIONAL read-modify-write, and that is the whole point. The UI's consume is an
// exchange that can land between a plain load and its store, and a load-compare-store fold
// would then drop the block outright: it decided against storing by comparing with a window the
// UI has since taken, so that block's reading enters neither the old window nor the new one.
// The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on
// exit however the two interleave. STRONG, so the loop is bounded by the interference it is
// written against: the audio thread is the only writer besides the UI's single consume, and
// weak's permitted spurious failure would make an unbounded retry count reachable with no
// interference at all. Three calls per block, so the strong form costs nothing measurable.
// Relaxed throughout: the accumulators are advisory and order no other state. `Accumulator` is
// templated only so a test can pin the interleave; it must behave as std::atomic<float>.
template <class Accumulator>
inline void foldPeak(Accumulator& acc, float blockPeak) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_strong(seen, seen > blockPeak ? seen : blockPeak,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
template <class Accumulator>
inline void foldMinGain(Accumulator& acc, float blockMinGain) {
float seen = acc.load(std::memory_order_relaxed);
while (!acc.compare_exchange_strong(seen, seen < blockMinGain ? seen : blockMinGain,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
// Takes what the window accumulated and reinstalls the identity element, which IS what starts
// the next window — so exactly one reader may consume (the shell's MasterBusMeter states who).
// Concrete: only the folds have the interleave a test seam buys, and a template over one
// instantiation models nothing.
inline float consumePeak(std::atomic<float>& acc) {
return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed);
}
inline float consumeMinGain(std::atomic<float>& acc) {
return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed);
}
} // namespace reasampler::instrument::engine
+6
View File
@@ -104,6 +104,12 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
}
}
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b) {
return a.relativePath == b.relativePath && a.rootNote == b.rootNote &&
a.channelCount == b.channelCount && a.loop.hasLoop == b.loop.hasLoop &&
a.loop.start == b.loop.start && a.loop.end == b.loop.end;
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
+7
View File
@@ -85,6 +85,13 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId);
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids);
// True when two refs would build the same SampleData: path plus every intrinsic
// resolveCapture folds. displayName is excluded on purpose — it is a label, never a decode
// input. Exists so a caller holding an ALREADY-DECODED sample can ask whether a refresh moved
// what that sample was decoded from; comparing the fields at the call site instead would go
// stale the first time this struct gains one.
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b);
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
+27 -9
View File
@@ -20,13 +20,11 @@ reasampler_test(capture_browser LINK capture_browser)
reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
# sample_bands is PRIVATE: the lane split is used internally and nothing in the public
# header needs it.
# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit the meter's bar
# count consumes that answer, so the type is part of this module's surface, not an internal.
reasampler_pure_library(waveform_view
SOURCES waveform_view.cpp
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands)
# sample_bands is linked directly here because the test exercises the lane metrics that
# waveform_view does not re-export.
LINK PUBLIC editor_geometry peaks sample_bands)
reasampler_test(waveform_view LINK waveform_view sample_bands)
# The loop enable's state machine. Links loop_span for the park bounds the span the user is
@@ -57,17 +55,37 @@ reasampler_test(envelope_edit LINK envelope_edit)
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
reasampler_test(knob_deck LINK knob_deck)
# The spanning deck's meter column, split from knob_deck on the axis sample_chrome has to
# sample_bands: that says where the column is, this lays out inside it. sample_bands is PUBLIC
# for LaneSplit the bar count is the SAME resolved decision the waveform's lane split is.
reasampler_pure_library(master_meter
SOURCES master_meter.cpp
LINK PUBLIC editor_geometry sample_bands meter_ballistics)
# waveform_view is linked for the test only: proving the bar count is not a second rule takes
# the real waveformSurface fold, over channel mode x source channel count.
reasampler_test(master_meter LINK master_meter waveform_view)
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
# master_meter is PRIVATE: MASTER's descriptor reserves the meter column's own kMeterColumnW,
# but nothing in deck_groups.h names a meter type, so the edge stops at this TU.
reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks curve_law)
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
# needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands)
LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter)
# WHICH descriptors the deck carries, and how they resolve to a layout no window-floor budget
# assertion here, so this target needs neither sample_bands nor master_meter.
reasampler_test(deck_groups LINK deck_groups)
# The width-BUDGET half, split out on the same seam PRIVATE master_meter already draws above:
# the deck-fits-the-floor-window assertion needs the band allocator, and the MASTER-reserve
# identity needs the column width the PRIVATE edge on deck_groups does not re-export.
reasampler_test(deck_groups_measured LINK deck_groups sample_bands master_meter)
# The commit-tier + overlay-selection state machine, split out of deck_groups_tests on the seam
# those fixtures already had: deckParamCommit/liveCommitFor and the overlay predicates are pure
# control-id/enum logic that touches no layout, so this target needs no sample_bands/master_meter.
reasampler_test(deck_groups_state LINK deck_groups)
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
# envelope_overlay/envelope_edit, which stay engine-free the staged envelopes touch no curve).
+23 -4
View File
@@ -4,6 +4,8 @@
#include <utility>
#include "core/instrument/ui/master_meter.h" // kMeterColumnW (what the column's interior needs)
namespace reasampler::instrument::ui {
namespace {
@@ -11,8 +13,9 @@ int id(DeckParam p) { return static_cast<int>(p); }
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
// no wider than its knob row the ceiling is PITCH ENV's, whose caption row lands exactly on
// its four-cell knob row at 23. Raising it reflows the deck's first row.
// no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob
// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
// the CONTOUR row, which has 152px of slack, not the SOUND row.
constexpr int kEnvModeSegW = 23;
} // namespace
@@ -63,7 +66,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
id(DeckParam::kFilterModAmt),
id(DeckParam::kFilterVel),
id(DeckParam::kFilterKeyTrack)};
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
// The morph law rides the caption slack. Moving it back to the knob row costs the
// group 92px and the SOUND row stops fitting its block.
filter.captionToggle2 = {id(DeckParam::kFilterLaw), 44};
out.push_back(std::move(filter));
}
{
@@ -126,12 +131,21 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
out.push_back(std::move(voice));
}
{
// The lower slot is reserved and draws NOTHING: blank reads as breathing room where a
// dashed placeholder would read as unfinished. It is one cell, not two — a second
// would spend 60 of the layout's whole 82px budget on a control nobody has named.
DeckGroupDesc master;
master.id = kGroupMaster;
master.captionWidth = 46;
master.cellIds = {id(DeckParam::kMasterGain)};
master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true};
master.captionToggle = {id(DeckParam::kLimiterEnable), 32};
master.cellIds = {id(DeckParam::kMasterGain), -1};
// The reserve IS what the interior consumes — read from master_meter rather than
// restated, so the two cannot drift when the column grows into MASTER's banked room.
master.column = {id(DeckParam::kMasterMeter), kMeterColumnW};
out.push_back(std::move(master));
}
for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast<DeckGroupId>(d.id));
return out;
}
@@ -257,6 +271,11 @@ LiveCommit deckParamCommit(DeckParam id) {
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
case DeckParam::kMasterGain:
case DeckParam::kLimiterEnable:
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
// radios above.
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
case DeckParam::kCount: // not a control
return LiveCommit::Reload;
}
+9 -8
View File
@@ -87,6 +87,11 @@ enum class DeckParam {
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
kLimiterEnable, // master-bus limiter Off | On caption toggle (MASTER group)
// MASTER's two readouts. Neither reaches a parameter: the meter's only gesture is the
// click that clears its latched clip cap, and the bubble is a passive lamp.
kMasterMeter,
kMasterGr,
kCount
};
@@ -103,14 +108,10 @@ enum DeckGroupId {
kGroupMaster,
};
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice
// IS, Contour is how it moves over time, Spanning is what happens after the mixer.
enum class DeckRow { Sound, Contour, Spanning };
// Which row a group belongs to. Membership is a property of the GROUP; width is a property of
// its descriptor — separating them is what lets the row law be settled while the descriptors
// are still moving. Total over DeckGroupId by an exhaustive switch with no default, so a group
// added without a row cannot silently become Sound.
// Which row a group belongs to (the row vocabulary itself is knob_deck's — the layout is what
// reads it). Membership is a property of the GROUP; width is a property of its descriptor.
// Total over DeckGroupId by an exhaustive switch with no default, so a group added without a
// row cannot silently become Sound.
DeckRow deckRowFor(DeckGroupId group);
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
+149 -55
View File
@@ -8,10 +8,19 @@ namespace reasampler::instrument::ui {
namespace {
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
// already carries its own breathing room around the 28px knob), plus the optional row
// toggle after a kDeckToggleGap.
// The knob-row width of a group: cells side by side (no inter-cell gap — the 60px cell
// already carries its own breathing room around the 40px knob), plus the optional row
// toggle after a kDeckToggleGap. A spanning group's cells stack, so its knob row is one
// cell wide plus whatever readout column sits beside it.
int knobRowWidth(const DeckGroupDesc& g) {
if (g.row == DeckRow::Spanning) {
int w = g.cellIds.empty() ? 0 : kDeckCellW;
if (g.column.id >= 0) {
if (w > 0) w += kDeckColumnGap;
w += g.column.width;
}
return w;
}
int w = static_cast<int>(g.cellIds.size()) * kDeckCellW;
if (g.rowToggle.id >= 0) {
if (w > 0) w += kDeckToggleGap;
@@ -29,6 +38,24 @@ int captionRowWidth(const DeckGroupDesc& g) {
return w;
}
// One knob cell inside `cell`: the centered dial square, its concentric inner disc, and the
// label band beneath.
DeckCellLayout layoutCell(int id, const Rect& cell) {
DeckCellLayout c;
c.id = id;
c.cell = cell;
const int knobLeft = cell.x + (cell.width - kDeckKnobSize) / 2;
const int knobTop = cell.y + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
innerTopPx + kDeckInnerDialSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(cell.x, labelTop, cell.right(), labelTop + kDeckCellLabelH);
return c;
}
// Place one group's inner geometry given its box.
DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
DeckGroupLayout out;
@@ -46,7 +73,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
out.captionRadio = DeckRadioLayout{
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
radioTop + kDeckRadioSize)};
radioTop + kDeckRadioSize),
g.captionRadio.passive};
captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x;
}
@@ -65,10 +93,36 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
placeToggle(g.captionToggle, out.captionToggle);
placeToggle(g.captionToggle2, out.captionToggle2);
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
if (g.row == DeckRow::Spanning) {
// FIXED slots down the left column, one per declared id (reserves advance the slot
// without drawing a cell), spaced by a whole row pitch so slot k lands exactly on
// categorical row k's knob baseline. Deliberately NOT the run-division law below.
int slotTop = cellTop;
for (int id : g.cellIds) {
if (id >= 0) {
out.cells.push_back(layoutCell(
id, Rect::ltrb(innerLeft, slotTop, innerLeft + kDeckCellW,
slotTop + kDeckCellH)));
}
slotTop += kDeckGroupH + kDeckRowGap;
}
if (g.column.id >= 0) {
// ONE rect spanning every slot, not a readout per row. Right-anchored off
// innerRight rather than measured past the cell slot, so a wider caption
// reserve on this group can never detach the column from the padding.
const int colX = innerRight - g.column.width;
out.column = DeckColumnLayout{
g.column.id, Rect::ltrb(colX, cellTop, colX + g.column.width,
box.bottom() - kDeckGroupPadY)};
}
return out;
}
// Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
// id, reserves included). Integer division puts an indivisible residue in symmetric end
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
int presentCells = 0;
for (int id : g.cellIds) {
@@ -78,19 +132,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
for (int id : g.cellIds) {
if (id < 0) continue;
DeckCellLayout c;
c.id = id;
c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
innerTopPx + kDeckInnerDialSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c);
out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW,
cellTop + kDeckCellH)));
x += cellW;
}
if (g.rowToggle.id >= 0) {
@@ -107,65 +150,110 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
return out;
}
// The gutters between `count` groups whose widths total `total`, justified space-between
// inside `blockW`. Empty for a single group.
std::vector<int> justifyGutters(int count, int total, int blockW) {
const int gutters = count - 1;
if (gutters <= 0) return {};
const int slack = blockW - total;
if (slack < gutters * kDeckGroupGap) {
// The block cannot hold the row: minimum gutters, and the row overruns to the right
// rather than wrapping — see layoutDeck's header note for when this degrade applies.
return std::vector<int>(static_cast<std::size_t>(gutters), kDeckGroupGap);
}
const int base = slack / gutters;
const int residue = slack % gutters; // both non-negative: slack >= gutters * 12 > 0
std::vector<int> out(static_cast<std::size_t>(gutters), base);
for (int i = 0; i < residue; ++i) ++out[static_cast<std::size_t>(i)];
return out;
}
} // namespace
int deckGroupWidth(const DeckGroupDesc& g) {
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
}
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth) {
if (groups.empty()) return 0;
int rows = 1;
int x = 0;
int deckRowCount(const std::vector<DeckGroupDesc>& groups) {
bool sound = false, contour = false;
for (const DeckGroupDesc& g : groups) {
const int w = deckGroupWidth(g);
if (x > 0 && x + kDeckGroupGap + w > availWidth) {
++rows;
x = w;
} else {
x += (x > 0 ? kDeckGroupGap : 0) + w;
}
if (g.row == DeckRow::Sound) sound = true;
else if (g.row == DeckRow::Contour) contour = true;
}
return rows;
return (sound ? 1 : 0) + (contour ? 1 : 0);
}
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth) {
const int rows = deckRowCount(groups, availWidth);
if (rows == 0) return 0;
return rows * kDeckGroupH + (rows - 1) * kDeckRowGap;
int deckHeight(const std::vector<DeckGroupDesc>& groups) {
const int rows = deckRowCount(groups);
int h = rows > 0 ? rows * kDeckGroupH + (rows - 1) * kDeckRowGap : 0;
for (const DeckGroupDesc& g : groups) {
if (g.row == DeckRow::Spanning) h = (std::max)(h, kDeckSpanningH);
}
return h;
}
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth) {
DeckLayout out;
if (groups.empty()) return out;
int x = left;
int y = top;
bool rowHasGroup = false;
out.rowCount = 1;
for (const DeckGroupDesc& g : groups) {
const int w = deckGroupWidth(g);
if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) {
// Wrap: whole trailing group onto the next row (mirror of deckRowCount).
++out.rowCount;
x = left;
y += kDeckGroupH + kDeckRowGap;
rowHasGroup = false;
}
if (rowHasGroup) x += kDeckGroupGap;
const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
out.groups.push_back(layoutGroup(g, box));
x = box.right();
rowHasGroup = true;
// Partition by the group's OWN row (indices, so the OUTPUT keeps deck order — the shell
// and the tests pair a layout with the descriptor at the same position).
std::vector<std::size_t> rows[2];
std::vector<std::size_t> spanning;
for (std::size_t i = 0; i < groups.size(); ++i) {
const DeckRow row = groups[i].row;
if (row == DeckRow::Spanning) spanning.push_back(i);
else rows[row == DeckRow::Contour ? 1 : 0].push_back(i);
}
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
// The spanning decks take the right edge; the row block is what is left of them.
int spanTotal = 0;
for (std::size_t i : spanning) spanTotal += deckGroupWidth(groups[i]);
if (!spanning.empty()) {
spanTotal += (static_cast<int>(spanning.size()) - 1) * kDeckGroupGap;
}
const int blockW = availWidth - (spanning.empty() ? 0 : spanTotal + kDeckGroupGap);
std::vector<Rect> boxes(groups.size());
int y = top;
for (const std::vector<std::size_t>& row : rows) {
if (row.empty()) continue; // an absent category collapses; it leaves no empty band
++out.rowCount;
int total = 0;
for (std::size_t i : row) total += deckGroupWidth(groups[i]);
const std::vector<int> gutters =
justifyGutters(static_cast<int>(row.size()), total, blockW);
int x = left;
for (std::size_t k = 0; k < row.size(); ++k) {
const int w = deckGroupWidth(groups[row[k]]);
boxes[row[k]] = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
x += w;
if (k < gutters.size()) x += gutters[k];
}
y += kDeckGroupH + kDeckRowGap;
}
int sx = left + availWidth - spanTotal;
for (std::size_t i : spanning) {
const int w = deckGroupWidth(groups[i]);
boxes[i] = Rect::ltrb(sx, top, sx + w, top + kDeckSpanningH);
sx += w + kDeckGroupGap;
}
out.groups.reserve(groups.size());
for (std::size_t i = 0; i < groups.size(); ++i) {
out.groups.push_back(layoutGroup(groups[i], boxes[i]));
}
out.height = deckHeight(groups);
return out;
}
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue;
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
if (g.captionRadio.id >= 0 && !g.captionRadio.passive &&
contains(g.captionRadio.box, x, y)) {
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
}
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
@@ -185,7 +273,13 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
}
}
return {}; // inside the box but on fence/padding — a miss (groups never overlap)
if (g.column.id >= 0 && contains(g.column.box, x, y)) {
return {DeckHitKind::Column, g.column.id, -1, false};
}
// Inside the box but on fence/padding — a miss. First-match is exact while the boxes
// are disjoint, which they are at every width the row block fits; under the sub-floor
// overrun an overrunning row can reach the spanning deck and the row group answers.
return {};
}
return {};
}
+70 -30
View File
@@ -1,18 +1,8 @@
// knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free
// like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of
// action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
// param_slider's — a knob cell here is just a rect the shell composes it into.
//
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob
// row of equal-width cells (knob centered, label band beneath). A group may also place one
// two-segment toggle in the knob row after its cells. Groups that must keep stable
// geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows
// neighbouring groups.
//
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
// that does not fit the remaining width starts a new row (whole groups only, never
// split); the first group of a row always places even if wider than the row.
// param_slider's — a knob cell here is just a rect the shell composes it into. Group/row
// composition and the justification law are this directory's own CLAUDE.md's to describe.
#pragma once
@@ -37,8 +27,10 @@ inline constexpr int kDeckGroupPadY = 4; // group box vertical inner paddin
inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
inline constexpr int kDeckRowGap = 8; // gap between the deck's two categorical rows,
// and between the spanning deck's stacked slots
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
inline constexpr int kDeckColumnGap = 8; // the spanning deck's cell column -> its readout column
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
@@ -46,6 +38,15 @@ inline constexpr int kDeckInnerDialSize = 20;
// One group box: padding + caption + gap + cell row + padding.
inline constexpr int kDeckGroupH =
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
// The spanning deck's box: it stands across both categorical rows AND the seam between them,
// which is what lets its two cell slots land on the two rows' own knob baselines.
inline constexpr int kDeckSpanningH = 2 * kDeckGroupH + kDeckRowGap;
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice
// IS, Contour is how it moves over time, Spanning is what happens after the mixer. Declared
// here rather than with the group inventory because the LAYOUT is what reads it; which group
// sits in which row is deck_groups' deckRowFor.
enum class DeckRow { Sound, Contour, Spanning };
// --- The deck's width budget at the editor's floor ------------------------------------
// DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a
@@ -53,10 +54,14 @@ inline constexpr int kDeckGroupH =
// from the first two — kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2*kPad — and the
// identity is asserted in test_deck_groups.cpp rather than coded, so the allocator keeps no
// include edge to this header.
inline constexpr int kDeckRowBlockW = 1020; // the block both categorical rows justify inside
// 1028 is the width at which the justification law puts BOTH rows' filter groups on the same
// right edge (x = 640 block-relative) AND divides row 1's slack into three equal gutters. The
// narrower 1020 delivered neither: 40 px over three gutters is 13⅓, so the law produced
// 14/13/13 and left row 1's filter edge 2 px past row 2's.
inline constexpr int kDeckRowBlockW = 1028; // the block both categorical rows justify inside
inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck, outside the block
// The hard ceiling the floor may not exceed lives beside the floor itself, in sample_bands.h's
// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 90px,
// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 82px,
// the whole width budget for the life of this layout (asserted in test_deck_groups.cpp) — see
// instrument-control-surface.md §1.6 before spending any of it.
@@ -67,15 +72,29 @@ struct DeckToggleDesc {
};
// A single-square corner radio (an exclusive selector across groups, so the group itself
// carries no state). id -1 = absent.
// carries no state). id -1 = absent. `passive` reuses the same slot for a READOUT lamp: the
// hit-test skips it entirely, so the shell cannot accidentally grow a gesture on it.
struct DeckRadioDesc {
int id = -1;
bool passive = false;
};
// A full-height readout column beside a spanning group's cell slots (the output meter). Only
// a Spanning group may carry one — a categorical row's groups have no height to span.
// id -1 = absent.
struct DeckColumnDesc {
int id = -1;
int width = 0;
};
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
// reserves for the caption text (this module does not measure text).
//
// A SPANNING group reads `cellIds` down instead of across: one FIXED kDeckCellW slot per
// declared id, at successive row baselines, reserves included. The run-division law above is
// horizontal only — applied vertically it would stretch a lone knob over the whole box.
struct DeckGroupDesc {
int id = 0; // shell group id (opaque here)
int captionWidth = 60;
@@ -87,6 +106,8 @@ struct DeckGroupDesc {
DeckToggleDesc captionToggle2;
std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above)
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
DeckRow row = DeckRow::Sound;
DeckColumnDesc column; // Spanning groups only; id -1 = none
};
// --- Laid-out geometry ---------------------------------------------------------------
@@ -100,6 +121,12 @@ struct DeckToggleLayout {
struct DeckRadioLayout {
int id = -1;
Rect box;
bool passive = false; // a readout lamp, not a selector — see DeckRadioDesc
};
struct DeckColumnLayout {
int id = -1;
Rect box;
};
struct DeckCellLayout {
@@ -119,34 +146,46 @@ struct DeckGroupLayout {
DeckToggleLayout captionToggle2;
std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent
DeckColumnLayout column; // id -1 when absent (Spanning groups only)
};
struct DeckLayout {
std::vector<DeckGroupLayout> groups;
int rowCount = 0;
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups
int rowCount = 0; // POPULATED categorical rows (0..2). A spanning deck is in neither.
int height = 0; // the tallest thing laid out; 0 for no groups
};
// Width of one group box: the wider of its caption row (caption + gap + toggle) and its
// knob row (cells + gap + row toggle), plus horizontal padding.
// Width of one group box: the wider of its caption row (caption + gap + toggles + radio) and
// its knob row, plus horizontal padding. A Spanning group's knob row is one cell wide plus
// its readout column, because its cells stack.
int deckGroupWidth(const DeckGroupDesc& g);
// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap.
// 0 for an empty list.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
// How many of the two categorical rows carry at least one group (0..2). Independent of width:
// row membership is the group's own property.
int deckRowCount(const std::vector<DeckGroupDesc>& groups);
// Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell
// Total deck height: the categorical rows, or the spanning deck when it is taller. The shell
// bottom-anchors a band of exactly this height.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
int deckHeight(const std::vector<DeckGroupDesc>& groups);
// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's
// rule. Every rect is absolute.
// Lays the groups out from (left, top) within `availWidth`. Every rect is absolute, and
// `groups` comes back in DECK order — the same position as the descriptor it was built from,
// whichever row that descriptor landed in.
//
// Spanning groups are right-anchored at `left + availWidth` and take no part in either row's
// justification; the ROW BLOCK is what remains to their left. Inside the block each row is
// justified SPACE-BETWEEN: groups keep their natural widths and the slack becomes gutters,
// divided equally with the integer residue going to the leftmost ones. Decks are never
// stretched. Below the width the block needs, every gutter sits at kDeckGroupGap and the row
// overflows right rather than wrapping — the shell clamps the window to a floor that fits
// (sample_bands' kEditorMinWidth) via checkSizeConstraint, a host-honoured clamp rather than a
// guarantee, so this degrade is defined and tested rather than assumed impossible.
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth);
// --- Hit-test --------------------------------------------------------------------------
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio };
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Column };
struct DeckHit {
DeckHitKind kind = DeckHitKind::None;
@@ -157,8 +196,9 @@ struct DeckHit {
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
// the caption-row corner radio. Everything else — fence, padding, outside — misses.
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, an
// interactive caption-row corner radio, or a spanning group's readout column. Everything
// else — fence, padding, a PASSIVE radio, outside — misses.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
// The knob FACE a point lands on. id -1 is a miss.
+113
View File
@@ -0,0 +1,113 @@
// master_meter.cpp — see master_meter.h.
#include "core/instrument/ui/master_meter.h"
namespace reasampler::instrument::ui {
bool meterTickNumeralled(int db) {
// Every OTHER 6 dB tick, which is the 0/12/24/36/48/60 set the scale is specified as.
return db % (2 * static_cast<int>(kMeterTickStepDb)) == 0;
}
MeterRects meterRects(const Rect& column, LaneSplit split) {
MeterRects r;
if (column.width < kMeterColumnW || column.height <= 0) return r;
r.labels = Rect::ltrb(column.x, column.y, column.x + kMeterLabelW, column.bottom());
const int fieldLeft = column.x + kMeterLabelW + kMeterLabelGap;
r.field = Rect::ltrb(fieldLeft, column.y, fieldLeft + kMeterFieldW, column.bottom());
if (split == LaneSplit::Single) {
r.barA = r.field;
return r;
}
const int barW = (kMeterFieldW - kMeterBarGap) / 2;
r.barA = Rect::ltrb(fieldLeft, column.y, fieldLeft + barW, column.bottom());
const int bLeft = r.barA.right() + kMeterBarGap;
r.barB = Rect::ltrb(bLeft, column.y, bLeft + barW, column.bottom());
return r;
}
Rect meterNumeralRect(const Rect& labels, int y) {
if (labels.empty()) return {};
int top = y - 5;
if (top < labels.y) top = labels.y;
int bottom = top + 10;
if (bottom > labels.bottom()) {
bottom = labels.bottom();
top = bottom - 10 < labels.y ? labels.y : bottom - 10;
}
return Rect::ltrb(labels.x, top, labels.right(), bottom);
}
int meterDbToY(const Rect& field, double db) {
const double norm = engine::meterNormFromDb(db);
const int y = field.bottom() - static_cast<int>(norm * field.height + 0.5);
if (y < field.y) return field.y;
if (y > field.bottom()) return field.bottom();
return y;
}
MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block,
double elapsedSeconds) {
MasterMeterUi next;
next.left = engine::advanceMeter(prev.left, block.peakL, elapsedSeconds);
next.right = engine::advanceMeter(prev.right, block.peakR, elapsedSeconds);
// ORed in unconditionally. Now that the peaks accumulate, advanceMeter's own >= 0 dBFS
// check sees the same window and would latch too — but the published flag stays the
// definitive one, and it is the half clearMasterBusClip resets.
if (block.clip) {
next.left.clip = true;
next.right.clip = true;
}
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
const double reduction = -engine::meterDbFromLinear(block.minGain);
// Same hold-then-release shape as the peak tick, for the same reason: at the UI period the
// lamp actually runs at, a bare decay retires a catch before it has been drawn twice.
if (reduction >= prev.reductionDb) {
next.reductionDb = reduction;
next.reductionHoldSeconds = engine::kMeterPeakHoldSeconds;
} else {
next.reductionDb = prev.reductionDb;
next.reductionHoldSeconds = prev.reductionHoldSeconds - dt;
if (next.reductionHoldSeconds < 0.0) {
// Spend the overshoot as fall time so the release does not quantize to whichever
// UI frame the hold happened to expire on.
const double fallen =
next.reductionDb - engine::kMeterFallDbPerSecond * -next.reductionHoldSeconds;
next.reductionDb = fallen > reduction ? fallen : reduction;
next.reductionHoldSeconds = 0.0;
}
}
if (next.reductionDb < 0.0) next.reductionDb = 0.0;
return next;
}
bool meterClipped(const MasterMeterUi& m) { return m.left.clip || m.right.clip; }
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev) {
MasterMeterUi next = prev;
next.left = engine::clearMeterClip(prev.left);
next.right = engine::clearMeterClip(prev.right);
return next;
}
engine::MeterState meterSingleLaneState(const MasterMeterUi& m) {
engine::MeterState s;
s.levelDb = m.left.levelDb > m.right.levelDb ? m.left.levelDb : m.right.levelDb;
s.holdDb = m.left.holdDb > m.right.holdDb ? m.left.holdDb : m.right.holdDb;
s.holdRemainingSeconds = m.left.holdRemainingSeconds > m.right.holdRemainingSeconds
? m.left.holdRemainingSeconds
: m.right.holdRemainingSeconds;
s.clip = m.left.clip || m.right.clip;
return s;
}
bool grLampLit(const MasterMeterUi& m) { return m.reductionDb >= kGrLampFloorDb; }
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b) {
return a.left.levelDb == b.left.levelDb && a.right.levelDb == b.right.levelDb &&
a.left.holdDb == b.left.holdDb && a.right.holdDb == b.right.holdDb &&
meterClipped(a) == meterClipped(b) && grLampLit(a) == grLampLit(b);
}
} // namespace reasampler::instrument::ui
+101
View File
@@ -0,0 +1,101 @@
// master_meter.h — the interior of the spanning deck's readout column: the label gutter and
// bar field it divides into, the dB->y map its scale draws against, and the per-instance UI
// state the bars are drawn from. knob_deck hands over the column rect; this lays out inside
// it. Every timed, logged or latched quantity lives here, on the UI thread — the audio thread
// publishes raw block magnitudes and converts nothing.
#pragma once
#include "core/instrument/engine/meter_ballistics.h"
#include "core/instrument/ui/editor_geometry.h"
#include "core/instrument/ui/sample_bands.h" // LaneSplit
namespace reasampler::instrument::ui {
inline constexpr int kMeterLabelW = 22; // the numeral gutter, left of the bars
inline constexpr int kMeterLabelGap = 4;
inline constexpr int kMeterFieldW = 36; // one 36px bar, or two 17px bars kMeterBarGap apart
inline constexpr int kMeterBarGap = 2;
// What the interior consumes, and therefore the width the deck must RESERVE for the column.
// knob_deck's MASTER descriptor reads this rather than restating 62 — §1.2 banks MASTER's
// growth to 236 as the meter's growth room, so this constant is expected to move.
inline constexpr int kMeterColumnW = kMeterLabelW + kMeterLabelGap + kMeterFieldW;
// A tick every 6 dB up the scale; every other one carries a numeral, and 0 dB draws heavier.
inline constexpr double kMeterTickStepDb = 6.0;
// Whether the tick at `db` carries a numeral. The numeral SET is spec-pinned (0, 12, 24,
// 36, 48, 60), so it lives beside the step it is derived from rather than in the painter.
bool meterTickNumeralled(int db);
struct MeterRects {
Rect labels; // the numeral gutter
Rect field; // the whole bar field
Rect barA; // Single: the one wide bar. Stereo: L.
Rect barB; // empty() unless Stereo
};
// `split` is the RESOLVED lane decision resolveLaneSplit already folds (channel mode AND the
// source's channel count), not "is the instrument in stereo mode": a mono source under stereo
// mode is dual-mono, and two identical bars would be a lie. One source, two views, one rule.
// A column narrower than kMeterColumnW yields nothing rather than an overrunning field.
MeterRects meterRects(const Rect& column, LaneSplit split);
// y of `db` inside the bar field — kMeterTopDb at the top edge, kMeterFloorDb at the bottom,
// linear in dB between, clamped outside.
int meterDbToY(const Rect& field, double db);
// The numeral's label rect for the tick at `y`, kept inside the gutter: the floor tick sits ON
// the field's bottom edge, and an unclamped y±5 box would hang below the column.
Rect meterNumeralRect(const Rect& labels, int y);
// The per-instance UI state behind the column. One clip latch per channel (the cap is drawn
// once, over whichever of them tripped).
struct MasterMeterUi {
engine::MeterState left;
engine::MeterState right;
double reductionDb = 0.0; // how far the limiter is pulling gain down; 0 = not working
// The lamp's hold, on the SAME principle (and the same window) as the peak tick's: without
// it a catch smaller than kMeterFallDbPerSecond x the UI period is fully decayed by the
// next frame, so the lamp is dark again after the single repaint the catch landed on.
double reductionHoldSeconds = 0.0;
};
// What the audio thread published SINCE THE LAST READ, in this module's own vocabulary — the
// peaks are a max over every block in that window and minGain a min, so no block is discarded
// unseen between two UI frames.
struct MasterMeterBlock {
double peakL = 0.0;
double peakR = 0.0;
double minGain = 1.0; // the limiter's smallest gain over the window; 1 = no reduction
bool clip = false; // the AUDIO thread's own latch
};
MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block,
double elapsedSeconds);
bool meterClipped(const MasterMeterUi& m);
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev);
// What the ONE bar shows on a single-lane column: the two channels folded per FIELD, not the
// louder channel's whole state. Picking a channel by level would draw a hold tick and a clip
// belonging to whichever won on level — inert while L ≡ R on every path that reaches Single,
// and wrong the moment they diverge.
engine::MeterState meterSingleLaneState(const MasterMeterUi& m);
// Whether two states would DRAW the same, so the UI tick can repaint only on a change and an
// idle editor costs nothing. Compares what the column shows — the bar, the held tick, the cap
// and the lamp — not every stored double.
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b);
// The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs
// a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the
// spec asks only for "a small floor". It is bounded on BOTH sides: raising it hides genuine
// catches, since the limiter's ceiling is only 0.3 dBTP; lowering it turns the lamp into a
// "some sample crossed the ceiling" light, because the gain law is ceiling/peak and so reports
// an arbitrarily small reduction for a peak arbitrarily close to the ceiling.
inline constexpr double kGrLampFloorDb = 0.5;
bool grLampLit(const MasterMeterUi& m);
} // namespace reasampler::instrument::ui
+3 -3
View File
@@ -21,12 +21,12 @@ inline constexpr int kPad = 8;
// this allocator is deliberately independent of the deck (it takes deckHeight as a parameter
// for exactly that reason), so the derivation is asserted in test_deck_groups.cpp — the one
// place that already includes both headers — rather than coded as an include edge.
inline constexpr int kEditorMinWidth = 1190;
inline constexpr int kEditorMinWidth = 1198;
inline constexpr int kEditorMinHeight = 680;
// The hard ceiling the floor above may not exceed; the window itself still grows freely above
// it. A window fact, sibling of kEditorMinWidth/kEditorMinHeight, not a deck one — moved here
// from knob_deck.h for that reason. The gap to the floor (today: 90px) is the deck's whole
// from knob_deck.h for that reason. The gap to the floor (today: 82px) is the deck's whole
// width budget, spent once; the identity is asserted in test_deck_groups.cpp, the one place
// that already includes both this header and knob_deck.h.
inline constexpr int kEditorCeilingWidth = 1280;
@@ -55,7 +55,7 @@ struct SampleBands {
};
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own
// wrapped height (from knob_deck) — the only interior measurement the allocator needs, so
// height (from knob_deck) — the only interior measurement the allocator needs, so
// the deck band is exactly as tall as its content. Pure.
SampleBands computeSampleBands(int w, int h, int deckHeight);
+5 -3
View File
@@ -24,13 +24,15 @@ OverlayArea waveformOverlayArea(const Rect& band) {
return OverlayArea{band.empty() ? Rect{} : band};
}
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels) {
return (stereoMode && sourceChannels >= 2) ? LaneSplit::Stereo : LaneSplit::Single;
}
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels) {
WaveformSurface s;
if (band.empty()) return s;
s.overlay = waveformOverlayArea(band);
const bool twoLanes = stereoMode && sourceChannels >= 2;
const WaveformLanes lanes =
waveformLanes(band, twoLanes ? LaneSplit::Stereo : LaneSplit::Single);
const WaveformLanes lanes = waveformLanes(band, resolveLaneSplit(stereoMode, sourceChannels));
s.upper = lanes.upper;
s.lower = lanes.lower;
// Derived from the resolved lanes, not `twoLanes` — a stereo split's integer division
+11 -3
View File
@@ -11,6 +11,7 @@
#include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, OverlayArea, contains
#include "core/instrument/ui/sample_bands.h" // LaneSplit (resolveLaneSplit's answer)
#include "core/audio/peaks.h" // AudioSample (float), Envelope
namespace reasampler::instrument::ui {
@@ -35,9 +36,16 @@ struct WaveformSurface {
// the band-stack allocator's kWaveformMinHeight floor.
};
// Resolves the surface for a waveform band. Two lanes need BOTH stereo mode and a source
// that has a second channel to show: a mono source under stereo mode is dual-mono, so a
// second lane would be the redundant duplicate single-lane mode exists to avoid.
// THE lane-split decision, free of any pixel geometry: two lanes need BOTH stereo mode and a
// source that has a second channel to show, since a mono source under stereo mode is dual-mono
// and a second lane would be the redundant duplicate single-lane mode exists to avoid. The one
// home of that rule — the meter's bar count is the SAME question and reads it here, rather than
// inferring it from a band rect it has no business knowing about.
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels);
// Resolves the surface for a waveform band, folding the split above and then measuring it
// against the band: WaveformSurface::laneCount can still report 1 for a Stereo split on a band
// too thin to divide, which is a geometry fact and not a second rule.
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels);
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
+35 -2
View File
@@ -12,7 +12,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`,
`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`,
`limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and
`limiter`, `meter_ballistics`, `master_meter`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it.
## Invariants
@@ -107,12 +107,13 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published per block as relaxed atomics (per-channel peak, latched clip, the block's smallest limiter gain). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_interaction.h` — the editor's INTERACTION VOCABULARY: `DragKind` (what a gesture in flight is editing) and `HoverKind`/`HoverTarget` (what the pointer can be over). Split out of `reasampler_editor.h`, which had grown past the ~600-line ceiling with no seam — these two catalogues are produced by the input TUs and read by the paint TUs, and neither is behaviour, which is what makes them a responsibility rather than a bisection. Namespace-scope, so the editor's own members still spell them unqualified. Internal to this TU family, like `editor_internal.h`.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
@@ -124,6 +125,38 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
the very instance whose frame is on the stack. Deferring by one tick is same-thread and
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one.
- **The limiter toggle splits its commit: the sound is inline, the HOST NOTIFICATION arms.**
Its commit needs the host's `restartComponent(kLatencyChanged)`; a host that services that
synchronously runs `setActive(false)`/`setActive(true)`, which rebuilds this instance's voice
state — running that inline from `WM_LBUTTONDOWN` would nest it in a mouse handler. So the
click commits the parameter set, the audio-thread mirror and the latency reader at once, and
`setInstrumentParams` only ARMS a pending restart that `flushLatencyRestart` delivers. The
editor's sync tick is the general drain and sits AFTER the drag guard with the bake (the
restart rebuilds the instance, which mid-drag would yank the edit surface exactly as a reload
would); `setState` flushes at its own tail because it can commit with no editor open, and the
bake's adopt does so only to save a tick — its chain runs from that same tick. The arm is
judged against the LAST ANNOUNCED enable, so toggling back to it inside one tick costs no
restart at all.
**The residual:** between the commit and the flush the host's delay compensation is out of
step with the plugin by `limiterLookaheadSamples` (2 ms — `round(0.002 · rate)`, the
detector's 4-sample group delay INSIDE that budget, not on top), bounded by one 500 ms tick.
Narrowing it further means a second deferral mechanism (a posted window message) rather than
the tick — deliberately not built.
- **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run
BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but
the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the
timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed.
**The published block state is therefore ACCUMULATED, not sampled**: at 48 kHz / 512 frames
~47 blocks elapse per tick, so the processor folds a per-channel max and a min limiter gain
across them and `masterBusMeter()` clears the accumulators as it reads. A plain overwriting
store displayed one block in ~47 and lost the rest — the specified "a peak displays on the
first UI frame after it occurs" is what the fold restores. `masterBusMeter()` is CONSUMING,
so exactly one caller may hold it; the embed strip reads its own non-consuming
`embedActivityLevel()`. **The tick's FIRST read is discarded**, because that caller is the
only consumer: with no editor open the accumulators hold everything since the instance was
created, and advancing off them would open the meter at the session's loudest peak. The clip
latch is not discarded with them — it is a latch the user clears. A meter-rate timer remains a
separate change and is not in.
- The bake's availability probe runs on the SAME tick that paints the button, so the
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
+1 -1
View File
@@ -89,7 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
limiter meter_ballistics bake_hold
limiter meter_accumulate meter_ballistics master_meter bake_hold
file_bytes curve_law stroke_aa
curve_tessellate
bake_plan bake_render bake_reset bake_wire wav_codec)
+2 -2
View File
@@ -75,10 +75,10 @@ double curveExponentFor(int id, const PlaySeconds& play) {
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
// The ONE resolve every paint and hit-test path goes through, so the band stack, the
// chrome interior, and the deck descriptors can never be derived three different ways.
// The deck's own wrapped height is the only interior measurement the allocator needs.
// The deck's own height is the only interior measurement the allocator needs.
FaceLayout fl;
fl.deckDescs = sampleDeckGroups(params_.play.playMode);
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs));
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
return fl;
}
+2 -2
View File
@@ -98,8 +98,8 @@ void ReaSamplerEditor::dragBrowse(int x, int y) {
invalidate();
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
int y) const {
const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y)) return {HoverKind::kBack, -1};
if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1};
+2 -2
View File
@@ -152,8 +152,8 @@ void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) {
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
int y) const {
const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
+2 -2
View File
@@ -114,8 +114,8 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) {
/*addOnEmptySpace=*/false);
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
int y) const {
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1};
if (!contains(pl.curveBox, x, y)) return {};
+26 -2
View File
@@ -64,6 +64,19 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
applyParamControl(hit.id, 0.0, hit.segment);
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);
}
invalidate();
break;
}
default: {
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable,
// and the three env-mode toggles).
@@ -80,6 +93,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
}
return true;
}
if (hit.kind == DeckHitKind::Column) {
// The meter's ONLY gesture: clear the latched clip cap. Both latches go — the audio
// thread's is what the next tick would otherwise re-latch the UI's from.
masterMeter_ = clearMasterMeterClip(masterMeter_);
processor_->clearMasterBusClip();
invalidate();
return true;
}
if (hit.kind == DeckHitKind::Knob) {
// Knobs of a disabled group are drawn but inert.
if (deckKnobDisabled(hit.id)) return true;
@@ -172,13 +193,16 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
invalidate();
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
int y) const {
HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
int y) const {
const Rect& band = fl.bands.decks;
if (!contains(band, x, y)) return {};
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind == DeckHitKind::None) return {};
// The meter reports its own state continuously; a hover on it would only mean "the clip
// cap is clearable", which the cap's presence already says.
if (dh.kind == DeckHitKind::Column) return {};
if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id};
if (dh.kind == DeckHitKind::Knob && dh.inner &&
curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) {
@@ -188,8 +188,8 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
return true;
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x,
int y) {
HoverTarget ReaSamplerEditor::hoverWaveform(const FaceLayout& fl, int x,
int y) {
// Caps only: the cap is the grip, so it is the one thing on the overlay a resting pointer
// can be "on". A hovered mark promotes its own label past the suppression rule.
//
+55
View File
@@ -0,0 +1,55 @@
// editor_interaction.h — the Sample editor's INTERACTION VOCABULARY: what a drag can be
// editing, and what the pointer can be over. Two catalogues of the editor's interactive
// surface, produced by the input TUs and read by the paint TUs; neither is behaviour, which is
// why they are named here rather than buried inside the editor class between its paint and
// layout declarations. Internal to the reasampler_editor TU family, like editor_internal.h.
#pragma once
namespace reasampler::vst {
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their grabbed
// item in the editor's waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored knob
// drag (control in dragParamId_, grab value in dragKnobStartValue_). kSplineNode is the
// overlay's peer of kCurveNode: the same VelocityCurve point drag, over the waveform overlay's
// box and the overlay-active envelope's contour rather than the popup's box and curve.
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kSplineNode, kDeckKnob };
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE.
enum class HoverKind {
kNone,
kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
kBack, // the Browse "back" affordance (returns to Sample)
kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index)
kBrowseConfirm, // the Browse modal "Load" confirm button
kBrowseCancel, // the Browse modal "Cancel" button
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kLoopOff, // the loop enable's Off segment
kLoopOn, // the loop enable's On segment
kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label
kPreview, // the preview-trigger button
kBake, // the resample-bake trigger
kControl, // a knob-deck element (index = control id)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the chrome preview-velocity radial knob
kHoldKnob, // the chrome bake-Hold radial knob
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
kPopupClose, // the curve popup's Close (x) button
};
// `index` disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1
// when not applicable.
struct HoverTarget {
HoverKind kind = HoverKind::kNone;
int index = -1;
bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; }
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
};
} // namespace reasampler::vst
+103 -11
View File
@@ -12,6 +12,8 @@
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state)
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
#include "core/instrument/ui/master_meter.h" // the bus meter's column interior + ballistics
#include "core/instrument/ui/waveform_view.h" // resolveLaneSplit (THE lane-split fold)
#include "shell/instrument/editor_internal.h" // kit adapters + knob face
#include "shell/instrument/reasampler_processor.h"
@@ -24,6 +26,76 @@ using namespace reasampler::instrument::ui; // deck geometry
// are chrome you read once — this is the readout you read while turning something.
constexpr Font kCellLabelFont = Font::Label;
namespace {
// One tick's numeral, in whole dB ("0", "-12"). No unit suffix — the column is 22px wide and
// the scale's unit is stated once, by the caption.
std::string tickLabel(int db) {
char buf[8];
std::snprintf(buf, sizeof(buf), "%d", db);
return std::string(buf);
}
// The MASTER column: dB scale in the label gutter, one or two bars, the held peak tick, and
// the latched clip cap. `split` is the RESOLVED lane decision — see master_meter.h.
void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi& state,
LaneSplit split) {
if (column.width <= 0 || column.height <= 0) return;
const MeterRects m = meterRects(column, split);
// A column narrower than the interior needs yields all-empty rects, which under rect.h's
// contract means suppressed — not a zero-height field to fill, tick twelve times and cap.
if (m.field.empty()) return;
fillSurface(bmp, toKitBox(m.field), Role::BgCell, InteractionState::Rest);
// Scale: a rule every 6 dB, numeralled every 12 with 0 dB heavier — the reference the
// limiter-off case is read against.
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
for (int db = static_cast<int>(instrument::engine::kMeterTopDb);
db >= static_cast<int>(instrument::engine::kMeterFloorDb);
db -= static_cast<int>(kMeterTickStepDb)) {
const int y = meterDbToY(m.field, db);
const bool zero = (db == 0);
LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1,
zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0);
if (meterTickNumeralled(db)) {
kitText(bmp, meterNumeralRect(m.labels, y), tickLabel(db).c_str(), Font::Micro,
Role::TextDim, Align::Right);
}
}
// The bars. A single-lane surface shows ONE bar folding both channels per field
// (meterSingleLaneState) — the two are the same signal there (dual-mono), so two bars would
// be a duplicate rather than a reading.
const LICE_pixel barInk = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel holdInk = toLice(roleColor(Role::TextPrimary));
const auto drawBar = [&](const Rect& bar, const instrument::engine::MeterState& ch) {
if (bar.empty()) return;
const int top = meterDbToY(bar, ch.levelDb);
if (top < bar.bottom()) {
LICE_FillRect(bmp, bar.x, top, bar.width, bar.bottom() - top, barInk, 1.0f, 0);
}
if (ch.holdDb > instrument::engine::kMeterFloorDb) {
// Clamped so the 2px tick cannot hang past the bar when the hold sits on the floor.
const int hold = (std::min)(meterDbToY(bar, ch.holdDb), bar.bottom() - 2);
LICE_FillRect(bmp, bar.x, hold, bar.width, 2, holdInk, 1.0f, 0);
}
};
if (split == LaneSplit::Single) {
drawBar(m.barA, meterSingleLaneState(state));
} else {
drawBar(m.barA, state.left);
drawBar(m.barB, state.right);
}
// The clip cap: latched over the whole field, click to clear.
if (meterClipped(state)) {
LICE_FillRect(bmp, m.field.x, m.field.y, m.field.width, 3,
toLice(roleColor(Role::Warn)), 1.0f, 0);
}
}
} // namespace
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const Rect& deckArea = fl.bands.decks;
if (deckArea.width <= 0 || deckArea.height <= 0) return;
@@ -31,6 +103,12 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const PlaySeconds& play = params_.play;
const bool isMono = (voiceMode_ == VoiceMode::Mono);
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
// The meter's bar count is the SAME resolved decision the waveform's lane split is —
// resolveLaneSplit is the one home of it. Asked directly rather than read back off
// waveformSurface, whose laneCount additionally folds in the waveform BAND's pixel height,
// which decides nothing about how many channels the bus is carrying.
const LaneSplit meterSplit = resolveLaneSplit(channelMode_ == ChannelMode::Stereo,
channelPcmFor(selectedId_).channelCount);
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
@@ -122,9 +200,19 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
}
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
// The gain-reduction lamp. ROUND, where the overlay radios in this same slot are
// square, so it reads as a lamp rather than a control.
if (g.captionRadio.id >= 0 && g.captionRadio.passive) {
const Rect& rb = g.captionRadio.box;
const float r = rb.width / 2.0f - 0.5f;
LICE_FillCircle(bmp, rb.x + rb.width / 2.0f, rb.y + rb.height / 2.0f, r,
toLice(roleColor(grLampLit(masterMeter_) ? Role::Warn
: Role::LineHairline)),
1.0f, 0, true);
}
// The overlay-select radio: filled in the tertiary accent (the colour the overlay
// traces in) when this group's envelope is the one on the waveform, hollow otherwise.
if (g.captionRadio.id >= 0) {
if (g.captionRadio.id >= 0 && !g.captionRadio.passive) {
// overlayEnvForRadio returns kNone for BOTH "not a radio id" and "no selection" —
// a non-radio id must never read as lit just because nothing is selected, so the
// picked env has to be checked against kNone itself, not just matched by equality.
@@ -164,6 +252,15 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
case ParamControl::kFilterEnable:
drawToggle(t, "Off", "On", play.filter.enabled, false);
break;
case ParamControl::kFilterLaw:
drawToggle(t, "Band", "Notch",
play.filter.settings.morphLaw ==
instrument::engine::filter::MorphLaw::HighNotchLow,
!play.filter.enabled);
break;
case ParamControl::kLimiterEnable:
drawToggle(t, "Off", "On", params_.limiterEnabled, false);
break;
case ParamControl::kAmpEnvMode:
drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false);
break;
@@ -176,19 +273,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
default: break;
}
}
// Row toggles: VOICE's Retrig|Legato (live only in Mono) and FILTER's morph law.
// The one row toggle left: VOICE's Retrig|Legato, live only in Mono.
if (g.rowToggle.id >= 0) {
if (static_cast<ParamControl>(g.rowToggle.id) == ParamControl::kFilterLaw) {
drawToggle(g.rowToggle, "Band", "Notch",
play.filter.settings.morphLaw ==
instrument::engine::filter::MorphLaw::HighNotchLow,
!play.filter.enabled);
} else {
drawToggle(g.rowToggle, "Retrig", "Legato",
monoTrigger_ == MonoTrigger::Legato, !isMono);
}
drawToggle(g.rowToggle, "Retrig", "Legato",
monoTrigger_ == MonoTrigger::Legato, !isMono);
}
if (g.column.id >= 0) paintMeterColumn(bmp, g.column.box, masterMeter_, meterSplit);
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
// The predicate is the input side's, so the drawn state and the inert grab agree.
for (const DeckCellLayout& c : g.cells) {
+33
View File
@@ -107,8 +107,41 @@ void ReaSamplerEditor::onSyncTimer() {
// rebuild the instrument and repaint under the cursor, yanking the edit — the next
// tick picks up the change after release.
if (!processor_) return;
// 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
// tick is only telling the host to re-ask for the latency. Past the drag guard with the bake,
// because the restart makes the host rebuild this instance — mid-drag that would yank the
// edit surface exactly as a reload would. Unconditional: it self-cancels when nothing is
// armed, so no commit site has to remember to ask for it.
processor_->flushLatencyRestart();
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
// can never be enabled on one tick and refuse on the next.
const bool available = bakeAvailable(processor_->bridge());
+91 -50
View File
@@ -96,10 +96,6 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// retired-slot free is single-writer; never taken on the audio thread.
std::lock_guard<std::mutex> lock(reloadMutex_);
// Mint this reload's generation number first so the built instrument is stamped
// before publishing.
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// 1. Self-contained resolution: the instance-owned refs table is the source of truth.
// The live bank blob, when readable, is folded in first (refreshRefsFromBank — the
// browser's copy-the-ref-in + recapture-sync mechanism), but its absence changes
@@ -124,17 +120,6 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
// capture's own channel count below, before the decode.
ChannelMode mode = channelMode();
// Snapshot the voice-system parameters once — baked into the built engine's
// construction (immutable config; a later change rebuilds).
int builtVoiceCount = kDefaultVoiceCount;
VoiceMode builtVoiceMode = VoiceMode::Poly;
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
{
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
std::string resolvedId;
std::unique_ptr<LoadedInstrument> built;
@@ -177,17 +162,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
}
}
if (havePlayable) {
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
// pre-sized here so process()-time note-on never allocates. Floored at 2 so a
// valid window is always a real ring, covering a pathological host rate <= 0 too.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
if (havePlayable) built = buildInstrumentLocked(std::move(sample));
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
// header). A null `built` (no ref / unreadable WAV) installs silence while any
@@ -246,6 +221,11 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
// ONE reload for the re-point and the reset together: it decodes the new file and
// publishes the neutral parameters in the same swap.
reloadInstrument();
// The bake's reset may have flipped the limiter; delivering the restart here rather than
// leaving it to the editor's tick is a LATENCY improvement, not a correctness one — the
// bake chain only ever runs from that tick, so the arm would be drained on the next one
// anyway. At the tail for the same reason setState's is (see there).
flushLatencyRestart();
}
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
@@ -288,8 +268,7 @@ void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
}
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
// REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the
// one safety-critical swap dance (see the header's drain-slot proof).
// REQUIRES reloadMutex_ held (see the header's drain-slot proof).
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
graveyard_.erase(
std::remove_if(graveyard_.begin(), graveyard_.end(),
@@ -297,6 +276,10 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
return e->installedAt < seen;
}),
graveyard_.end());
// Any publish supersedes the deactivate's park: whatever is installed here is the newer
// truth, and a park surviving it would be reinstalled over this instrument at the next
// activation (the setState-while-inactive case).
dormantSample_.reset();
LoadedInstrument* prev = live_.exchange(built.release());
// A bake's reset gain lands here rather than at its call site, so the gain and the
// capture it belongs to become audible to process() within one block of each other.
@@ -313,6 +296,34 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
}
std::unique_ptr<LoadedInstrument>
ReaSamplerProcessor::buildInstrumentLocked(SampleData sample) {
// REQUIRES reloadMutex_ held. The ONE construction of a playable snapshot, so the three
// callers (full reload, voice-param rebuild, reactivation) cannot drift on the generation
// stamp, the voice-system snapshot or the ring size.
int builtVoiceCount = kDefaultVoiceCount;
VoiceMode builtVoiceMode = VoiceMode::Poly;
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
{
// Baked into the engine's construction (immutable config; a later change rebuilds).
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs), pre-sized
// here so process()-time note-on never allocates. Floored at 2 so a valid window is always
// a real ring, covering a pathological host rate <= 0 too. Re-derived per build, so a
// reactivation after the host changed its rate gets a ring sized for the new one.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
return std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
void ReaSamplerProcessor::rebuildVoiceEngine() {
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds
// the engine around a copy of the live instrument's already-decoded SampleData — no
@@ -320,31 +331,61 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
std::lock_guard<std::mutex> lock(reloadMutex_);
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
if (!cur) return; // nothing loaded: the new params bake into the next real reload.
int builtVoiceCount = kDefaultVoiceCount;
VoiceMode builtVoiceMode = VoiceMode::Poly;
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
{
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
builtVoiceCount = voiceCount_;
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
// Same Preserve-window derivation as reloadInstrument.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
// Deep-copy the decoded sample: safe to read concurrently with process() because the
// SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
// freed.
SampleData sample = cur->sample;
auto built = std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
publishBuiltLocked(std::move(built));
publishBuiltLocked(buildInstrumentLocked(cur->sample));
}
bool ReaSamplerProcessor::resumeDormantInstrument() {
// Off the audio thread (setActive only). The reactivation half of the lifetime split: the
// voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is.
std::lock_guard<std::mutex> lock(reloadMutex_);
// A publish that landed while inactive (setState's reload, a bake's adopt) IS the
// activation state — its voices have never rendered, and it has already superseded the
// park. Rebuilding here would displace a correct instrument into the drain slot.
if (live_.load(std::memory_order_acquire)) return true;
if (!dormantSample_) return false;
// The activation is still where a bank change made with NO EDITOR OPEN is picked up:
// pollBankSync, the only other route to either of the two calls below, runs off the
// editor's sync tick and nothing else. Both are a GetProjExtState plus a parse — no disk
// and no decode, which is what lets them stay on a path whose whole point is skipping
// those two.
const std::string selId = selectedSampleId();
const std::vector<std::string> ids = referencedSampleIds(selId);
SampleRefs refs;
bool sourceMoved = false;
{
std::optional<std::string> banksJson =
bridge_.readReasamplerExtState(kProjExtBanksKey);
std::lock_guard<std::mutex> rl(refsMutex_);
if (banksJson) {
const SelectedSample* before = findRef(sampleRefs_, selId);
const std::optional<SelectedSample> was =
before ? std::optional<SelectedSample>(*before) : std::nullopt;
refreshRefsFromBank(sampleRefs_, *banksJson, ids);
const SelectedSample* now = findRef(sampleRefs_, selId);
sourceMoved = !was || !now || !sameDecodeSource(*was, *now);
}
refs = sampleRefs_;
}
// A recapture that landed while this instance was inactive makes the park the WRONG audio,
// and refreshing the refs without re-decoding would leave the table naming one file while
// the voices play another. Hand back to the full reload, which decodes the new one.
if (sourceMoved) return false;
// MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
// second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a
// throwing build leaves nothing to resume — the next activation then takes the reload
// rather than publishing an empty sample as permanent silence.
SampleData resumed = std::move(*dormantSample_);
dormantSample_.reset();
publishBuiltLocked(buildInstrumentLocked(std::move(resumed)));
// The prune-protection republish reloadInstrument owes on every publish: it is also what
// heals an rsusage_ key whose write failed when this instance last set its state.
publishUsage(refs, ids);
return true;
}
void ReaSamplerProcessor::retireIdleDrain() {
+53 -18
View File
@@ -86,6 +86,10 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
// A new blob is new facts — the legacy lift gets one fresh run per restored state.
legacyLiftConcluded_.store(false, std::memory_order_relaxed);
reloadInstrument();
// This caller has no editor to flush for it. At the TAIL on purpose: a host that services the
// restart synchronously deactivates/reactivates, and our setActive(true) resumes or reloads
// against the refs above, which are only fully restored once this function has run to here.
flushLatencyRestart();
return kResultOk;
}
@@ -149,25 +153,50 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
}
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
bool limiterFlagChanged = false;
{
std::lock_guard<std::mutex> lock(paramsMutex_);
limiterFlagChanged = (params_.limiterEnabled != params.limiterEnabled);
params_ = params;
}
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
// funnels through here, so mirroring the limiter flag at this one point is what keeps the
// audio thread's copy and the latency report from ever lagging what is persisted, and
// requesting the restart here (not just from setLimiterEnabled) is what keeps the host's
// PDC from lagging it too. Coalesced: writing the value already held requests nothing.
// audio thread's copy and the latency report from ever lagging what is persisted. The MIRROR
// is inline, because that is the sound the user clicked for; the host notification is not,
// because this funnel is reachable from inside a mouse handler and restartComponent is not
// safe there (see this directory's CLAUDE.md).
publishLimiterEnabled(params.limiterEnabled);
if (limiterFlagChanged && componentHandler) {
// The SDK requires this on the UI thread and answers getLatencySamples only after the
// host's own deactivate/reactivate — so the flag above is already committed by the time
// the host asks. This is a kLatencyChanged restart with the bus untouched, NOT the
// retired per-mode kIoChanged bus renegotiation (see initialize()); do not conflate.
componentHandler->restartComponent(kLatencyChanged);
}
// Armed AFTER the mirror, so getLatencySamples already answers the new value for the whole
// window the arm stays outstanding. Compared against the last ANNOUNCED enable rather than
// against the previous parameter set: off->on->off inside one tick ends at the latency the
// host already knows, and a restart rebuilds the instance, so announcing a latency that
// never changed is pure cost. Any number of changes before one flush still cost at most one
// restart, and this store is the only one that raises OR lowers the arm.
latencyRestartPending_.store(
params.limiterEnabled != latencyAnnounced_.load(std::memory_order_relaxed),
std::memory_order_release);
}
void ReaSamplerProcessor::flushLatencyRestart() {
// Cleared only once it can actually be delivered — an arm raised before the host connected
// its handler waits for a later flush instead of evaporating.
if (!componentHandler) return;
if (!latencyRestartPending_.exchange(false, std::memory_order_acquire)) return;
// Latched BEFORE the call: a host that services the restart synchronously re-enters this
// object inside it, so the next commit must compare against the value the host is about to
// read, not against the one it held before.
const bool previouslyAnnounced = latencyAnnounced_.exchange(
limiterEnabled_.load(std::memory_order_relaxed), std::memory_order_relaxed);
// The SDK requires this on the UI thread and answers getLatencySamples only after the host's
// own deactivate/reactivate — so the flag is long committed by the time the host asks. This
// is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode kIoChanged
// bus renegotiation (see initialize()); do not conflate.
if (componentHandler->restartComponent(kLatencyChanged) == kResultOk) return;
// A refused restart leaves the host's delay compensation on the OLD value, so the latch has
// to come back off it: announcing a value the host never took would let a later toggle BACK
// to that value arm nothing, stranding the host's view permanently. Re-armed instead, which
// costs one retry per drain in a host that always refuses. The SDK documents no refusal
// semantics, so whether any host returns non-kResultOk here is `[verify — DAW]`.
latencyAnnounced_.store(previouslyAnnounced, std::memory_order_relaxed);
latencyRestartPending_.store(true, std::memory_order_release);
}
void ReaSamplerProcessor::publishLimiterEnabled(bool on) {
@@ -176,18 +205,24 @@ void ReaSamplerProcessor::publishLimiterEnabled(bool on) {
}
void ReaSamplerProcessor::setLimiterEnabled(bool on) {
// Thin wrapper: setInstrumentParams is the one funnel that mirrors the flag AND requests
// the restart, so every writer of the parameter set — this one included — agrees.
// Rebased off the PROCESSOR's copy rather than taking a caller-supplied set: an editor
// snapshot may carry edits it has not committed, and writing one back here would clobber
// them. Everything else is setInstrumentParams', the one funnel every writer agrees through.
InstrumentParams params = instrumentParams();
params.limiterEnabled = on;
setInstrumentParams(params);
}
MasterBusMeter ReaSamplerProcessor::masterBusMeter() const {
MasterBusMeter ReaSamplerProcessor::masterBusMeter() {
MasterBusMeter m;
m.peakL = meterPeakL_.load(std::memory_order_relaxed);
m.peakR = meterPeakR_.load(std::memory_order_relaxed);
m.minGain = meterMinGain_.load(std::memory_order_relaxed);
// Consuming: each read takes the window and reinstalls its identity element, which is what
// starts the next one. The audio thread's fold is an unconditional CAS against exactly that
// (meter_accumulate.h owns the argument), so a fold interleaved with these exchanges lands
// in one window or the other and is never dropped between them.
m.peakL = instrument::engine::consumePeak(meterPeakL_);
m.peakR = instrument::engine::consumePeak(meterPeakR_);
m.minGain = instrument::engine::consumeMinGain(meterMinGain_);
// NOT consumed: the clip is a latch the user clears, not a window.
m.clip = meterClip_.load(std::memory_order_relaxed);
return m;
}
+8 -44
View File
@@ -20,6 +20,7 @@
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine)
#include "core/instrument/ui/master_meter.h" // MasterMeterUi (the bus meter's UI-side state)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks)
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
@@ -27,6 +28,7 @@
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
#include "shell/instrument/editor_interaction.h" // DragKind / HoverKind / HoverTarget
#ifdef _WIN32
#include <windows.h>
@@ -78,15 +80,6 @@ private:
// picker overlaid on it.
enum class View { kSample, kBrowse };
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
// kSplineNode is the overlay's peer of kCurveNode: the same VelocityCurve point drag, over
// the waveform overlay's box and the overlay-active envelope's contour rather than the
// popup's box and curve.
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kSplineNode, kDeckKnob };
// Which envelope the waveform overlay is drawing and editing. The selection type and its
// whole state machine are the pure deck_groups module's; this alias keeps the shell's
// spelling.
@@ -114,41 +107,6 @@ private:
// What those four marks are showing — see pickedMarkers.
using SetupMarkers = instrument::ui::LoopMarks;
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
// disambiguates within a kind (tab ordinal, visible-card index, control-row id); -1 when
// not applicable.
enum class HoverKind {
kNone,
kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
kBack, // the Browse "back" affordance (returns to Sample)
kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index)
kBrowseConfirm, // the Browse modal "Load" confirm button
kBrowseCancel, // the Browse modal "Cancel" button
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kLoopOff, // the loop enable's Off segment
kLoopOn, // the loop enable's On segment
kWaveMark, // a waveform overlay mark (index = WaveMark ordinal); promotes its label
kPreview, // the preview-trigger button
kBake, // the resample-bake trigger
kControl, // a knob-deck element (index = control id)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the chrome preview-velocity radial knob
kHoldKnob, // the chrome bake-Hold radial knob
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
kPopupClose, // the curve popup's Close (x) button
};
struct HoverTarget {
HoverKind kind = HoverKind::kNone;
int index = -1;
bool operator==(const HoverTarget& o) const { return kind == o.kind && index == o.index; }
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
};
// The three-band stack for the current client size, plus the chrome interior. Every
// paint/hit-test path derives both through this one call so draw and hit-test can never
// disagree about where a band is.
@@ -498,6 +456,12 @@ 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.
instrument::ui::MasterMeterUi masterMeter_;
unsigned long long meterTickMs_ = 0;
// Hover state (transient, never persisted).
HoverTarget hover_; // the interactive element under the pointer
#ifdef _WIN32
+41 -21
View File
@@ -79,34 +79,52 @@ tresult PLUGIN_API ReaSamplerProcessor::terminate() {
delete live_.exchange(nullptr);
delete draining_.exchange(nullptr);
graveyard_.clear();
dormantSample_.reset();
return SingleComponentEffect::terminate();
}
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// Activating: build from the currently-selected sample so the first block after
// activation can play. Deactivating: process is now guaranteed stopped, so this is
// the safe point to reclaim the graveyard. Main/UI-thread call.
// Activation governs ONE thing — whether the audio thread may run. The decoded
// SampleData has its own lifetime and survives the cycle (dormantSample_); only the
// voice state is built and destroyed here. Main/UI-thread call.
//
// Neither branch is idempotent on its own — a repeated deactivate would park an empty
// optional over a still-valid sample, and a repeated activate would reset the limiter over
// a live delay line — and the SDK base is an empty stub that guards neither.
// `[verify — DAW]` whether any host actually repeats the call.
if (static_cast<bool>(state) == active_) return kResultOk;
active_ = static_cast<bool>(state);
if (state) {
// Resolves + decodes from the instance-owned refs — no bank read needed, so it
// plays regardless of PROJEXTSTATE parse state. Also doubles as the non-editor
// legacy-lift trigger for a pre-v10 blob: reloadInstrument's opportunistic
// refreshRefsFromBank copies refs in when the bank blob is readable by now.
// Rebuild the voices around the sample the deactivate parked — no WAV decode — so a
// host-driven cycle (a kLatencyChanged restart, an offline-render bracket) costs no
// disk read. With nothing to activate from, the full reload runs: it resolves +
// decodes from the instance-owned refs (no bank read required, so it plays regardless
// of PROJEXTSTATE parse state) and doubles as the non-editor legacy-lift trigger for a
// pre-v10 blob, whose opportunistic refreshRefsFromBank copies refs in when the bank
// blob is readable by now. Nothing can shadow that lift: a pre-v10 blob resolves
// nothing, so it has neither a parked sample nor a published instrument.
// Residual load-order race (DAW-verifiable only): if the host activates before the
// project's ext-state parses, nothing retries until the next activation or editor
// tick — open a pre-v10 instrument once after upgrading if it restores silent.
reloadInstrument();
if (!resumeDormantInstrument()) reloadInstrument();
// The host performs this deactivate/reactivate whenever it acts on a kLatencyChanged
// request, so the limiter starts each activation with an empty delay line and snapped
// to its persisted state — no transition mute, because there is nothing sounding to be
// continuous with once the block above has destroyed every voice.
// to its persisted state — no transition mute, because the deactivate destroyed every
// voice and the rebuild above starts with none sounding.
limiter_.reset();
} else {
std::lock_guard<std::mutex> lock(reloadMutex_);
// Free EVERYTHING, including live_: its voices are frozen mid-flight, and if it
// survived deactivation the reactivate reload would displace it into the drain
// slot, resurrecting stale sustained voices as ghosts. Reactivation rebuilds from
// scratch above, so nothing is lost.
delete live_.exchange(nullptr);
// Park the decoded PCM; free EVERYTHING else, live_ included. Its voices are frozen
// mid-flight, and if it survived deactivation the reactivate would displace it into
// the drain slot, resurrecting stale sustained voices as ghosts.
std::unique_ptr<LoadedInstrument> dying(live_.exchange(nullptr));
// Moved out ahead of the destruction: `sample` is declared before `engine`, so the
// engine — the only holder of a reference to it — dies first and never reads the
// moved-from value. Empty when nothing was loaded, which is what routes the next
// activation back through the full reload.
dormantSample_ = dying ? std::optional<SampleData>(std::move(dying->sample))
: std::nullopt;
dying.reset();
delete draining_.exchange(nullptr);
graveyard_.clear();
}
@@ -309,7 +327,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
}
// The chain's last stage before the bus, after the gain above.
const float minGain = limiter_.process(ch0, ch1, frames);
meterMinGain_.store(minGain, std::memory_order_relaxed);
instrument::engine::foldMinGain(meterMinGain_, minGain);
// Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
for (int32 ch = 2; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) {
@@ -324,8 +342,9 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
if (a0 > peakL) peakL = a0;
if (a1 > peakR) peakR = a1;
}
meterPeakL_.store(peakL, std::memory_order_relaxed);
meterPeakR_.store(peakR, std::memory_order_relaxed);
instrument::engine::foldPeak(meterPeakL_, peakL);
instrument::engine::foldPeak(meterPeakR_, peakR);
advisoryPeak_.store(peakL > peakR ? peakL : peakR, std::memory_order_relaxed);
if (peakL >= 1.f || peakR >= 1.f) meterClip_.store(true, std::memory_order_relaxed);
} else if (ch0) {
// Mono: render into channel 0, replicate to any extra channels (defensive).
@@ -354,14 +373,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
}
}
const float minGain = limiter_.process(ch0, nullptr, frames);
meterMinGain_.store(minGain, std::memory_order_relaxed);
instrument::engine::foldMinGain(meterMinGain_, minGain);
float peak = 0.f;
for (int32 i = 0; i < frames; ++i) {
const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i];
if (a > peak) peak = a;
}
meterPeakL_.store(peak, std::memory_order_relaxed);
meterPeakR_.store(peak, std::memory_order_relaxed);
instrument::engine::foldPeak(meterPeakL_, peak);
instrument::engine::foldPeak(meterPeakR_, peak);
advisoryPeak_.store(peak, std::memory_order_relaxed);
if (peak >= 1.f) meterClip_.store(true, std::memory_order_relaxed);
for (int32 ch = 1; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) {
+82 -24
View File
@@ -22,6 +22,7 @@
#include "core/instrument/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::vst {
@@ -128,16 +129,20 @@ public:
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
void** obj) override;
// The embedded-strip activity level (0..1) for the embed shell, UI thread. The loudest of
// the two published bus peaks — one publication serves the strip and the meter.
// The embedded-strip activity level (0..1) for the embed shell, UI thread. The last block's
// loudest channel — a non-consuming read, so it stays correct however often the editor's
// meter drains its own accumulators (or never does, with no editor open).
double embedActivityLevel() const {
const float l = meterPeakL_.load(std::memory_order_relaxed);
const float r = meterPeakR_.load(std::memory_order_relaxed);
return static_cast<double>(l > r ? l : r);
return static_cast<double>(advisoryPeak_.load(std::memory_order_relaxed));
}
// What the audio thread published about the output bus last block. UI thread.
MasterBusMeter masterBusMeter() const;
// What the audio thread published since the LAST call: peaks maxed and minGain minimised
// across every block in that window. CONSUMING — it resets the accumulators as it reads, so
// exactly one reader may call it, and that reader is the editor's meter tick. Two live
// editors would each consume half the windows and both meters would read low; the single
// reader rests on the HOST calling createView once per instance `[verify — DAW]`, not on
// anything this plugin enforces — createView allocates a new editor on every call. UI thread.
MasterBusMeter masterBusMeter();
void clearMasterBusClip();
// Resolves the selection against the instance-owned SampleRefs, decodes its WAV
@@ -235,15 +240,24 @@ public:
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
// The master-bus limiter's single enable (persisted in the parameter set). UI thread only:
// a thin wrapper over setInstrumentParams, the one funnel that both mirrors the flag and
// requests the host's kLatencyChanged restart, which the SDK requires be issued from the UI
// thread and which process() must therefore never trigger. Setting the value it already
// holds is a no-op, so repeated clicks on one segment cost no restart.
// a thin wrapper over setInstrumentParams, the one funnel that mirrors the flag onto the
// audio thread INLINE — the sound follows the click — and only ARMS the host's latency
// restart. Setting the value it already holds is a no-op, so repeated clicks on one segment
// cost no restart.
bool limiterEnabled() const {
return limiterEnabled_.load(std::memory_order_relaxed);
}
void setLimiterEnabled(bool on);
// Delivers the armed kLatencyChanged restart, at most once per armed window, and does
// nothing when none is armed. Split off the commit because the SDK requires this on the UI
// thread AND a host may service it synchronously — deactivate/reactivate, which reaches our
// setActive(true) — so it must never run nested inside a mouse handler. The editor's sync
// tick is the general drain; setState flushes at its own tail because it can commit with no
// editor open, and adoptBakedCapture does so only to save a tick (its chain runs from that
// same tick, so its arm would drain on the next one regardless).
void flushLatencyRestart();
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
@@ -274,6 +288,20 @@ private:
// swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
void rebuildVoiceEngine();
// The reactivation half of the activation/decode lifetime split (see dormantSample_):
// folds the live bank blob into the refs, rebuilds the voice state around the parked
// sample, publishes it through the same drain-slot swap, and republishes usage. True also
// when a publish landed while inactive, which needs no rebuild. False when there is nothing
// to activate from, or when that fold moved what the park was decoded from — the caller
// then falls back to a full reload, which decodes the new file and is also where the pre-v10
// legacy lift lives. Off the audio thread only.
bool resumeDormantInstrument();
// Builds a playable snapshot around `sample` at the current voice-system parameters and
// host rate, stamped with a fresh generation. Requires reloadMutex_ held; the ONE
// construction site shared by the reload, the voice-param rebuild and the reactivation.
std::unique_ptr<LoadedInstrument> buildInstrumentLocked(SampleData sample);
// Pre-v10 legacy-lift gate: true when a lift attempt this tick could make progress
// (see legacyLiftConcluded_). Off the audio thread only (bridge read + bank parse).
bool legacyLiftShouldRun();
@@ -281,16 +309,16 @@ private:
// Publishes `built` (null = install silence) into live_: prunes the graveyard by the
// last process()-published generation, swaps `built` into live_, displaces the previous
// live into the drain slot, and parks the evicted drain instrument in the graveyard.
// Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine.
// Requires reloadMutex_ held — shared by reloadInstrument, rebuildVoiceEngine and
// resumeDormantInstrument.
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// Publishes a silent block to the meter. EVERY process() path that emits no audio calls
// this, or the bar freezes at the last peak it saw. The clip latch is deliberately not
// touched — it survives silence until the user clears it.
// Publishes a silent block. EVERY process() path that emits no audio calls this. It clears
// only the ADVISORY level, which is a last-block reading: the meter accumulators need
// nothing here, because a block that emitted no audio contributes no peak and no gain
// reduction, and the UI's own read is what resets them.
void publishSilentMeterBlock() {
meterPeakL_.store(0.f, std::memory_order_relaxed);
meterPeakR_.store(0.f, std::memory_order_relaxed);
meterMinGain_.store(1.f, std::memory_order_relaxed);
advisoryPeak_.store(0.f, std::memory_order_relaxed);
}
// Mirrors the persisted limiter enable onto the audio thread and the latency reader. Called
@@ -359,6 +387,17 @@ private:
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
std::optional<double> gainAtNextPublish_;
// The decoded PCM parked across a deactivate, so an activation cycle costs no disk read
// and no WAV decode: activation is "the audio thread may run", not "the sample is
// rebuilt". Holds a value only while inactive, and any publish drops it (publishBuiltLocked
// owns why). Voice state is deliberately NOT parked with it; see setActive. Guarded by
// reloadMutex_.
std::optional<SampleData> dormantSample_;
// Whether the host has us active, so setActive can treat a repeat of the state it already
// holds as a no-op (it owns why). Main/UI thread only, like setActive itself.
bool active_ = false;
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread.
std::mutex selectionMutex_;
@@ -455,14 +494,33 @@ private:
// getLatencySamples answers from.
instrument::engine::Limiter limiter_;
std::atomic<bool> limiterEnabled_{false};
// Raised (and lowered) by the commit funnel from the difference between the enable and
// latencyAnnounced_, cleared by flushLatencyRestart on delivery and re-raised there if the
// host refuses. A bool and not a count on purpose: the host is being told to re-ASK, so N
// changes before one flush need exactly one restart, and whatever getLatencySamples answers
// at that moment is the truth announced.
std::atomic<bool> latencyRestartPending_{false};
// The enable the host has ACCEPTED — false initially, which is what an instance that has
// announced nothing reports. Every arm is judged against this, so a change that returns to
// the announced state costs no restart.
std::atomic<bool> latencyAnnounced_{false};
// What the audio thread publishes about the output bus each block, relaxed peaks, the
// latched clip, and the limiter's smallest gain. No dB, no ballistics, no hold timer here;
// the UI runs those off these values and its own elapsed time.
std::atomic<float> meterPeakL_{0.f};
std::atomic<float> meterPeakR_{0.f};
std::atomic<float> meterMinGain_{1.f};
// What the audio thread publishes about the output bus each block, relaxed. The peaks and
// minGain ACCUMULATE (max / min) across every block since the UI last read, and
// masterBusMeter() consumes them as it reads — the fix for a bar that displayed roughly one
// block in fifty. The folds and the identity elements below are meter_accumulate's; no dB,
// no ballistics, no hold timer here — the UI runs those off these values and its own
// elapsed time.
std::atomic<float> meterPeakL_{instrument::engine::kMeterPeakIdentity};
std::atomic<float> meterPeakR_{instrument::engine::kMeterPeakIdentity};
std::atomic<float> meterMinGain_{instrument::engine::kMeterGainIdentity};
std::atomic<bool> meterClip_{false};
// The embed strip's activity level: the LAST block's loudest channel, plainly overwritten.
// Separate from the accumulators above because it answers a different question ("how loud
// is it now") and has a different reader — sharing one would make each reader's reset
// silently truncate the other's window.
std::atomic<float> advisoryPeak_{0.f};
};
} // namespace reasampler::vst