Reflow the deck into two categorical rows plus a double-height MASTER bus deck
Row membership is now the group's own property, not a wrap outcome. FILTER's Band|Notch moves to its caption slack, which is what makes the sound row fit. MASTER gains the limiter toggle, the output meter and the GR lamp.
This commit is contained in:
@@ -311,7 +311,7 @@ anything for a trigger shape.
|
||||
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
|
||||
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws.
|
||||
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
|
||||
|
||||
### `map/`
|
||||
|
||||
@@ -339,7 +339,7 @@ anything for a trigger shape.
|
||||
- `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
|
||||
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
|
||||
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n−1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
||||
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
|
||||
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
|
||||
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
|
||||
@@ -357,7 +357,8 @@ anything for a trigger shape.
|
||||
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
|
||||
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||
labels for them.
|
||||
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
|
||||
- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveformSurface` folds** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Composes `engine/meter_ballistics` per channel and adds the gain-reduction lamp's own decay; the audio thread's clip flag is ORed in because it is the only latch that sees the blocks between two UI frames. `meterDrawEqual` is what lets the UI tick repaint on change alone.
|
||||
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the −92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164.
|
||||
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
|
||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
|
||||
@@ -57,6 +57,16 @@ reasampler_test(envelope_edit LINK envelope_edit)
|
||||
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(knob_deck LINK knob_deck)
|
||||
|
||||
# The spanning deck's meter column, split from knob_deck on the axis sample_chrome has to
|
||||
# sample_bands: that says where the column is, this lays out inside it. sample_bands is PUBLIC
|
||||
# for LaneSplit — the bar count is the SAME resolved decision the waveform's lane split is.
|
||||
reasampler_pure_library(master_meter
|
||||
SOURCES master_meter.cpp
|
||||
LINK PUBLIC editor_geometry sample_bands meter_ballistics)
|
||||
# waveform_view is linked for the test only: proving the bar count is not a second rule takes
|
||||
# the real waveformSurface fold, over channel mode x source channel count.
|
||||
reasampler_test(master_meter LINK master_meter waveform_view)
|
||||
|
||||
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
|
||||
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
|
||||
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
|
||||
|
||||
@@ -11,9 +11,14 @@ 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 144px of slack, not the SOUND row.
|
||||
constexpr int kEnvModeSegW = 23;
|
||||
|
||||
// The output meter's column, right of MASTER's cell slots. 62 + the 60px cell + the gap + the
|
||||
// group's own padding is exactly kDeckSpanningW.
|
||||
constexpr int kMasterMeterW = 62;
|
||||
} // namespace
|
||||
|
||||
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
|
||||
@@ -63,7 +68,9 @@ std::vector<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 +133,19 @@ 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 90px budget on a control nobody has named.
|
||||
DeckGroupDesc master;
|
||||
master.id = kGroupMaster;
|
||||
master.captionWidth = 46;
|
||||
master.cellIds = {id(DeckParam::kMasterGain)};
|
||||
master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true};
|
||||
master.captionToggle = {id(DeckParam::kLimiterEnable), 32};
|
||||
master.cellIds = {id(DeckParam::kMasterGain), -1};
|
||||
master.column = {id(DeckParam::kMasterMeter), kMasterMeterW};
|
||||
out.push_back(std::move(master));
|
||||
}
|
||||
for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast<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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,8 +10,17 @@ namespace {
|
||||
|
||||
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
|
||||
// already carries its own breathing room around the 28px knob), plus the optional row
|
||||
// toggle after a kDeckToggleGap.
|
||||
// toggle after a kDeckToggleGap. A spanning group's cells stack, so its knob row is one
|
||||
// cell wide plus whatever readout column sits beside it.
|
||||
int knobRowWidth(const DeckGroupDesc& g) {
|
||||
if (g.row == DeckRow::Spanning) {
|
||||
int w = g.cellIds.empty() ? 0 : kDeckCellW;
|
||||
if (g.column.id >= 0) {
|
||||
if (w > 0) w += kDeckColumnGap;
|
||||
w += g.column.width;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
int w = static_cast<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,34 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
placeToggle(g.captionToggle, out.captionToggle);
|
||||
placeToggle(g.captionToggle2, out.captionToggle2);
|
||||
|
||||
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
|
||||
|
||||
if (g.row == DeckRow::Spanning) {
|
||||
// FIXED slots down the left column, one per declared id (reserves advance the slot
|
||||
// without drawing a cell), spaced by a whole row pitch so slot k lands exactly on
|
||||
// categorical row k's knob baseline. Deliberately NOT the run-division law below.
|
||||
int slotTop = cellTop;
|
||||
for (int id : g.cellIds) {
|
||||
if (id >= 0) {
|
||||
out.cells.push_back(layoutCell(
|
||||
id, Rect::ltrb(innerLeft, slotTop, innerLeft + kDeckCellW,
|
||||
slotTop + kDeckCellH)));
|
||||
}
|
||||
slotTop += kDeckGroupH + kDeckRowGap;
|
||||
}
|
||||
if (g.column.id >= 0) {
|
||||
// ONE rect spanning every slot, not a readout per row.
|
||||
const int colX = innerLeft + (g.cellIds.empty() ? 0 : kDeckCellW + kDeckColumnGap);
|
||||
out.column = DeckColumnLayout{
|
||||
g.column.id, Rect::ltrb(colX, cellTop, colX + g.column.width,
|
||||
box.bottom() - kDeckGroupPadY)};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
|
||||
// id, reserves included). Integer division puts an indivisible residue in symmetric end
|
||||
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
|
||||
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
|
||||
const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
|
||||
int presentCells = 0;
|
||||
for (int id : g.cellIds) {
|
||||
@@ -78,19 +130,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
|
||||
for (int id : g.cellIds) {
|
||||
if (id < 0) continue;
|
||||
DeckCellLayout c;
|
||||
c.id = id;
|
||||
c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
|
||||
const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
|
||||
const int knobTop = cellTop + 4;
|
||||
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
|
||||
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
|
||||
innerTopPx + kDeckInnerDialSize);
|
||||
const int labelTop = knobTop + kDeckKnobSize + 4;
|
||||
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
|
||||
out.cells.push_back(c);
|
||||
out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW,
|
||||
cellTop + kDeckCellH)));
|
||||
x += cellW;
|
||||
}
|
||||
if (g.rowToggle.id >= 0) {
|
||||
@@ -107,65 +148,110 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The gutters between `count` groups whose widths total `total`, justified space-between
|
||||
// inside `blockW`. Empty for a single group.
|
||||
std::vector<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. Unreachable in the editor — see layoutDeck's header note.
|
||||
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 +271,13 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
|
||||
}
|
||||
}
|
||||
return {}; // inside the box but on fence/padding — a miss (groups never overlap)
|
||||
if (g.column.id >= 0 && contains(g.column.box, x, y)) {
|
||||
return {DeckHitKind::Column, g.column.id, -1, false};
|
||||
}
|
||||
// Inside the box but on fence/padding — a miss. First-match is exact while the boxes
|
||||
// are disjoint, which they are at every width the row block fits; under the sub-floor
|
||||
// overrun an overrunning row can reach the spanning deck and the row group answers.
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
// action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
|
||||
// param_slider's — a knob cell here is just a rect the shell composes it into.
|
||||
//
|
||||
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
|
||||
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob
|
||||
// row of equal-width cells (knob centered, label band beneath). A group may also place one
|
||||
// two-segment toggle in the knob row after its cells. Groups that must keep stable
|
||||
// geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows
|
||||
// neighbouring groups.
|
||||
//
|
||||
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
|
||||
// that does not fit the remaining width starts a new row (whole groups only, never
|
||||
// split); the first group of a row always places even if wider than the row.
|
||||
// A group is a fenced box: caption row (caption left, toggles and a corner radio
|
||||
// right-anchored) over a knob row of equal-width cells, optionally followed by one row
|
||||
// toggle. Row membership is a PROPERTY OF THE GROUP (DeckRow), never a wrap outcome — see
|
||||
// the justification law at layoutDeck.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -39,6 +33,7 @@ inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells
|
||||
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
|
||||
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
|
||||
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
|
||||
inline constexpr int kDeckColumnGap = 8; // the spanning deck's cell column -> its readout column
|
||||
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
|
||||
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
|
||||
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
|
||||
@@ -46,6 +41,15 @@ inline constexpr int kDeckInnerDialSize = 20;
|
||||
// One group box: padding + caption + gap + cell row + padding.
|
||||
inline constexpr int kDeckGroupH =
|
||||
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
|
||||
// The spanning deck's box: it stands across both categorical rows AND the seam between them,
|
||||
// which is what lets its two cell slots land on the two rows' own knob baselines.
|
||||
inline constexpr int kDeckSpanningH = 2 * kDeckGroupH + kDeckRowGap;
|
||||
|
||||
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice
|
||||
// IS, Contour is how it moves over time, Spanning is what happens after the mixer. Declared
|
||||
// here rather than with the group inventory because the LAYOUT is what reads it; which group
|
||||
// sits in which row is deck_groups' deckRowFor.
|
||||
enum class DeckRow { Sound, Contour, Spanning };
|
||||
|
||||
// --- The deck's width budget at the editor's floor ------------------------------------
|
||||
// DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a
|
||||
@@ -67,15 +71,29 @@ struct DeckToggleDesc {
|
||||
};
|
||||
|
||||
// A single-square corner radio (an exclusive selector across groups, so the group itself
|
||||
// carries no state). id -1 = absent.
|
||||
// carries no state). id -1 = absent. `passive` reuses the same slot for a READOUT lamp: the
|
||||
// hit-test skips it entirely, so the shell cannot accidentally grow a gesture on it.
|
||||
struct DeckRadioDesc {
|
||||
int id = -1;
|
||||
bool passive = false;
|
||||
};
|
||||
|
||||
// A full-height readout column beside a spanning group's cell slots (the output meter). Only
|
||||
// a Spanning group may carry one — a categorical row's groups have no height to span.
|
||||
// id -1 = absent.
|
||||
struct DeckColumnDesc {
|
||||
int id = -1;
|
||||
int width = 0;
|
||||
};
|
||||
|
||||
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
|
||||
// reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
|
||||
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
|
||||
// reserves for the caption text (this module does not measure text).
|
||||
//
|
||||
// A SPANNING group reads `cellIds` down instead of across: one FIXED kDeckCellW slot per
|
||||
// declared id, at successive row baselines, reserves included. The run-division law above is
|
||||
// horizontal only — applied vertically it would stretch a lone knob over the whole box.
|
||||
struct DeckGroupDesc {
|
||||
int id = 0; // shell group id (opaque here)
|
||||
int captionWidth = 60;
|
||||
@@ -87,6 +105,8 @@ struct DeckGroupDesc {
|
||||
DeckToggleDesc captionToggle2;
|
||||
std::vector<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 +120,12 @@ struct DeckToggleLayout {
|
||||
struct DeckRadioLayout {
|
||||
int id = -1;
|
||||
Rect box;
|
||||
bool passive = false; // a readout lamp, not a selector — see DeckRadioDesc
|
||||
};
|
||||
|
||||
struct DeckColumnLayout {
|
||||
int id = -1;
|
||||
Rect box;
|
||||
};
|
||||
|
||||
struct DeckCellLayout {
|
||||
@@ -119,34 +145,45 @@ struct DeckGroupLayout {
|
||||
DeckToggleLayout captionToggle2;
|
||||
std::vector<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), so that degrade is unreachable in the editor.
|
||||
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 +194,9 @@ struct DeckHit {
|
||||
|
||||
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
|
||||
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
|
||||
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
|
||||
// the caption-row corner radio. Everything else — fence, padding, outside — misses.
|
||||
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, an
|
||||
// interactive caption-row corner radio, or a spanning group's readout column. Everything
|
||||
// else — fence, padding, a PASSIVE radio, outside — misses.
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
|
||||
|
||||
// The knob FACE a point lands on. id -1 is a miss.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// master_meter.cpp — see master_meter.h.
|
||||
|
||||
#include "core/instrument/ui/master_meter.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
MeterRects meterRects(const Rect& column, LaneSplit split) {
|
||||
MeterRects r;
|
||||
if (column.width <= 0 || column.height <= 0) return r;
|
||||
r.labels = Rect::ltrb(column.x, column.y, column.x + kMeterLabelW, column.bottom());
|
||||
const int fieldLeft = column.x + kMeterLabelW + kMeterLabelGap;
|
||||
r.field = Rect::ltrb(fieldLeft, column.y, fieldLeft + kMeterFieldW, column.bottom());
|
||||
if (split == LaneSplit::Single) {
|
||||
r.barA = r.field;
|
||||
return r;
|
||||
}
|
||||
const int barW = (kMeterFieldW - kMeterBarGap) / 2;
|
||||
r.barA = Rect::ltrb(fieldLeft, column.y, fieldLeft + barW, column.bottom());
|
||||
const int bLeft = r.barA.right() + kMeterBarGap;
|
||||
r.barB = Rect::ltrb(bLeft, column.y, bLeft + barW, column.bottom());
|
||||
return r;
|
||||
}
|
||||
|
||||
int meterDbToY(const Rect& field, double db) {
|
||||
const double norm = engine::meterNormFromDb(db);
|
||||
const int y = field.bottom() - static_cast<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);
|
||||
// The audio thread's latch is the authoritative one: it sees every block, where this only
|
||||
// ever samples the last block before the UI woke. A clip that came and went between two
|
||||
// frames is invisible to the peaks above and would otherwise be lost.
|
||||
if (block.clip) {
|
||||
next.left.clip = true;
|
||||
next.right.clip = true;
|
||||
}
|
||||
|
||||
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
|
||||
const double reduction = -engine::meterDbFromLinear(block.minGain);
|
||||
const double fallen = prev.reductionDb - engine::kMeterFallDbPerSecond * dt;
|
||||
next.reductionDb = reduction > fallen ? reduction : fallen;
|
||||
if (next.reductionDb < 0.0) next.reductionDb = 0.0;
|
||||
return next;
|
||||
}
|
||||
|
||||
bool meterClipped(const MasterMeterUi& m) { return m.left.clip || m.right.clip; }
|
||||
|
||||
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev) {
|
||||
MasterMeterUi next = prev;
|
||||
next.left = engine::clearMeterClip(prev.left);
|
||||
next.right = engine::clearMeterClip(prev.right);
|
||||
return next;
|
||||
}
|
||||
|
||||
bool grLampLit(const MasterMeterUi& m) { return m.reductionDb >= kGrLampFloorDb; }
|
||||
|
||||
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b) {
|
||||
return a.left.levelDb == b.left.levelDb && a.right.levelDb == b.right.levelDb &&
|
||||
a.left.holdDb == b.left.holdDb && a.right.holdDb == b.right.holdDb &&
|
||||
meterClipped(a) == meterClipped(b) && grLampLit(a) == grLampLit(b);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,71 @@
|
||||
// master_meter.h — the interior of the spanning deck's readout column: the label gutter and
|
||||
// bar field it divides into, the dB->y map its scale draws against, and the per-instance UI
|
||||
// state the bars are drawn from. knob_deck hands over the column rect; this lays out inside
|
||||
// it. Every timed, logged or latched quantity lives here, on the UI thread — the audio thread
|
||||
// publishes raw block magnitudes and converts nothing.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/meter_ballistics.h"
|
||||
#include "core/instrument/ui/editor_geometry.h"
|
||||
#include "core/instrument/ui/sample_bands.h" // LaneSplit
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
inline constexpr int kMeterLabelW = 22; // the numeral gutter, left of the bars
|
||||
inline constexpr int kMeterLabelGap = 4;
|
||||
inline constexpr int kMeterFieldW = 36; // one 36px bar, or two 17px bars kMeterBarGap apart
|
||||
inline constexpr int kMeterBarGap = 2;
|
||||
|
||||
// A tick every 6 dB up the scale; every other one carries a numeral, and 0 dB draws heavier.
|
||||
inline constexpr double kMeterTickStepDb = 6.0;
|
||||
|
||||
struct MeterRects {
|
||||
Rect labels; // the numeral gutter
|
||||
Rect field; // the whole bar field
|
||||
Rect barA; // Single: the one wide bar. Stereo: L.
|
||||
Rect barB; // empty() unless Stereo
|
||||
};
|
||||
|
||||
// `split` is the RESOLVED lane decision waveformSurface already folds (channel mode AND the
|
||||
// source's channel count), not "is the instrument in stereo mode": a mono source under stereo
|
||||
// mode is dual-mono, and two identical bars would be a lie. One source, two views, one rule.
|
||||
MeterRects meterRects(const Rect& column, LaneSplit split);
|
||||
|
||||
// y of `db` inside the bar field — kMeterTopDb at the top edge, kMeterFloorDb at the bottom,
|
||||
// linear in dB between, clamped outside.
|
||||
int meterDbToY(const Rect& field, double db);
|
||||
|
||||
// The per-instance UI state behind the column. One clip latch per channel (the cap is drawn
|
||||
// once, over whichever of them tripped).
|
||||
struct MasterMeterUi {
|
||||
engine::MeterState left;
|
||||
engine::MeterState right;
|
||||
double reductionDb = 0.0; // how far the limiter is pulling gain down; 0 = not working
|
||||
};
|
||||
|
||||
// What the audio thread published about the last block, in this module's own vocabulary.
|
||||
struct MasterMeterBlock {
|
||||
double peakL = 0.0;
|
||||
double peakR = 0.0;
|
||||
double minGain = 1.0; // the limiter's smallest gain over the block; 1 = no reduction
|
||||
bool clip = false; // the AUDIO thread's own latch
|
||||
};
|
||||
|
||||
MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block,
|
||||
double elapsedSeconds);
|
||||
|
||||
bool meterClipped(const MasterMeterUi& m);
|
||||
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev);
|
||||
|
||||
// Whether two states would DRAW the same, so the UI tick can repaint only on a change and an
|
||||
// idle editor costs nothing. Compares what the column shows — the bar, the held tick, the cap
|
||||
// and the lamp — not every stored double.
|
||||
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b);
|
||||
|
||||
// The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs
|
||||
// a floor rather than a bare non-zero test.
|
||||
inline constexpr double kGrLampFloorDb = 0.5;
|
||||
bool grLampLit(const MasterMeterUi& m);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -12,7 +12,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
|
||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`,
|
||||
`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`,
|
||||
`limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||
`limiter`, `meter_ballistics`, `master_meter`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||
`core/wire` and is documented there — this directory consumes it but does not own it.
|
||||
|
||||
## Invariants
|
||||
@@ -124,6 +124,13 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
|
||||
the very instance whose frame is on the stack. Deferring by one tick is same-thread and
|
||||
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
|
||||
one.
|
||||
- **The MASTER meter's ballistics ride the sync tick, and that tick is 500 ms.** They run
|
||||
BEFORE the tick's in-flight-drag guard on purpose — a drag suppresses the reload poll, but
|
||||
the bus keeps sounding. Elapsed time is measured (`GetTickCount64`), never assumed from the
|
||||
timer's period, and the tick repaints only when `meterDrawEqual` says the picture changed.
|
||||
At that cadence the bar falls 10 dB per redraw and the 1.5 s hold spans three frames:
|
||||
correct against `meter_ballistics`' contract, coarse to the eye. A meter-rate timer is a
|
||||
separate change and is not in yet.
|
||||
- The bake's availability probe runs on the SAME tick that paints the button, so the
|
||||
control can never be enabled on one tick and refuse on the next. The bake Hold control's
|
||||
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
|
||||
|
||||
@@ -89,7 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
|
||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
|
||||
limiter meter_ballistics bake_hold
|
||||
limiter meter_ballistics master_meter bake_hold
|
||||
file_bytes curve_law stroke_aa
|
||||
curve_tessellate
|
||||
bake_plan bake_render bake_reset bake_wire wav_codec)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,18 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||
applyParamControl(hit.id, 0.0, hit.segment);
|
||||
commitAndReload();
|
||||
break;
|
||||
case ParamControl::kLimiterEnable: {
|
||||
const bool on = (hit.segment == 1);
|
||||
if (on != params_.limiterEnabled) {
|
||||
params_.limiterEnabled = on;
|
||||
// The processor's own funnel mirrors the audio-thread flag and requests the
|
||||
// host's latency restart; a reload would re-decode a WAV the toggle cannot
|
||||
// change. The local snapshot moves with it so a later commit agrees.
|
||||
processor_->setLimiterEnabled(on);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable,
|
||||
// and the three env-mode toggles).
|
||||
@@ -80,6 +92,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Column) {
|
||||
// The meter's ONLY gesture: clear the latched clip cap. Both latches go — the audio
|
||||
// thread's is what the next tick would otherwise re-latch the UI's from.
|
||||
masterMeter_ = clearMasterMeterClip(masterMeter_);
|
||||
processor_->clearMasterBusClip();
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// Knobs of a disabled group are drawn but inert.
|
||||
if (deckKnobDisabled(hit.id)) return true;
|
||||
@@ -179,6 +199,9 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl,
|
||||
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind == DeckHitKind::None) return {};
|
||||
// The meter reports its own state continuously; a hover on it would only mean "the clip
|
||||
// cap is clearable", which the cap's presence already says.
|
||||
if (dh.kind == DeckHitKind::Column) return {};
|
||||
if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id};
|
||||
if (dh.kind == DeckHitKind::Knob && dh.inner &&
|
||||
curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) {
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state)
|
||||
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
||||
#include "core/instrument/ui/master_meter.h" // the bus meter's column interior + ballistics
|
||||
#include "core/instrument/ui/waveform_view.h" // waveformSurface (THE lane-split fold)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
@@ -24,6 +26,75 @@ using namespace reasampler::instrument::ui; // deck geometry
|
||||
// are chrome you read once — this is the readout you read while turning something.
|
||||
constexpr Font kCellLabelFont = Font::Label;
|
||||
|
||||
namespace {
|
||||
|
||||
// One tick's numeral, in whole dB ("0", "-12"). No unit suffix — the column is 22px wide and
|
||||
// the scale's unit is stated once, by the caption.
|
||||
std::string tickLabel(int db) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof(buf), "%d", db);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// The MASTER column: dB scale in the label gutter, one or two bars, the held peak tick, and
|
||||
// the latched clip cap. `split` is waveformSurface's own lane decision — see master_meter.h.
|
||||
void paintMeterColumn(LICE_IBitmap* bmp, const Rect& column, const MasterMeterUi& state,
|
||||
LaneSplit split) {
|
||||
if (column.width <= 0 || column.height <= 0) return;
|
||||
const MeterRects m = meterRects(column, split);
|
||||
fillSurface(bmp, toKitBox(m.field), Role::BgCell, InteractionState::Rest);
|
||||
|
||||
// Scale: a rule every 6 dB, numeralled every 12 with 0 dB heavier — the reference the
|
||||
// limiter-off case is read against.
|
||||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||||
for (int db = static_cast<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);
|
||||
const bool numeralled = zero || (db % 12 == 0);
|
||||
LICE_FillRect(bmp, m.field.x, y, m.field.width, zero ? 2 : 1,
|
||||
zero ? toLice(roleColor(Role::TextDim)) : hairline, 1.0f, 0);
|
||||
if (numeralled) {
|
||||
kitText(bmp, Rect::ltrb(m.labels.x, y - 5, m.labels.right(), y + 5),
|
||||
tickLabel(db).c_str(), Font::Micro, Role::TextDim, Align::Right);
|
||||
}
|
||||
}
|
||||
|
||||
// The bars. A single-lane surface shows ONE bar off the louder channel: the two are the
|
||||
// same signal there (dual-mono), so two bars would be a duplicate rather than a reading.
|
||||
const LICE_pixel barInk = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel holdInk = toLice(roleColor(Role::TextPrimary));
|
||||
const auto drawBar = [&](const Rect& bar, const instrument::engine::MeterState& ch) {
|
||||
if (bar.empty()) return;
|
||||
const int top = meterDbToY(bar, ch.levelDb);
|
||||
if (top < bar.bottom()) {
|
||||
LICE_FillRect(bmp, bar.x, top, bar.width, bar.bottom() - top, barInk, 1.0f, 0);
|
||||
}
|
||||
if (ch.holdDb > instrument::engine::kMeterFloorDb) {
|
||||
// Clamped so the 2px tick cannot hang past the bar when the hold sits on the floor.
|
||||
const int hold = (std::min)(meterDbToY(bar, ch.holdDb), bar.bottom() - 2);
|
||||
LICE_FillRect(bmp, bar.x, hold, bar.width, 2, holdInk, 1.0f, 0);
|
||||
}
|
||||
};
|
||||
if (split == LaneSplit::Single) {
|
||||
const instrument::engine::MeterState& loudest =
|
||||
state.left.levelDb >= state.right.levelDb ? state.left : state.right;
|
||||
drawBar(m.barA, loudest);
|
||||
} else {
|
||||
drawBar(m.barA, state.left);
|
||||
drawBar(m.barB, state.right);
|
||||
}
|
||||
|
||||
// The clip cap: latched over the whole field, click to clear.
|
||||
if (meterClipped(state)) {
|
||||
LICE_FillRect(bmp, m.field.x, m.field.y, m.field.width, 3,
|
||||
toLice(roleColor(Role::Warn)), 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
const Rect& deckArea = fl.bands.decks;
|
||||
if (deckArea.width <= 0 || deckArea.height <= 0) return;
|
||||
@@ -31,6 +102,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
const PlaySeconds& play = params_.play;
|
||||
const bool isMono = (voiceMode_ == VoiceMode::Mono);
|
||||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||||
// The meter's bar count is the SAME resolved decision the waveform's lane split is — read
|
||||
// off waveformSurface rather than re-derived, so it can never become a second rule.
|
||||
const LaneSplit meterSplit =
|
||||
waveformSurface(fl.bands.waveform, channelMode_ == ChannelMode::Stereo,
|
||||
channelPcmFor(selectedId_).channelCount)
|
||||
.laneCount == 2
|
||||
? LaneSplit::Stereo
|
||||
: LaneSplit::Single;
|
||||
|
||||
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
|
||||
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
|
||||
@@ -122,9 +201,19 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
}
|
||||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||||
|
||||
// The gain-reduction lamp. ROUND, where the overlay radios in this same slot are
|
||||
// square, so it reads as a lamp rather than a control.
|
||||
if (g.captionRadio.id >= 0 && g.captionRadio.passive) {
|
||||
const Rect& rb = g.captionRadio.box;
|
||||
const float r = rb.width / 2.0f - 0.5f;
|
||||
LICE_FillCircle(bmp, rb.x + rb.width / 2.0f, rb.y + rb.height / 2.0f, r,
|
||||
toLice(roleColor(grLampLit(masterMeter_) ? Role::Warn
|
||||
: Role::LineHairline)),
|
||||
1.0f, 0, true);
|
||||
}
|
||||
// The overlay-select radio: filled in the tertiary accent (the colour the overlay
|
||||
// traces in) when this group's envelope is the one on the waveform, hollow otherwise.
|
||||
if (g.captionRadio.id >= 0) {
|
||||
if (g.captionRadio.id >= 0 && !g.captionRadio.passive) {
|
||||
// overlayEnvForRadio returns kNone for BOTH "not a radio id" and "no selection" —
|
||||
// a non-radio id must never read as lit just because nothing is selected, so the
|
||||
// picked env has to be checked against kNone itself, not just matched by equality.
|
||||
@@ -164,6 +253,15 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
case ParamControl::kFilterEnable:
|
||||
drawToggle(t, "Off", "On", play.filter.enabled, false);
|
||||
break;
|
||||
case ParamControl::kFilterLaw:
|
||||
drawToggle(t, "Band", "Notch",
|
||||
play.filter.settings.morphLaw ==
|
||||
instrument::engine::filter::MorphLaw::HighNotchLow,
|
||||
!play.filter.enabled);
|
||||
break;
|
||||
case ParamControl::kLimiterEnable:
|
||||
drawToggle(t, "Off", "On", params_.limiterEnabled, false);
|
||||
break;
|
||||
case ParamControl::kAmpEnvMode:
|
||||
drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false);
|
||||
break;
|
||||
@@ -176,19 +274,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
// Row toggles: VOICE's Retrig|Legato (live only in Mono) and FILTER's morph law.
|
||||
// The one row toggle left: VOICE's Retrig|Legato, live only in Mono.
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (static_cast<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) {
|
||||
|
||||
@@ -107,6 +107,26 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
// rebuild the instrument and repaint under the cursor, yanking the edit — the next
|
||||
// tick picks up the change after release.
|
||||
if (!processor_) return;
|
||||
|
||||
// The meter runs on EVERY tick, the in-flight-drag case included: a drag suppresses the
|
||||
// reload poll below, but the bus keeps sounding and a frozen bar would misreport it.
|
||||
// Elapsed time is measured rather than assumed — WM_TIMER's period is a request.
|
||||
{
|
||||
const unsigned long long now = GetTickCount64();
|
||||
const double elapsed = meterTickMs_ == 0
|
||||
? 0.0
|
||||
: static_cast<double>(now - meterTickMs_) / 1000.0;
|
||||
meterTickMs_ = now;
|
||||
const MasterBusMeter bus = processor_->masterBusMeter();
|
||||
const instrument::ui::MasterMeterUi advanced = instrument::ui::advanceMasterMeter(
|
||||
masterMeter_,
|
||||
{bus.peakL, bus.peakR, bus.minGain, bus.clip},
|
||||
elapsed);
|
||||
const bool changed = !instrument::ui::meterDrawEqual(advanced, masterMeter_);
|
||||
masterMeter_ = advanced;
|
||||
if (changed) invalidate();
|
||||
}
|
||||
|
||||
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
|
||||
|
||||
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
|
||||
#include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine)
|
||||
#include "core/instrument/ui/master_meter.h" // MasterMeterUi (the bus meter's UI-side state)
|
||||
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
|
||||
#include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks)
|
||||
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
|
||||
@@ -498,6 +499,11 @@ private:
|
||||
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||
bool searchFocused_ = false; // whether the search box has keyboard focus
|
||||
|
||||
// The MASTER deck's meter, advanced from the published block magnitudes on the sync tick
|
||||
// (see onSyncTimer for why it runs mid-drag too). meterTickMs_ 0 = never advanced.
|
||||
instrument::ui::MasterMeterUi masterMeter_;
|
||||
unsigned long long meterTickMs_ = 0;
|
||||
|
||||
// Hover state (transient, never persisted).
|
||||
HoverTarget hover_; // the interactive element under the pointer
|
||||
#ifdef _WIN32
|
||||
|
||||
Reference in New Issue
Block a user