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:
2026-08-02 07:41:10 -04:00
parent f60c05c042
commit 0627398bbb
19 changed files with 1239 additions and 236 deletions
+4 -3
View File
@@ -311,7 +311,7 @@ anything for a trigger shape.
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked 0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle. - `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked 0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over 60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. - `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over 60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
### `map/` ### `map/`
@@ -339,7 +339,7 @@ anything for a trigger shape.
- `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required. - `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know. - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots. - `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split - `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups` from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` / says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
@@ -357,7 +357,8 @@ anything for a trigger shape.
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
labels for them. labels for them.
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there. - `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. **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. - `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. - `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
+10
View File
@@ -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_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
reasampler_test(knob_deck LINK knob_deck) reasampler_test(knob_deck LINK knob_deck)
# The spanning deck's meter column, split from knob_deck on the axis sample_chrome has to
# sample_bands: that says where the column is, this lays out inside it. sample_bands is PUBLIC
# for LaneSplit — the bar count is the SAME resolved decision the waveform's lane split is.
reasampler_pure_library(master_meter
SOURCES master_meter.cpp
LINK PUBLIC editor_geometry sample_bands meter_ballistics)
# waveform_view is linked for the test only: proving the bar count is not a second rule takes
# the real waveformSurface fold, over channel mode x source channel count.
reasampler_test(master_meter LINK master_meter waveform_view)
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see # The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads # core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's # PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
+23 -4
View File
@@ -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); } double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays // Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
// no wider than its knob row the ceiling is PITCH ENV's, whose caption row lands exactly on // no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob
// its four-cell knob row at 23. Raising it reflows the deck's first row. // row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
// the CONTOUR row, which has 144px of slack, not the SOUND row.
constexpr int kEnvModeSegW = 23; 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 } // namespace
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; } 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::kFilterModAmt),
id(DeckParam::kFilterVel), id(DeckParam::kFilterVel),
id(DeckParam::kFilterKeyTrack)}; id(DeckParam::kFilterKeyTrack)};
filter.rowToggle = {id(DeckParam::kFilterLaw), 44}; // The morph law rides the caption slack. Moving it back to the knob row costs the
// group 92px and the SOUND row stops fitting its block.
filter.captionToggle2 = {id(DeckParam::kFilterLaw), 44};
out.push_back(std::move(filter)); out.push_back(std::move(filter));
} }
{ {
@@ -126,12 +133,19 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
out.push_back(std::move(voice)); out.push_back(std::move(voice));
} }
{ {
// The lower slot is reserved and draws NOTHING: blank reads as breathing room where a
// dashed placeholder would read as unfinished. It is one cell, not two — a second
// would spend 60 of the layout's whole 90px budget on a control nobody has named.
DeckGroupDesc master; DeckGroupDesc master;
master.id = kGroupMaster; master.id = kGroupMaster;
master.captionWidth = 46; master.captionWidth = 46;
master.cellIds = {id(DeckParam::kMasterGain)}; master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true};
master.captionToggle = {id(DeckParam::kLimiterEnable), 32};
master.cellIds = {id(DeckParam::kMasterGain), -1};
master.column = {id(DeckParam::kMasterMeter), kMasterMeterW};
out.push_back(std::move(master)); out.push_back(std::move(master));
} }
for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast<DeckGroupId>(d.id));
return out; return out;
} }
@@ -257,6 +271,11 @@ LiveCommit deckParamCommit(DeckParam id) {
case DeckParam::kVoiceMode: case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger: case DeckParam::kMonoTrigger:
case DeckParam::kMasterGain: case DeckParam::kMasterGain:
case DeckParam::kLimiterEnable:
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
// radios above.
case DeckParam::kMasterMeter:
case DeckParam::kMasterGr:
case DeckParam::kCount: // not a control case DeckParam::kCount: // not a control
return LiveCommit::Reload; return LiveCommit::Reload;
} }
+9 -8
View File
@@ -87,6 +87,11 @@ enum class DeckParam {
kVoiceMode, // Poly | Mono caption toggle (VOICE group) kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono) kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group) kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
kLimiterEnable, // master-bus limiter Off | On caption toggle (MASTER group)
// MASTER's two readouts. Neither reaches a parameter: the meter's only gesture is the
// click that clears its latched clip cap, and the bubble is a passive lamp.
kMasterMeter,
kMasterGr,
kCount kCount
}; };
@@ -103,14 +108,10 @@ enum DeckGroupId {
kGroupMaster, kGroupMaster,
}; };
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice // Which row a group belongs to (the row vocabulary itself is knob_deck's — the layout is what
// IS, Contour is how it moves over time, Spanning is what happens after the mixer. // reads it). Membership is a property of the GROUP; width is a property of its descriptor.
enum class DeckRow { Sound, Contour, Spanning }; // Total over DeckGroupId by an exhaustive switch with no default, so a group added without a
// row cannot silently become Sound.
// Which row a group belongs to. Membership is a property of the GROUP; width is a property of
// its descriptor — separating them is what lets the row law be settled while the descriptors
// are still moving. Total over DeckGroupId by an exhaustive switch with no default, so a group
// added without a row cannot silently become Sound.
DeckRow deckRowFor(DeckGroupId group); DeckRow deckRowFor(DeckGroupId group);
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE // Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
+141 -49
View File
@@ -10,8 +10,17 @@ namespace {
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell // The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
// already carries its own breathing room around the 28px knob), plus the optional row // 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) { int knobRowWidth(const DeckGroupDesc& g) {
if (g.row == DeckRow::Spanning) {
int w = g.cellIds.empty() ? 0 : kDeckCellW;
if (g.column.id >= 0) {
if (w > 0) w += kDeckColumnGap;
w += g.column.width;
}
return w;
}
int w = static_cast<int>(g.cellIds.size()) * kDeckCellW; int w = static_cast<int>(g.cellIds.size()) * kDeckCellW;
if (g.rowToggle.id >= 0) { if (g.rowToggle.id >= 0) {
if (w > 0) w += kDeckToggleGap; if (w > 0) w += kDeckToggleGap;
@@ -29,6 +38,24 @@ int captionRowWidth(const DeckGroupDesc& g) {
return w; return w;
} }
// One knob cell inside `cell`: the centered dial square, its concentric inner disc, and the
// label band beneath.
DeckCellLayout layoutCell(int id, const Rect& cell) {
DeckCellLayout c;
c.id = id;
c.cell = cell;
const int knobLeft = cell.x + (cell.width - kDeckKnobSize) / 2;
const int knobTop = cell.y + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
innerTopPx + kDeckInnerDialSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(cell.x, labelTop, cell.right(), labelTop + kDeckCellLabelH);
return c;
}
// Place one group's inner geometry given its box. // Place one group's inner geometry given its box.
DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) { DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
DeckGroupLayout out; DeckGroupLayout out;
@@ -46,7 +73,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2; const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
out.captionRadio = DeckRadioLayout{ out.captionRadio = DeckRadioLayout{
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight, g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
radioTop + kDeckRadioSize)}; radioTop + kDeckRadioSize),
g.captionRadio.passive};
captionRight = out.captionRadio.box.x - kDeckToggleGap; captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x; out.caption.width = captionRight - out.caption.x;
} }
@@ -65,10 +93,34 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
placeToggle(g.captionToggle, out.captionToggle); placeToggle(g.captionToggle, out.captionToggle);
placeToggle(g.captionToggle2, out.captionToggle2); placeToggle(g.captionToggle2, out.captionToggle2);
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
if (g.row == DeckRow::Spanning) {
// FIXED slots down the left column, one per declared id (reserves advance the slot
// without drawing a cell), spaced by a whole row pitch so slot k lands exactly on
// categorical row k's knob baseline. Deliberately NOT the run-division law below.
int slotTop = cellTop;
for (int id : g.cellIds) {
if (id >= 0) {
out.cells.push_back(layoutCell(
id, Rect::ltrb(innerLeft, slotTop, innerLeft + kDeckCellW,
slotTop + kDeckCellH)));
}
slotTop += kDeckGroupH + kDeckRowGap;
}
if (g.column.id >= 0) {
// ONE rect spanning every slot, not a readout per row.
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 // Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
// id, reserves included). Integer division puts an indivisible residue in symmetric end // id, reserves included). Integer division puts an indivisible residue in symmetric end
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule. // margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW; const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
int presentCells = 0; int presentCells = 0;
for (int id : g.cellIds) { for (int id : g.cellIds) {
@@ -78,19 +130,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
int x = innerLeft + (runWidth - presentCells * cellW) / 2; int x = innerLeft + (runWidth - presentCells * cellW) / 2;
for (int id : g.cellIds) { for (int id : g.cellIds) {
if (id < 0) continue; if (id < 0) continue;
DeckCellLayout c; out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW,
c.id = id; cellTop + kDeckCellH)));
c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
innerTopPx + kDeckInnerDialSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c);
x += cellW; x += cellW;
} }
if (g.rowToggle.id >= 0) { if (g.rowToggle.id >= 0) {
@@ -107,65 +148,110 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
return out; return out;
} }
// The gutters between `count` groups whose widths total `total`, justified space-between
// inside `blockW`. Empty for a single group.
std::vector<int> justifyGutters(int count, int total, int blockW) {
const int gutters = count - 1;
if (gutters <= 0) return {};
const int slack = blockW - total;
if (slack < gutters * kDeckGroupGap) {
// The block cannot hold the row: minimum gutters, and the row overruns to the right
// rather than wrapping. 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 } // namespace
int deckGroupWidth(const DeckGroupDesc& g) { int deckGroupWidth(const DeckGroupDesc& g) {
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX; return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
} }
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth) { int deckRowCount(const std::vector<DeckGroupDesc>& groups) {
if (groups.empty()) return 0; bool sound = false, contour = false;
int rows = 1;
int x = 0;
for (const DeckGroupDesc& g : groups) { for (const DeckGroupDesc& g : groups) {
const int w = deckGroupWidth(g); if (g.row == DeckRow::Sound) sound = true;
if (x > 0 && x + kDeckGroupGap + w > availWidth) { else if (g.row == DeckRow::Contour) contour = true;
++rows;
x = w;
} else {
x += (x > 0 ? kDeckGroupGap : 0) + w;
} }
} return (sound ? 1 : 0) + (contour ? 1 : 0);
return rows;
} }
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth) { int deckHeight(const std::vector<DeckGroupDesc>& groups) {
const int rows = deckRowCount(groups, availWidth); const int rows = deckRowCount(groups);
if (rows == 0) return 0; int h = rows > 0 ? rows * kDeckGroupH + (rows - 1) * kDeckRowGap : 0;
return rows * kDeckGroupH + (rows - 1) * kDeckRowGap; for (const DeckGroupDesc& g : groups) {
if (g.row == DeckRow::Spanning) h = (std::max)(h, kDeckSpanningH);
}
return h;
} }
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top, DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth) { int availWidth) {
DeckLayout out; DeckLayout out;
if (groups.empty()) return out; if (groups.empty()) return out;
int x = left;
// 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);
}
// 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; int y = top;
bool rowHasGroup = false; for (const std::vector<std::size_t>& row : rows) {
out.rowCount = 1; if (row.empty()) continue; // an absent category collapses; it leaves no empty band
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; ++out.rowCount;
x = left; 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; y += kDeckGroupH + kDeckRowGap;
rowHasGroup = false;
} }
if (rowHasGroup) x += kDeckGroupGap;
const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH); int sx = left + availWidth - spanTotal;
out.groups.push_back(layoutGroup(g, box)); for (std::size_t i : spanning) {
x = box.right(); const int w = deckGroupWidth(groups[i]);
rowHasGroup = true; boxes[i] = Rect::ltrb(sx, top, sx + w, top + kDeckSpanningH);
sx += w + kDeckGroupGap;
} }
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
out.groups.reserve(groups.size());
for (std::size_t i = 0; i < groups.size(); ++i) {
out.groups.push_back(layoutGroup(groups[i], boxes[i]));
}
out.height = deckHeight(groups);
return out; return out;
} }
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) { DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) { for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue; if (!contains(g.box, x, y)) continue;
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) { if (g.captionRadio.id >= 0 && !g.captionRadio.passive &&
contains(g.captionRadio.box, x, y)) {
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false}; return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
} }
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) { for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
@@ -185,7 +271,13 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)}; return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
} }
} }
return {}; // inside the box but on fence/padding — a miss (groups never overlap) if (g.column.id >= 0 && contains(g.column.box, x, y)) {
return {DeckHitKind::Column, g.column.id, -1, false};
}
// Inside the box but on fence/padding — a miss. First-match is exact while the boxes
// are disjoint, which they are at every width the row block fits; under the sub-floor
// overrun an overrunning row can reach the spanning deck and the row group answers.
return {};
} }
return {}; return {};
} }
+63 -25
View File
@@ -3,16 +3,10 @@
// action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is // action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
// param_slider's — a knob cell here is just a rect the shell composes it into. // param_slider's — a knob cell here is just a rect the shell composes it into.
// //
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a // A group is a fenced box: caption row (caption left, toggles and a corner radio
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob // right-anchored) over a knob row of equal-width cells, optionally followed by one row
// row of equal-width cells (knob centered, label band beneath). A group may also place one // toggle. Row membership is a PROPERTY OF THE GROUP (DeckRow), never a wrap outcome — see
// two-segment toggle in the knob row after its cells. Groups that must keep stable // the justification law at layoutDeck.
// geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows
// neighbouring groups.
//
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
// that does not fit the remaining width starts a new row (whole groups only, never
// split); the first group of a row always places even if wider than the row.
#pragma once #pragma once
@@ -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 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 wrapped deck rows
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
inline constexpr int kDeckColumnGap = 8; // the spanning deck's cell column -> its readout column
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while // The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is // the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob. // deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
@@ -46,6 +41,15 @@ inline constexpr int kDeckInnerDialSize = 20;
// One group box: padding + caption + gap + cell row + padding. // One group box: padding + caption + gap + cell row + padding.
inline constexpr int kDeckGroupH = inline constexpr int kDeckGroupH =
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY; kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
// The spanning deck's box: it stands across both categorical rows AND the seam between them,
// which is what lets its two cell slots land on the two rows' own knob baselines.
inline constexpr int kDeckSpanningH = 2 * kDeckGroupH + kDeckRowGap;
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice
// IS, Contour is how it moves over time, Spanning is what happens after the mixer. Declared
// here rather than with the group inventory because the LAYOUT is what reads it; which group
// sits in which row is deck_groups' deckRowFor.
enum class DeckRow { Sound, Contour, Spanning };
// --- The deck's width budget at the editor's floor ------------------------------------ // --- The deck's width budget at the editor's floor ------------------------------------
// DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a // DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a
@@ -67,15 +71,29 @@ struct DeckToggleDesc {
}; };
// A single-square corner radio (an exclusive selector across groups, so the group itself // A single-square corner radio (an exclusive selector across groups, so the group itself
// carries no state). id -1 = absent. // carries no state). id -1 = absent. `passive` reuses the same slot for a READOUT lamp: the
// hit-test skips it entirely, so the shell cannot accidentally grow a gesture on it.
struct DeckRadioDesc { struct DeckRadioDesc {
int id = -1; int id = -1;
bool passive = false;
};
// A full-height readout column beside a spanning group's cell slots (the output meter). Only
// a Spanning group may carry one — a categorical row's groups have no height to span.
// id -1 = absent.
struct DeckColumnDesc {
int id = -1;
int width = 0;
}; };
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 // One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// reserves one cell's WIDTH without a cell, and the cells present divide the whole run — // reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell // see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
// reserves for the caption text (this module does not measure text). // reserves for the caption text (this module does not measure text).
//
// A SPANNING group reads `cellIds` down instead of across: one FIXED kDeckCellW slot per
// declared id, at successive row baselines, reserves included. The run-division law above is
// horizontal only — applied vertically it would stretch a lone knob over the whole box.
struct DeckGroupDesc { struct DeckGroupDesc {
int id = 0; // shell group id (opaque here) int id = 0; // shell group id (opaque here)
int captionWidth = 60; int captionWidth = 60;
@@ -87,6 +105,8 @@ struct DeckGroupDesc {
DeckToggleDesc captionToggle2; DeckToggleDesc captionToggle2;
std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above) std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above)
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
DeckRow row = DeckRow::Sound;
DeckColumnDesc column; // Spanning groups only; id -1 = none
}; };
// --- Laid-out geometry --------------------------------------------------------------- // --- Laid-out geometry ---------------------------------------------------------------
@@ -100,6 +120,12 @@ struct DeckToggleLayout {
struct DeckRadioLayout { struct DeckRadioLayout {
int id = -1; int id = -1;
Rect box; Rect box;
bool passive = false; // a readout lamp, not a selector — see DeckRadioDesc
};
struct DeckColumnLayout {
int id = -1;
Rect box;
}; };
struct DeckCellLayout { struct DeckCellLayout {
@@ -119,34 +145,45 @@ struct DeckGroupLayout {
DeckToggleLayout captionToggle2; DeckToggleLayout captionToggle2;
std::vector<DeckCellLayout> cells; std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent DeckToggleLayout rowToggle; // id -1 when absent
DeckColumnLayout column; // id -1 when absent (Spanning groups only)
}; };
struct DeckLayout { struct DeckLayout {
std::vector<DeckGroupLayout> groups; std::vector<DeckGroupLayout> groups;
int rowCount = 0; int rowCount = 0; // POPULATED categorical rows (0..2). A spanning deck is in neither.
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups int height = 0; // the tallest thing laid out; 0 for no groups
}; };
// Width of one group box: the wider of its caption row (caption + gap + toggle) and its // Width of one group box: the wider of its caption row (caption + gap + toggles + radio) and
// knob row (cells + gap + row toggle), plus horizontal padding. // its knob row, plus horizontal padding. A Spanning group's knob row is one cell wide plus
// its readout column, because its cells stack.
int deckGroupWidth(const DeckGroupDesc& g); int deckGroupWidth(const DeckGroupDesc& g);
// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap. // How many of the two categorical rows carry at least one group (0..2). Independent of width:
// 0 for an empty list. // row membership is the group's own property.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckRowCount(const std::vector<DeckGroupDesc>& groups);
// Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell // Total deck height: the categorical rows, or the spanning deck when it is taller. The shell
// bottom-anchors a band of exactly this height. // bottom-anchors a band of exactly this height.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckHeight(const std::vector<DeckGroupDesc>& groups);
// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's // Lays the groups out from (left, top) within `availWidth`. Every rect is absolute, and
// rule. Every rect is absolute. // `groups` comes back in DECK order — the same position as the descriptor it was built from,
// whichever row that descriptor landed in.
//
// Spanning groups are right-anchored at `left + availWidth` and take no part in either row's
// justification; the ROW BLOCK is what remains to their left. Inside the block each row is
// justified SPACE-BETWEEN: groups keep their natural widths and the slack becomes gutters,
// divided equally with the integer residue going to the leftmost ones. Decks are never
// stretched. Below the width the block needs, every gutter sits at kDeckGroupGap and the row
// overflows right rather than wrapping — the shell clamps the window to a floor that fits
// (sample_bands' kEditorMinWidth), so that degrade is unreachable in the editor.
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top, DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth); int availWidth);
// --- Hit-test -------------------------------------------------------------------------- // --- Hit-test --------------------------------------------------------------------------
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio }; enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Column };
struct DeckHit { struct DeckHit {
DeckHitKind kind = DeckHitKind::None; DeckHitKind kind = DeckHitKind::None;
@@ -157,8 +194,9 @@ struct DeckHit {
// The deck element a point lands on: a knob cell (the whole cell, not just the knob // The deck element a point lands on: a knob cell (the whole cell, not just the knob
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking // circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or // a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, an
// the caption-row corner radio. Everything else — fence, padding, outside — misses. // interactive caption-row corner radio, or a spanning group's readout column. Everything
// else — fence, padding, a PASSIVE radio, outside — misses.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
// The knob FACE a point lands on. id -1 is a miss. // The knob FACE a point lands on. id -1 is a miss.
+70
View File
@@ -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
+71
View File
@@ -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
+8 -1
View File
@@ -12,7 +12,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `loop_marks`, `capture_browser`, `browser_scroll`,
`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, `param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`,
`limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and `limiter`, `meter_ballistics`, `master_meter`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it. `core/wire` and is documented there — this directory consumes it but does not own it.
## Invariants ## Invariants
@@ -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 the very instance whose frame is on the stack. Deferring by one tick is same-thread and
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one. one.
- **The 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 - The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next. The bake Hold control's control can never be enabled on one tick and refuse on the next. The bake Hold control's
applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and applicability (`resolveBakeHoldNeeded`) rides the same tick for the same reason, and
+1 -1
View File
@@ -89,7 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
waveform_view loop_marks bank_sync browser_scroll param_slider tooltip waveform_view loop_marks bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
limiter meter_ballistics bake_hold limiter meter_ballistics master_meter bake_hold
file_bytes curve_law stroke_aa file_bytes curve_law stroke_aa
curve_tessellate curve_tessellate
bake_plan bake_render bake_reset bake_wire wav_codec) bake_plan bake_render bake_reset bake_wire wav_codec)
+2 -2
View File
@@ -75,10 +75,10 @@ double curveExponentFor(int id, const PlaySeconds& play) {
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const { ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
// The ONE resolve every paint and hit-test path goes through, so the band stack, the // The ONE resolve every paint and hit-test path goes through, so the band stack, the
// chrome interior, and the deck descriptors can never be derived three different ways. // chrome interior, and the deck descriptors can never be derived three different ways.
// The deck's own wrapped height is the only interior measurement the allocator needs. // The deck's own height is the only interior measurement the allocator needs.
FaceLayout fl; FaceLayout fl;
fl.deckDescs = sampleDeckGroups(params_.play.playMode); fl.deckDescs = sampleDeckGroups(params_.play.playMode);
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad)); fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs));
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize); fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
return fl; return fl;
} }
@@ -64,6 +64,18 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
applyParamControl(hit.id, 0.0, hit.segment); applyParamControl(hit.id, 0.0, hit.segment);
commitAndReload(); commitAndReload();
break; break;
case ParamControl::kLimiterEnable: {
const bool on = (hit.segment == 1);
if (on != params_.limiterEnabled) {
params_.limiterEnabled = on;
// 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: { default: {
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable, // Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable,
// and the three env-mode toggles). // and the three env-mode toggles).
@@ -80,6 +92,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
} }
return true; return true;
} }
if (hit.kind == DeckHitKind::Column) {
// The meter's ONLY gesture: clear the latched clip cap. Both latches go — the audio
// thread's is what the next tick would otherwise re-latch the UI's from.
masterMeter_ = clearMasterMeterClip(masterMeter_);
processor_->clearMasterBusClip();
invalidate();
return true;
}
if (hit.kind == DeckHitKind::Knob) { if (hit.kind == DeckHitKind::Knob) {
// Knobs of a disabled group are drawn but inert. // Knobs of a disabled group are drawn but inert.
if (deckKnobDisabled(hit.id)) return true; if (deckKnobDisabled(hit.id)) return true;
@@ -179,6 +199,9 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl,
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width); const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit dh = hitTestDeck(dl, x, y); const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind == DeckHitKind::None) return {}; if (dh.kind == DeckHitKind::None) return {};
// The meter reports its own state continuously; a hover on it would only mean "the clip
// cap is clearable", which the cap's presence already says.
if (dh.kind == DeckHitKind::Column) return {};
if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id}; if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id};
if (dh.kind == DeckHitKind::Knob && dh.inner && if (dh.kind == DeckHitKind::Knob && dh.inner &&
curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) { curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) {
+102 -9
View File
@@ -12,6 +12,8 @@
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state) #include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's state)
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize #include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
#include "core/instrument/ui/master_meter.h" // the bus meter's column interior + ballistics
#include "core/instrument/ui/waveform_view.h" // waveformSurface (THE lane-split fold)
#include "shell/instrument/editor_internal.h" // kit adapters + knob face #include "shell/instrument/editor_internal.h" // kit adapters + knob face
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
@@ -24,6 +26,75 @@ using namespace reasampler::instrument::ui; // deck geometry
// are chrome you read once — this is the readout you read while turning something. // are chrome you read once — this is the readout you read while turning something.
constexpr Font kCellLabelFont = Font::Label; constexpr Font kCellLabelFont = Font::Label;
namespace {
// One tick's numeral, in whole dB ("0", "-12"). No unit suffix — the column is 22px wide and
// the scale's unit is stated once, by the caption.
std::string tickLabel(int db) {
char buf[8];
std::snprintf(buf, sizeof(buf), "%d", db);
return std::string(buf);
}
// The MASTER column: dB scale in the label gutter, one or two bars, the held peak tick, and
// the latched clip cap. `split` is 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) { void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const Rect& deckArea = fl.bands.decks; const Rect& deckArea = fl.bands.decks;
if (deckArea.width <= 0 || deckArea.height <= 0) return; if (deckArea.width <= 0 || deckArea.height <= 0) return;
@@ -31,6 +102,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const PlaySeconds& play = params_.play; const PlaySeconds& play = params_.play;
const bool isMono = (voiceMode_ == VoiceMode::Mono); const bool isMono = (voiceMode_ == VoiceMode::Mono);
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline)); const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
// The meter's bar count is the SAME resolved decision the waveform's lane split is — 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 // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
@@ -122,9 +201,19 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
} }
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim); kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
// The gain-reduction lamp. ROUND, where the overlay radios in this same slot are
// square, so it reads as a lamp rather than a control.
if (g.captionRadio.id >= 0 && g.captionRadio.passive) {
const Rect& rb = g.captionRadio.box;
const float r = rb.width / 2.0f - 0.5f;
LICE_FillCircle(bmp, rb.x + rb.width / 2.0f, rb.y + rb.height / 2.0f, r,
toLice(roleColor(grLampLit(masterMeter_) ? Role::Warn
: Role::LineHairline)),
1.0f, 0, true);
}
// The overlay-select radio: filled in the tertiary accent (the colour the overlay // The overlay-select radio: filled in the tertiary accent (the colour the overlay
// traces in) when this group's envelope is the one on the waveform, hollow otherwise. // traces in) when this group's envelope is the one on the waveform, hollow otherwise.
if (g.captionRadio.id >= 0) { if (g.captionRadio.id >= 0 && !g.captionRadio.passive) {
// overlayEnvForRadio returns kNone for BOTH "not a radio id" and "no selection" — // overlayEnvForRadio returns kNone for BOTH "not a radio id" and "no selection" —
// a non-radio id must never read as lit just because nothing is selected, so the // a non-radio id must never read as lit just because nothing is selected, so the
// picked env has to be checked against kNone itself, not just matched by equality. // picked env has to be checked against kNone itself, not just matched by equality.
@@ -164,6 +253,15 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
case ParamControl::kFilterEnable: case ParamControl::kFilterEnable:
drawToggle(t, "Off", "On", play.filter.enabled, false); drawToggle(t, "Off", "On", play.filter.enabled, false);
break; break;
case ParamControl::kFilterLaw:
drawToggle(t, "Band", "Notch",
play.filter.settings.morphLaw ==
instrument::engine::filter::MorphLaw::HighNotchLow,
!play.filter.enabled);
break;
case ParamControl::kLimiterEnable:
drawToggle(t, "Off", "On", params_.limiterEnabled, false);
break;
case ParamControl::kAmpEnvMode: case ParamControl::kAmpEnvMode:
drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false); drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false);
break; break;
@@ -176,18 +274,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
default: break; default: break;
} }
} }
// Row toggles: VOICE's Retrig|Legato (live only in Mono) and FILTER's morph law. // The one row toggle left: VOICE's Retrig|Legato, live only in Mono.
if (g.rowToggle.id >= 0) { if (g.rowToggle.id >= 0) {
if (static_cast<ParamControl>(g.rowToggle.id) == ParamControl::kFilterLaw) {
drawToggle(g.rowToggle, "Band", "Notch",
play.filter.settings.morphLaw ==
instrument::engine::filter::MorphLaw::HighNotchLow,
!play.filter.enabled);
} else {
drawToggle(g.rowToggle, "Retrig", "Legato", drawToggle(g.rowToggle, "Retrig", "Legato",
monoTrigger_ == MonoTrigger::Legato, !isMono); monoTrigger_ == MonoTrigger::Legato, !isMono);
} }
}
if (g.column.id >= 0) paintMeterColumn(bmp, g.column.box, masterMeter_, meterSplit);
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry. // The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
// The predicate is the input side's, so the drawn state and the inert grab agree. // The predicate is the input side's, so the drawn state and the inert grab agree.
+20
View File
@@ -107,6 +107,26 @@ void ReaSamplerEditor::onSyncTimer() {
// rebuild the instrument and repaint under the cursor, yanking the edit — the next // rebuild the instrument and repaint under the cursor, yanking the edit — the next
// tick picks up the change after release. // tick picks up the change after release.
if (!processor_) return; if (!processor_) return;
// 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 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 // Resolve the bake affordance's availability on the SAME tick that paints it, so it
+6
View File
@@ -20,6 +20,7 @@
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam) #include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band) #include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine) #include "core/instrument/ui/loop_marks.h" // LoopMarks (the loop enable's state machine)
#include "core/instrument/ui/master_meter.h" // MasterMeterUi (the bus meter's UI-side state)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator) #include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks) #include "core/instrument/ui/waveform_view.h" // WaveMark / WaveMarks (the overlay's marks)
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar #include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
@@ -498,6 +499,11 @@ private:
std::string searchQuery_; // type-to-filter narrow; "" = no search std::string searchQuery_; // type-to-filter narrow; "" = no search
bool searchFocused_ = false; // whether the search box has keyboard focus bool searchFocused_ = false; // whether the search box has keyboard focus
// The MASTER deck's meter, advanced from the published block magnitudes on the sync tick
// (see onSyncTimer for why it runs mid-drag too). meterTickMs_ 0 = never advanced.
instrument::ui::MasterMeterUi masterMeter_;
unsigned long long meterTickMs_ = 0;
// Hover state (transient, never persisted). // Hover state (transient, never persisted).
HoverTarget hover_; // the interactive element under the pointer HoverTarget hover_; // the interactive element under the pointer
#ifdef _WIN32 #ifdef _WIN32
+273 -81
View File
@@ -125,10 +125,11 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel), cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
cell(DeckParam::kFilterKeyTrack)}; cell(DeckParam::kFilterKeyTrack)};
CHECK(f.cellIds == expected); CHECK(f.cellIds == expected);
// Off by default is a state question, but reachability is a layout one: the enable // Off by default is a state question, but reachability is a layout one: BOTH toggles now
// toggle is in the caption row and the morph law in the knob row. // ride the caption row, which is what takes the group from 524 to 432.
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable)); CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
CHECK(f.rowToggle.id == cell(DeckParam::kFilterLaw)); CHECK(f.captionToggle2.id == cell(DeckParam::kFilterLaw));
CHECK(f.rowToggle.id == -1);
const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))]; const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
const std::vector<int> env = { const std::vector<int> env = {
@@ -141,14 +142,24 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
CHECK(fe.rowToggle.id == -1); CHECK(fe.rowToggle.id == -1);
} }
// Exactly the three envelope decks carry an overlay-select radio, each its own, and no other // Exactly the three envelope decks carry a SELECTABLE overlay radio, each its own, and no
// group has one — the exclusivity the shell enforces is only meaningful if the id space is. // other group has one — the exclusivity the shell enforces is only meaningful if the id space
static void testOnlyTheThreeEnvelopeDecksCarryARadio() { // is. MASTER occupies the same corner slot with a PASSIVE lamp, which is a different thing:
// it must never be counted as, or reachable as, a selector.
static void testOnlyTheThreeEnvelopeDecksCarryASelectableRadio() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode); const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int radios = 0; int radios = 0;
for (const DeckGroupDesc& d : g) { for (const DeckGroupDesc& d : g) {
if (d.captionRadio.id < 0) continue; if (d.captionRadio.id < 0) continue;
if (d.captionRadio.passive) {
CHECK(d.id == kGroupMaster);
CHECK(d.captionRadio.id == cell(DeckParam::kMasterGr));
// A passive slot names no overlay, so no click on it could select one even if
// the hit-test ever handed it through.
CHECK(overlayEnvForRadio(d.captionRadio.id) == OverlayEnv::kNone);
continue;
}
++radios; ++radios;
const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect) const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect)
: d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect) : d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect)
@@ -234,29 +245,38 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
CHECK(a.cellIds.size() == b.cellIds.size()); CHECK(a.cellIds.size() == b.cellIds.size());
CHECK(b.cellIds[4] == -1); // the Trigger face's one reserved blank CHECK(b.cellIds[4] == -1); // the Trigger face's one reserved blank
// Every other group is mode-independent, so the whole deck's height is too. // Every other group is mode-independent, so the whole deck's height is too.
CHECK(deckHeight(gate, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth)); CHECK(deckHeight(gate) == deckHeight(trig));
} }
static void testWrappedDeckHeightAtTheEditorFloorWidth() { // TWO rows plus the spanning deck, BY CONSTRUCTION: the row count is read off the group
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate); // inventory's own row assignment, not observed as a pack outcome, so it holds at every width.
// An UPPER BOUND, not an equality. The greedy whole-group wrap is still what decides row static void testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction() {
// membership until the reflow replaces it with the categorical partition, and at this width for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
// it happens to pack two ragged rows with the wrong composition. Bounding it is a real const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
// regression canary — a third row would cost the waveform 112 px again — without turning a CHECK(deckRowCount(g) == 2);
// wrap outcome into a claim. CHECK(deckHeight(g) == 2 * kDeckGroupH + kDeckRowGap);
const int rows = deckRowCount(g, kAvailAtMinWidth); CHECK(deckHeight(g) == 216);
CHECK(rows <= 2);
CHECK(deckHeight(g, kAvailAtMinWidth) == rows * kDeckGroupH + (rows - 1) * kDeckRowGap);
// Whole groups only, never split: every group's box lies inside the available width or is for (int avail : {kAvailAtMinWidth, kAvailAtMinWidth + 200, 4000}) {
// the first of its row. const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); CHECK(dl.rowCount == 2);
CHECK(dl.height == 216);
CHECK(dl.groups.size() == g.size()); CHECK(dl.groups.size() == g.size());
int rowTops[2] = {0, kDeckGroupH + kDeckRowGap};
for (const DeckGroupLayout& gl : dl.groups) { for (const DeckGroupLayout& gl : dl.groups) {
CHECK(gl.box.x >= kPad); const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
if (row == DeckRow::Spanning) {
CHECK(gl.box.y == 0);
CHECK(gl.box.height == kDeckSpanningH);
CHECK(gl.box.right() == kPad + avail); // right-anchored at every width
} else {
CHECK(gl.box.y == rowTops[row == DeckRow::Contour ? 1 : 0]);
CHECK(gl.box.height == kDeckGroupH); CHECK(gl.box.height == kDeckGroupH);
} }
} }
}
}
}
// The guard the raised floor exists to provide: at the smallest window the host can produce, // The guard the raised floor exists to provide: at the smallest window the host can produce,
// the deck band still lands inside the client area AND the waveform still gets its two-lane // the deck band still lands inside the client area AND the waveform still gets its two-lane
@@ -265,15 +285,15 @@ static void testWrappedDeckHeightAtTheEditorFloorWidth() {
static void testDeckFitsInsideTheEnforcedMinimumWindow() { static void testDeckFitsInsideTheEnforcedMinimumWindow() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode); const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const int h = deckHeight(g, kAvailAtMinWidth); const int h = deckHeight(g);
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
CHECK(deckRowCount(g, kAvailAtMinWidth) <= 2); // either face; see the bound above CHECK(deckRowCount(g) == 2); // either face
CHECK(b.decks.height == h); CHECK(b.decks.height == h);
// The raised floor hands the waveform the reflow's 112 px two waves early: at two rows // The reflow's 112 px land in the waveform: at two rows the deck band is 216 and the
// the deck band is 216 and the waveform 358, against 328/246 before. Bounded rather // waveform 358, against 328/246 before. Pinned now that both are reached by
// than pinned for the same reason the row count is. // construction rather than by a pack outcome.
CHECK(b.decks.height <= 2 * kDeckGroupH + kDeckRowGap); CHECK(b.decks.height == 2 * kDeckGroupH + kDeckRowGap);
CHECK(b.waveform.height >= 358); CHECK(b.waveform.height == 358);
// Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the
// deck down until the waveform hits its floor, so any deck too tall to fit stops // deck down until the waveform hits its floor, so any deck too tall to fit stops
// landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the // landing on this exact line. A `<= kEditorMinHeight` bound would not catch it — the
@@ -293,6 +313,14 @@ static void testTheEditorFloorIsDerivedFromTheDeckWidthBudget() {
CHECK(kEditorCeilingWidth - kEditorMinWidth == 90); CHECK(kEditorCeilingWidth - kEditorMinWidth == 90);
// The reflow's 112 px goes entirely to the waveform, so the height does not move. // The reflow's 112 px goes entirely to the waveform, so the height does not move.
CHECK(kEditorMinHeight == 680); CHECK(kEditorMinHeight == 680);
// The floor did not move to make the reflow fit — the reflow was fitted to the floor. This
// wave spends the budget it was handed; it does not widen it.
CHECK(kEditorMinWidth == 1190);
CHECK(kEditorMinWidth <= kEditorCeilingWidth);
CHECK(kEditorMinHeight <= 720);
// And the row block really is what the two rows justify inside — derived from the floor
// and the spanning reserve, not restated.
CHECK(kEditorMinWidth - 2 * kPad - kDeckSpanningW - kDeckGroupGap == kDeckRowBlockW);
} }
static void testEveryDeckGroupBelongsToExactlyOneRow() { static void testEveryDeckGroupBelongsToExactlyOneRow() {
@@ -320,36 +348,178 @@ static void testEveryDeckGroupBelongsToExactlyOneRow() {
} }
} }
// What the budget can already be measured against. The contour row fits today and MASTER has // Both rows now fit their block, in BOTH play modes. Row 1's fit is the one this track closes:
// not touched its reserve; the SOUND row does not fit yet and must not be forced to — it is // it was 1030, +42 from PITCH/RATE's third cell and 92 from FILTER's Band|Notch caption move
// 1030 against the 1020 block, and the 50 px deficit is exactly what two later descriptor // take it to 980. Row 2's 876 is mode-stable because FILTER ENV's and AMP's reserve slots hold
// changes buy: PITCH becoming PITCH/RATE (+42) and FILTER's Band|Notch moving from the knob // them at 312 in Trigger too — asserted here rather than assumed.
// row to the caption corner (92), netting 980. The fit is asserted when they land, not here. static void testBothRowsAndTheSpanningDeckFitTheBudget() {
static void testTheContourRowAndTheSpanningDeckFitTheBudget() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode); const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int contourWidth = 0, contourGroups = 0, spanningWidth = 0; int width[3] = {0, 0, 0};
int count[3] = {0, 0, 0};
for (const DeckGroupDesc& d : g) { for (const DeckGroupDesc& d : g) {
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(d.id)); const int r = static_cast<int>(deckRowFor(static_cast<DeckGroupId>(d.id)));
if (row == DeckRow::Contour) { width[r] += deckGroupWidth(d);
contourWidth += deckGroupWidth(d); ++count[r];
++contourGroups; }
} else if (row == DeckRow::Spanning) { const int sound = static_cast<int>(DeckRow::Sound);
spanningWidth += deckGroupWidth(d); const int contour = static_cast<int>(DeckRow::Contour);
const int spanning = static_cast<int>(DeckRow::Spanning);
CHECK(count[sound] == 4);
CHECK(width[sound] == 980); // 192 + 432 + 192 + 164
CHECK(count[contour] == 3);
CHECK(width[contour] == 876); // 252 + 312 + 312
CHECK(count[spanning] == 1);
CHECK(width[spanning] == kDeckSpanningW); // 142 exactly — the reserve is now spent
for (int r : {sound, contour}) {
CHECK(width[r] <= kDeckRowBlockW);
// Slack enough that no gutter in the row falls under the minimum.
CHECK(kDeckRowBlockW - width[r] >= (count[r] - 1) * kDeckGroupGap);
} }
} }
// 252 + 312 + 312. Mode-stable because FILTER ENV's and AMP's reserve slots hold them
// at 312 in Trigger as well as Gate.
CHECK(contourGroups == 3);
CHECK(contourWidth == 876);
CHECK(contourWidth <= kDeckRowBlockW);
// Slack enough that neither of the row's two gutters falls under the minimum.
CHECK(kDeckRowBlockW - contourWidth >= (contourGroups - 1) * kDeckGroupGap);
// MASTER is 72 today against a 142 reserve: the double-height interior it grows into is
// budgeted for, not yet spent.
CHECK(spanningWidth == 72);
CHECK(spanningWidth <= kDeckSpanningW);
} }
// The gutters the justification law produces at the floor, and the alignment they buy. The
// SPEC (instrument-control-surface.md §1.2/§1.3) states row 1 as 12/14/14 with both filter
// edges at x = 636; equal division of 40 px over three gutters cannot produce that, so what is
// pinned here is what the LAW produces — 14/13/13, filter edge 638 — with row 2 exact at
// 72/72 and 636. The 2 px is flagged for review; a row block of 1028 (floor 1198, still under
// the 1280 ceiling) is the width at which the law puts both edges on 640.
static void testGutterArithmeticAndTheFilterTieLineAtTheFloor() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const auto box = [&](int id) {
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box;
};
// Row 1: flush left, flush right on the block, gutters 14/13/13.
CHECK(box(kGroupPitch).x == kPad);
CHECK(box(kGroupFilter).x - box(kGroupPitch).right() == 14);
CHECK(box(kGroupVelocity).x - box(kGroupFilter).right() == 13);
CHECK(box(kGroupVoice).x - box(kGroupVelocity).right() == 13);
CHECK(box(kGroupVoice).right() == kPad + kDeckRowBlockW);
// Row 2: flush left, flush right, and its two gutters exactly equal — the property the
// 1020 block was chosen for, and the one it does deliver.
CHECK(box(kGroupPitchEnv).x == kPad);
CHECK(box(kGroupFilterEnv).x - box(kGroupPitchEnv).right() == 72);
CHECK(box(kGroupAmpEnv).x - box(kGroupFilterEnv).right() == 72);
CHECK(box(kGroupAmpEnv).right() == kPad + kDeckRowBlockW);
// The filter tie-line, block-relative. Row 2 lands on the specified 636; row 1 lands 2 px
// past it. See this test's header.
CHECK(box(kGroupFilterEnv).right() - kPad == 636);
CHECK(box(kGroupFilter).right() - kPad == 638);
// MASTER is right-anchored outside the block, one kDeckGroupGap clear of it.
CHECK(box(kGroupMaster).x - box(kGroupVoice).right() == kDeckGroupGap);
CHECK(box(kGroupMaster).right() == kPad + kAvailAtMinWidth);
}
// No gutter is ever narrower than kDeckGroupGap at or above the floor, and both rows stay
// flush at every width — the property the exact-at-the-floor numbers above are one point of.
// Above the floor the tie-line DRIFTS, which is accepted and deliberate (§1.3): row 1 divides
// its slack over three gutters and row 2 over two, so row 2's filter edge pulls right past
// row 1's and the gap widens monotonically. Encoded as EXPECTED, not as a failure.
static void testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int lastDrift = 1 << 20; // sentinel above any real drift
for (int avail = kAvailAtMinWidth; avail <= kAvailAtMinWidth + 600; avail += 37) {
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
const DeckGroupLayout* prev = nullptr;
DeckRow prevRow = DeckRow::Spanning;
for (const DeckGroupLayout& gl : dl.groups) {
const DeckRow row = deckRowFor(static_cast<DeckGroupId>(gl.id));
if (row != DeckRow::Spanning && prev && row == prevRow) {
CHECK(gl.box.x - prev->box.right() >= kDeckGroupGap);
}
prev = &gl;
prevRow = row;
}
const auto right = [&](int id) {
return dl.groups[static_cast<std::size_t>(indexOfGroup(g, id))].box.right();
};
// Flush right on the block at every width, both rows.
CHECK(right(kGroupVoice) == right(kGroupAmpEnv));
// Monotone in width rather than oscillating: row 2's two gutters absorb slack
// faster than row 1's three, so the gap only ever opens.
const int drift = right(kGroupFilter) - right(kGroupFilterEnv);
CHECK(drift <= lastDrift);
lastDrift = drift;
}
// It really does open up, and by far more than the 2 px it starts at — separation
// above the floor is the accepted outcome, not a near-miss to be pinned back.
CHECK(lastDrift < -50);
}
}
// MASTER's interior, exact to the pixel (§1.4). The two left slots sit on the two rows' own
// knob baselines — that is what "stitched to both rows" means — and the meter is ONE rect
// across both, never a readout per row.
static void testTheMasterDeckInteriorLandsOnBothRowBaselines() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
CHECK(m.box.width == 142);
CHECK(m.box.height == 216);
// 6 + 60 + 8 + 62 + 6 — the decomposition, not just the total.
CHECK(kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62 + kDeckGroupPadX == 142);
// One cell drawn (gain) and one slot RESERVED below it: the reserve is height at a fixed
// position and draws nothing.
CHECK(m.cells.size() == 1);
CHECK(m.cells[0].id == cell(DeckParam::kMasterGain));
CHECK(m.cells[0].cell.y - m.box.y == 26);
const int reserveTop = m.cells[0].cell.y + kDeckGroupH + kDeckRowGap;
CHECK(reserveTop - m.box.y == 138);
// The two baselines are row 1's and row 2's own.
const DeckGroupLayout& filter =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
const DeckGroupLayout& amp =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupAmpEnv))];
CHECK(m.cells[0].cell.y == filter.cells[0].cell.y);
CHECK(reserveTop == amp.cells[0].cell.y);
// The meter: one rect spanning both baselines, 62 x 186.
CHECK(m.column.id == cell(DeckParam::kMasterMeter));
CHECK(m.column.box.width == 62);
CHECK(m.column.box.height == 186);
CHECK(m.column.box.y == m.cells[0].cell.y);
CHECK(m.column.box.bottom() - m.box.y == 212);
}
// The regression guard for the rule most likely to be "generalised" wrongly: MASTER's left
// column is FIXED slots at the two baselines, NOT knob_deck's horizontal run-division law
// applied vertically — which would stretch the one gain knob over the whole 186 px.
static void testTheMasterColumnDoesNotDivideItsRunVertically() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m =
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupMaster))];
CHECK(m.cells[0].cell.height == kDeckCellH);
CHECK(m.cells[0].cell.width == kDeckCellW);
// Under the run-division law the lone present cell would take the whole two-slot run;
// here it takes exactly one slot and leaves the rest empty.
CHECK(m.cells[0].cell.height < m.column.box.height);
CHECK(m.cells[0].cell.bottom() < m.column.box.bottom());
CHECK(m.cells[0].knob.width == kDeckKnobSize && m.cells[0].knob.height == kDeckKnobSize);
// And dropping the reserve does not move the gain knob or the meter — the slot below it is
// reserved height, so nothing above it depends on whether it is there.
std::vector<DeckGroupDesc> noReserve = g;
for (DeckGroupDesc& d : noReserve) {
if (d.id == kGroupMaster) d.cellIds = {cell(DeckParam::kMasterGain)};
}
const DeckLayout dl2 = layoutDeck(noReserve, kPad, 0, kAvailAtMinWidth);
const DeckGroupLayout& m2 =
dl2.groups[static_cast<std::size_t>(indexOfGroup(noReserve, kGroupMaster))];
CHECK(m2.cells[0].cell == m.cells[0].cell);
CHECK(m2.column.box == m.column.box);
} }
static void testHitTestResolvesTheNewFilterControls() { static void testHitTestResolvesTheNewFilterControls() {
@@ -377,10 +547,15 @@ static void testHitTestResolvesTheNewFilterControls() {
f.captionToggle.seg1.y + 2); f.captionToggle.seg1.y + 2);
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1); CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2); // The morph law answers from its NEW home in the caption row, and as a CaptionToggle —
CHECK(band.kind == DeckHitKind::RowToggle); // the shell's toggle branch handles both kinds, so the move must not change the id or the
// segment either.
const DeckHit band = hitTestDeck(dl, f.captionToggle2.seg0.x + 2,
f.captionToggle2.seg0.y + 2);
CHECK(band.kind == DeckHitKind::CaptionToggle);
CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0); CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0);
const DeckHit notch = hitTestDeck(dl, f.rowToggle.seg1.x + 2, f.rowToggle.seg1.y + 2); const DeckHit notch = hitTestDeck(dl, f.captionToggle2.seg1.x + 2,
f.captionToggle2.seg1.y + 2);
CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1); CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1);
// The filter-envelope knobs resolve too, and are distinct ids from the amp's. // The filter-envelope knobs resolve too, and are distinct ids from the amp's.
@@ -455,7 +630,8 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode, DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
DeckParam::kVoiceCount, DeckParam::kVoiceMode, DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kLimiterEnable,
DeckParam::kMasterMeter, DeckParam::kMasterGr,
}; };
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload); for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
@@ -640,6 +816,9 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
const DeckLayout dl = layoutDeck(g, kPad, 0, avail); const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
CHECK(dl.groups.size() == g.size()); CHECK(dl.groups.size() == g.size());
for (std::size_t i = 0; i < dl.groups.size(); ++i) { for (std::size_t i = 0; i < dl.groups.size(); ++i) {
// The spanning deck's slots STACK — the run-division law this pins is the
// horizontal one, and its vertical guard is its own test.
if (g[i].row == DeckRow::Spanning) continue;
const DeckGroupLayout& lay = dl.groups[i]; const DeckGroupLayout& lay = dl.groups[i];
const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW; const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW;
const std::size_t present = lay.cells.size(); const std::size_t present = lay.cells.size();
@@ -687,30 +866,37 @@ static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over
} }
// Gate is the common face and its group widths are what the width budget is spent against: // Every group's width, in BOTH play modes, against the measured layout table
// pin them at the floor so a later edit anywhere in the deck cannot move one silently. // (instrument-control-surface.md §1.2). Mode-independence is the second half of the claim: the
// (Measured from the shipped descriptors, not copied out of a failing run.) The WRAP row a // reserve slots hold the two mode-dependent groups at 312 either way, which is what makes the
// group lands on is deliberately NOT pinned — that is the interim greedy pack the reflow // contour row's 876 a constant rather than a Gate-only fact.
// replaces, and deckRowFor is where row membership is asserted. static void testEveryGroupWidthMatchesTheMeasuredLayout() {
static void testGateModeGroupWidthsAreUnchanged() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const struct { int id; int width; } want[] = { const struct { int id; int width; } want[] = {
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 524}, {kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 432},
{kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192}, {kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
{kGroupVoice, 164}, {kGroupMaster, 72}, {kGroupVoice, 164}, {kGroupMaster, 142},
}; };
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
CHECK(g.size() == sizeof(want) / sizeof(want[0])); CHECK(g.size() == sizeof(want) / sizeof(want[0]));
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth); const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
for (std::size_t i = 0; i < dl.groups.size(); ++i) { for (const auto& w : want) {
CHECK(dl.groups[i].id == want[i].id); const int i = indexOfGroup(g, w.id);
CHECK(deckGroupWidth(g[i]) == want[i].width); CHECK(i >= 0);
CHECK(dl.groups[i].box.width == want[i].width); if (i < 0) continue;
// Every box lands on a row line, and no lower than the second — the same two-row CHECK(deckGroupWidth(g[static_cast<std::size_t>(i)]) == w.width);
// bound the deck height carries. const DeckGroupLayout& lay =
CHECK(dl.groups[i].box.y % (kDeckGroupH + kDeckRowGap) == 0); dl.groups[static_cast<std::size_t>(indexOfGroup(g, w.id))];
CHECK(dl.groups[i].box.y <= kDeckGroupH + kDeckRowGap); CHECK(lay.box.width == w.width);
// Gate carries no reserves, so its cells are the deck's base size. }
for (const DeckCellLayout& c : dl.groups[i].cells) CHECK(c.cell.width == kDeckCellW); // Gate carries no reserves, so its cells are the deck's base size; Trigger's two
// reduced faces divide the same reserved run between fewer cells and get wider ones.
for (const DeckGroupLayout& lay : dl.groups) {
for (const DeckCellLayout& c : lay.cells) {
CHECK(c.cell.width >= kDeckCellW);
if (mode == PlayMode::Gate) CHECK(c.cell.width == kDeckCellW);
}
}
} }
} }
@@ -725,8 +911,10 @@ static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
const DeckGroupLayout& x = a.groups[i]; const DeckGroupLayout& x = a.groups[i];
const DeckGroupLayout& y = b.groups[i]; const DeckGroupLayout& y = b.groups[i];
if (x.id != y.id || !(x.box == y.box) || !(x.caption == y.caption)) return false; if (x.id != y.id || !(x.box == y.box) || !(x.caption == y.caption)) return false;
if (x.captionRadio.id != y.captionRadio.id || !(x.captionRadio.box == y.captionRadio.box)) if (x.captionRadio.id != y.captionRadio.id ||
return false; !(x.captionRadio.box == y.captionRadio.box) ||
x.captionRadio.passive != y.captionRadio.passive) return false;
if (x.column.id != y.column.id || !(x.column.box == y.column.box)) return false;
if (!sameToggle(x.captionToggle, y.captionToggle) || if (!sameToggle(x.captionToggle, y.captionToggle) ||
!sameToggle(x.captionToggle2, y.captionToggle2) || !sameToggle(x.captionToggle2, y.captionToggle2) ||
!sameToggle(x.rowToggle, y.rowToggle)) return false; !sameToggle(x.rowToggle, y.rowToggle)) return false;
@@ -786,19 +974,23 @@ int main() {
testCurveTargetNamesEachCellsOwnDestination(); testCurveTargetNamesEachCellsOwnDestination();
testVelocityCellsHitTestWithinTheirGroup(); testVelocityCellsHitTestWithinTheirGroup();
testFilterGroupCarriesItsToneControlsPlusModulation(); testFilterGroupCarriesItsToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio(); testOnlyTheThreeEnvelopeDecksCarryASelectableRadio();
testGateAndTriggerFacesCarryTheirOwnShapes(); testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial(); testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip(); testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth(); testTheDeckIsTwoRowsPlusTheSpanningDeckByConstruction();
testDeckFitsInsideTheEnforcedMinimumWindow(); testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere(); testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo(); testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testGateModeGroupWidthsAreUnchanged(); testEveryGroupWidthMatchesTheMeasuredLayout();
testGateSplineGateRoundTripsToTheSameLayout(); testGateSplineGateRoundTripsToTheSameLayout();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget(); testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
testEveryDeckGroupBelongsToExactlyOneRow(); testEveryDeckGroupBelongsToExactlyOneRow();
testTheContourRowAndTheSpanningDeckFitTheBudget(); testBothRowsAndTheSpanningDeckFitTheBudget();
testGutterArithmeticAndTheFilterTieLineAtTheFloor();
testGuttersHoldTheirMinimumAndTheTieLineDriftsAboveTheFloor();
testTheMasterDeckInteriorLandsOnBothRowBaselines();
testTheMasterColumnDoesNotDivideItsRunVertically();
testHitTestResolvesTheNewFilterControls(); testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre(); testBipolarKnobLawRoundTripsAndIsExactAtCentre();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
+180 -35
View File
@@ -5,8 +5,8 @@
// * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right // * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right
// inside the box; knob square centered; label band beneath; row toggle after the cells. // inside the box; knob square centered; label band beneath; row toggle after the cells.
// * reserves — a -1 id holds the group's width and hands its pixels to the cells present. // * reserves — a -1 id holds the group's width and hands its pixels to the cells present.
// * wrap — deterministic whole-group wrap at a narrowing width; the first group of a row // * rows — membership comes from the group's own DeckRow, never from a wrap outcome;
// always places; deckHeight consistency with deckRowCount. // space-between justification inside the row block; the right-anchored spanning deck.
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding // * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
// misses, outside-deck misses. // misses, outside-deck misses.
// * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring, // * knob-FACE hit-test — the reset resolve against the drawn circles: inner disc, outer ring,
@@ -52,42 +52,183 @@ static void testGroupWidth() {
// No toggles: max(caption, cells) + padding. // No toggles: max(caption, cells) + padding.
DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}}; DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX); CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
// A spanning group's cells STACK, so extra slots cost it no width — only its readout
// column does. Two slots measure the same as one.
DeckGroupDesc bus{5, 46, {}, {}, {}, {11}, {}, DeckRow::Spanning, {300, 62}};
CHECK(deckGroupWidth(bus) == kDeckCellW + kDeckColumnGap + 62 + 2 * kDeckGroupPadX);
bus.cellIds = {11, -1, -1};
CHECK(deckGroupWidth(bus) == kDeckCellW + kDeckColumnGap + 62 + 2 * kDeckGroupPadX);
} }
static void testWrapAtNarrowWidthIsDeterministic() { // A two-row deck with a spanning bus deck, shaped like the shipped one but with synthetic
// A width that forces this synthetic deck to wrap: TWO rows, whole trailing groups only. // widths: two Sound groups, two Contour groups, one Spanning group carrying a column.
// Deliberately narrower than the shipped editor floor — this pins the wrap MECHANISM, not static std::vector<DeckGroupDesc> tworowDeck() {
// the shipped deck's row count (that is deck_groups' own test). std::vector<DeckGroupDesc> g;
const auto deck = shellLikeDeck(); g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}, DeckRow::Sound, {}});
CHECK(deckRowCount(deck, 544) == 2); g.push_back({1, 38, {}, {101, 48}, {}, {6, 7}, {}, DeckRow::Sound, {}});
CHECK(deckHeight(deck, 544) == 2 * kDeckGroupH + kDeckRowGap); g.push_back({2, 58, {}, {102, 32}, {}, {8, 9, 10}, {}, DeckRow::Contour, {}});
const DeckLayout dl = layoutDeck(deck, 8, 100, 544); g.push_back({3, 38, {}, {103, 40}, {}, {11}, {}, DeckRow::Contour, {}});
g.push_back({4, 46, {200, true}, {104, 32}, {}, {12, -1}, {},
DeckRow::Spanning, {300, 62}});
return g;
}
// Row membership is the GROUP's, and nothing about the width can change it: the same list at
// three very different widths lays out as the same two rows plus the same spanning deck.
static void testRowMembershipComesFromTheGroupNotTheWidth() {
const auto deck = tworowDeck();
CHECK(deckRowCount(deck) == 2);
CHECK(deckHeight(deck) == 2 * kDeckGroupH + kDeckRowGap);
CHECK(deckHeight(deck) == kDeckSpanningH);
for (int avail : {600, 1174, 2000}) {
const DeckLayout dl = layoutDeck(deck, 8, 100, avail);
CHECK(dl.rowCount == 2); CHECK(dl.rowCount == 2);
CHECK(dl.height == deckHeight(deck, 544)); CHECK(dl.height == deckHeight(deck));
CHECK(dl.groups.size() == 5); CHECK(dl.groups.size() == 5);
// Row membership: groups on row 1 share the first top; the wrapped groups sit one row // The output is in DECK order, not row order — a layout pairs with the descriptor at
// pitch lower and restart at the left margin. // the same index whichever row it landed in.
const int row0Top = dl.groups[0].box.y; CHECK(dl.groups[0].id == 0 && dl.groups[1].id == 1);
const int row1Top = row0Top + kDeckGroupH + kDeckRowGap; CHECK(dl.groups[2].id == 2 && dl.groups[3].id == 3);
CHECK(dl.groups[0].box.y == row0Top); CHECK(dl.groups[4].id == 4);
CHECK(dl.groups[1].box.y == row0Top); CHECK(dl.groups[0].box.y == 100 && dl.groups[1].box.y == 100);
bool sawWrap = false; const int row1Top = 100 + kDeckGroupH + kDeckRowGap;
for (std::size_t i = 1; i < dl.groups.size(); ++i) { CHECK(dl.groups[2].box.y == row1Top && dl.groups[3].box.y == row1Top);
if (dl.groups[i].box.y == row1Top && dl.groups[i - 1].box.y == row0Top) { // Both rows start flush left.
CHECK(dl.groups[i].box.x == 8); // wrapped row restarts at the left edge CHECK(dl.groups[0].box.x == 8 && dl.groups[2].box.x == 8);
sawWrap = true; // The spanning deck stands across both rows and is right-anchored.
CHECK(dl.groups[4].box.y == 100);
CHECK(dl.groups[4].box.height == kDeckSpanningH);
CHECK(dl.groups[4].box.right() == 8 + avail);
} }
} }
CHECK(sawWrap);
// Every box stays within the available width (no group straddles the right edge).
for (const auto& g : dl.groups) CHECK(g.box.right() <= 8 + 544);
}
static void testFirstGroupAlwaysPlaces() { // Space-between: slack becomes gutters, divided equally with the integer residue on the
// A group wider than the row still places (degenerate width) — exactly one row per group. // LEFTMOST ones, and the row ends flush against the block. Decks are never stretched.
const auto deck = shellLikeDeck(); static void testJustificationSpreadsSlackIntoEqualGutters() {
CHECK(deckRowCount(deck, 100) == 5); const auto deck = tworowDeck();
CHECK(deckHeight(deck, 100) == 5 * kDeckGroupH + 4 * kDeckRowGap); const int soundW = deckGroupWidth(deck[0]) + deckGroupWidth(deck[1]);
const int contourW = deckGroupWidth(deck[2]) + deckGroupWidth(deck[3]);
const int spanW = deckGroupWidth(deck[4]);
const int avail = 900;
const int block = avail - spanW - kDeckGroupGap;
const DeckLayout dl = layoutDeck(deck, 8, 0, avail);
// Natural widths, unstretched.
CHECK(dl.groups[0].box.width == deckGroupWidth(deck[0]));
CHECK(dl.groups[1].box.width == deckGroupWidth(deck[1]));
// One gutter per row here, so it takes the whole slack and both rows end on the block.
CHECK(dl.groups[1].box.x - dl.groups[0].box.right() == block - soundW);
CHECK(dl.groups[3].box.x - dl.groups[2].box.right() == block - contourW);
CHECK(dl.groups[1].box.right() == 8 + block);
CHECK(dl.groups[3].box.right() == 8 + block);
// Three gutters over an indivisible slack: base everywhere, +1 on the leftmost ones.
std::vector<DeckGroupDesc> four = {deck[0], deck[1], deck[1], deck[1]};
int total = 0;
for (const auto& g : four) total += deckGroupWidth(g);
const int block4 = 940;
const DeckLayout d4 = layoutDeck(four, 0, 0, block4);
const int slack = block4 - total;
CHECK(slack % 3 != 0); // the case the residue rule exists for
const int base = slack / 3;
const int residue = slack % 3;
for (int i = 0; i < 3; ++i) {
const int gut = d4.groups[static_cast<std::size_t>(i + 1)].box.x -
d4.groups[static_cast<std::size_t>(i)].box.right();
CHECK(gut == base + (i < residue ? 1 : 0));
CHECK(gut >= kDeckGroupGap);
}
CHECK(d4.groups.back().box.right() == block4); // flush right
}
// Below the width the block needs, gutters floor at kDeckGroupGap and the row overruns to the
// right. It never wraps — the editor clamps its window above this, so the degrade only has to
// be defined, not pretty.
static void testTooNarrowFloorsTheGuttersRatherThanWrapping() {
const auto deck = tworowDeck();
CHECK(deckRowCount(deck) == 2); // unchanged: a row count is not a width outcome
const DeckLayout dl = layoutDeck(deck, 0, 0, 200);
CHECK(dl.rowCount == 2);
CHECK(dl.height == 2 * kDeckGroupH + kDeckRowGap);
CHECK(dl.groups[1].box.x - dl.groups[0].box.right() == kDeckGroupGap);
CHECK(dl.groups[3].box.x - dl.groups[2].box.right() == kDeckGroupGap);
CHECK(dl.groups[1].box.right() > 200); // overruns rather than wrapping
}
// The spanning deck's left column uses FIXED slots at the row baselines. Applying the
// horizontal run-division law vertically would stretch its one knob over the whole box — this
// is the regression guard against exactly that.
static void testSpanningColumnStacksFixedSlotsAndCarriesItsReadout() {
const auto deck = tworowDeck();
const DeckLayout dl = layoutDeck(deck, 8, 100, 900);
const DeckGroupLayout& bus = dl.groups[4];
CHECK(bus.cells.size() == 1); // the -1 slot reserves height without drawing a cell
const DeckCellLayout& gain = bus.cells[0];
CHECK(gain.cell.width == kDeckCellW); // fixed, NOT the box's inner width
CHECK(gain.cell.height == kDeckCellH); // fixed, NOT half the double-height box
CHECK(gain.cell.x == bus.box.x + kDeckGroupPadX);
// Slot 0 shares row 0's knob baseline; the reserve below it shares row 1's.
CHECK(gain.cell.y == dl.groups[0].cells[0].cell.y);
const int reserveTop = gain.cell.y + kDeckGroupH + kDeckRowGap;
CHECK(reserveTop == dl.groups[2].cells[0].cell.y);
// ONE readout rect spanning both slots, right of the cell column, flush to the padding.
CHECK(bus.column.id == 300);
CHECK(bus.column.box.width == 62);
CHECK(bus.column.box.x == gain.cell.right() + kDeckColumnGap);
CHECK(bus.column.box.right() == bus.box.right() - kDeckGroupPadX);
CHECK(bus.column.box.y == gain.cell.y);
CHECK(bus.column.box.bottom() == bus.box.bottom() - kDeckGroupPadY);
CHECK(bus.column.box.height == kDeckSpanningH - kDeckGroupPadY - kDeckCaptionH -
kDeckCaptionGap - kDeckGroupPadY);
// The group is exactly as wide as its two columns plus padding.
CHECK(deckGroupWidth(deck[4]) ==
2 * kDeckGroupPadX + kDeckCellW + kDeckColumnGap + 62);
// The column answers its own hit kind; the cell above it still answers as a knob.
const DeckHit col = hitTestDeck(dl, bus.column.box.x + 4, bus.column.box.y + 40);
CHECK(col.kind == DeckHitKind::Column && col.id == 300);
const DeckHit knob = hitTestDeck(dl, gain.cell.x + 4, gain.cell.y + 4);
CHECK(knob.kind == DeckHitKind::Knob && knob.id == 12);
// The reserved slot draws nothing and answers nothing — it is height, not a control.
CHECK(hitTestDeck(dl, gain.cell.x + 4, reserveTop + 4).kind == DeckHitKind::None);
}
// A passive corner radio keeps its rect (the shell draws a lamp there) but is unreachable by
// the hit-test, so no gesture can grow on it by accident.
static void testPassiveRadioIsLaidOutButNeverHit() {
const auto deck = tworowDeck();
const DeckLayout dl = layoutDeck(deck, 8, 100, 900);
const DeckGroupLayout& bus = dl.groups[4];
CHECK(bus.captionRadio.id == 200);
CHECK(bus.captionRadio.passive);
CHECK(bus.captionRadio.box.width == kDeckRadioSize);
CHECK(bus.captionRadio.box.right() == bus.box.right() - kDeckGroupPadX);
const DeckHit h = hitTestDeck(dl, bus.captionRadio.box.x + 2, bus.captionRadio.box.y + 2);
CHECK(h.kind == DeckHitKind::None);
// An INTERACTIVE radio in the same slot still answers — the flag is what changed, not the
// geometry.
std::vector<DeckGroupDesc> active{deck[4]};
active[0].captionRadio.passive = false;
const DeckLayout dl2 = layoutDeck(active, 0, 0, 400);
const DeckHit h2 = hitTestDeck(dl2, dl2.groups[0].captionRadio.box.x + 2,
dl2.groups[0].captionRadio.box.y + 2);
CHECK(h2.kind == DeckHitKind::CaptionRadio && h2.id == 200);
}
// A deck with only a spanning group is as tall as that group, not as tall as zero rows.
static void testSpanningOnlyDeckKeepsItsHeight() {
std::vector<DeckGroupDesc> only{tworowDeck()[4]};
CHECK(deckRowCount(only) == 0);
CHECK(deckHeight(only) == kDeckSpanningH);
const DeckLayout dl = layoutDeck(only, 0, 0, 400);
CHECK(dl.rowCount == 0);
CHECK(dl.height == kDeckSpanningH);
CHECK(dl.groups.size() == 1);
} }
static void testGroupInnerGeometry() { static void testGroupInnerGeometry() {
@@ -401,16 +542,20 @@ static void testInKnobFaceUsesTheSmallerDimensionOnANonSquareRect() {
static void testEmptyDeck() { static void testEmptyDeck() {
const std::vector<DeckGroupDesc> none; const std::vector<DeckGroupDesc> none;
CHECK(deckRowCount(none, 800) == 0); CHECK(deckRowCount(none) == 0);
CHECK(deckHeight(none, 800) == 0); CHECK(deckHeight(none) == 0);
const DeckLayout dl = layoutDeck(none, 0, 0, 800); const DeckLayout dl = layoutDeck(none, 0, 0, 800);
CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0); CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0);
} }
int main() { int main() {
testGroupWidth(); testGroupWidth();
testWrapAtNarrowWidthIsDeterministic(); testRowMembershipComesFromTheGroupNotTheWidth();
testFirstGroupAlwaysPlaces(); testJustificationSpreadsSlackIntoEqualGutters();
testTooNarrowFloorsTheGuttersRatherThanWrapping();
testSpanningColumnStacksFixedSlotsAndCarriesItsReadout();
testPassiveRadioIsLaidOutButNeverHit();
testSpanningOnlyDeckKeepsItsHeight();
testGroupInnerGeometry(); testGroupInnerGeometry();
testHitTest(); testHitTest();
testReservedCellWidthGoesToTheCellsPresent(); testReservedCellWidthGoesToTheCellsPresent();
+215
View File
@@ -0,0 +1,215 @@
// Standalone tests for reasampler::instrument::ui::master_meter — no VST3, no REAPER, no
// framework. Assert:
//
// * column interior — the 22/4/36 decomposition, the mono bar taking the whole field, the
// two stereo bars at 17 px and kMeterBarGap apart, all inside the column.
// * bar count — the SAME LaneSplit waveformSurface folds, over channel mode x source
// channel count, so it can never become a second rule.
// * the dB axis — top/floor land on the field's edges, it is monotone, and it clamps.
// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release; the
// audio thread's clip latch surviving a UI frame that never sampled the loud block.
// * the GR lamp — lit only while the limiter actually reduces, and decaying afterwards.
#include "../src/core/instrument/ui/master_meter.h"
#include "../src/core/instrument/ui/waveform_view.h"
#include <cmath>
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
using reasampler::instrument::engine::kMeterFallDbPerSecond;
using reasampler::instrument::engine::kMeterFloorDb;
using reasampler::instrument::engine::kMeterPeakHoldSeconds;
using reasampler::instrument::engine::kMeterTopDb;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// The shipped column: 62 px wide, 186 tall (knob_deck's spanning geometry).
static const Rect kColumn = Rect::ltrb(1114, 40, 1176, 226);
static void testColumnDividesIntoGutterAndBarField() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
CHECK(m.labels.x == kColumn.x);
CHECK(m.labels.width == kMeterLabelW);
CHECK(m.field.x == m.labels.right() + kMeterLabelGap);
CHECK(m.field.width == kMeterFieldW);
// The three parts account for the column exactly — a residue would leave dead pixels the
// scale's numerals would then be centred against.
CHECK(kMeterLabelW + kMeterLabelGap + kMeterFieldW == kColumn.width);
CHECK(m.field.right() == kColumn.right());
// Full height in both rects: the column spans both row baselines as ONE readout.
CHECK(m.labels.y == kColumn.y && m.labels.bottom() == kColumn.bottom());
CHECK(m.field.y == kColumn.y && m.field.bottom() == kColumn.bottom());
}
static void testMonoDrawsOneWideBarAndStereoDrawsTwo() {
const MeterRects mono = meterRects(kColumn, LaneSplit::Single);
CHECK(mono.barA == mono.field); // the one bar IS the field
CHECK(mono.barB.empty());
const MeterRects st = meterRects(kColumn, LaneSplit::Stereo);
CHECK(!st.barB.empty());
CHECK(st.barA.width == st.barB.width);
CHECK(st.barA.width == (kMeterFieldW - kMeterBarGap) / 2);
CHECK(st.barA.width == 17);
CHECK(st.barB.x - st.barA.right() == kMeterBarGap);
// Both bars inside the field, and the pair fills it to the pixel.
CHECK(st.barA.x == st.field.x);
CHECK(st.barB.right() == st.field.right());
CHECK(st.barA.y == st.field.y && st.barB.bottom() == st.field.bottom());
}
// The bar count is NOT a second rule: it is whatever waveformSurface resolved for the same
// (mode, source) pair. A mono source under stereo mode is dual-mono — one source, two views.
static void testBarCountFollowsTheWaveformsOwnLaneSplit() {
const Rect band = Rect::ltrb(8, 100, 1182, 458);
for (bool stereoMode : {false, true}) {
for (int sourceChannels : {1, 2}) {
const WaveformSurface s = waveformSurface(band, stereoMode, sourceChannels);
const LaneSplit split =
s.laneCount == 2 ? LaneSplit::Stereo : LaneSplit::Single;
const MeterRects m = meterRects(kColumn, split);
const int bars = m.barB.empty() ? 1 : 2;
CHECK(bars == s.laneCount);
// Spelled out per combination so a regression names which one broke.
const bool expectTwo = stereoMode && sourceChannels >= 2;
CHECK(bars == (expectTwo ? 2 : 1));
}
}
}
static void testDbAxisSpansTheFieldAndClamps() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
CHECK(meterDbToY(m.field, kMeterTopDb) == m.field.y);
CHECK(meterDbToY(m.field, kMeterFloorDb) == m.field.bottom());
// Monotone downward as the level falls.
int prev = m.field.y;
for (double db = kMeterTopDb; db >= kMeterFloorDb; db -= 6.0) {
const int y = meterDbToY(m.field, db);
CHECK(y >= prev);
prev = y;
}
// Clamped outside the scale rather than drawn off the field.
CHECK(meterDbToY(m.field, kMeterTopDb + 40.0) == m.field.y);
CHECK(meterDbToY(m.field, kMeterFloorDb - 40.0) == m.field.bottom());
}
static void testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond() {
MasterMeterUi s;
// Unity on the left, silence on the right: the two channels are independent.
s = advanceMasterMeter(s, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(std::fabs(s.left.levelDb - 0.0) < 1e-9); // rise is instantaneous, this very frame
CHECK(s.right.levelDb == kMeterFloorDb);
// One second of silence: exactly kMeterFallDbPerSecond of fall, not a smoothed decay.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 1.0);
CHECK(std::fabs(s.left.levelDb - -kMeterFallDbPerSecond) < 1e-9);
}
static void testPeakHoldSitsForItsFullWindowThenReleases() {
MasterMeterUi s;
s = advanceMasterMeter(s, {1.0, 1.0, 1.0, false}, 0.1);
const double held = s.left.holdDb;
CHECK(std::fabs(held - 0.0) < 1e-9);
// Just under the hold window: the bar has fallen a long way, the tick has not moved.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, kMeterPeakHoldSeconds - 0.01);
CHECK(s.left.levelDb < held - 20.0);
CHECK(std::fabs(s.left.holdDb - held) < 1e-9);
// Past it, the tick releases at the same 20 dB/s the bar uses.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 0.5);
CHECK(s.left.holdDb < held);
CHECK(s.left.holdDb >= s.left.levelDb);
}
// The published latch is the authoritative one: a clip between two UI frames never appears in
// the block peak this frame samples, so dropping it would silently lose the report.
static void testClipLatchesFromThePublishedFlagAndClearsOnDemand() {
MasterMeterUi s;
CHECK(!meterClipped(s));
s = advanceMasterMeter(s, {0.25, 0.25, 1.0, /*clip=*/true}, 0.1);
CHECK(meterClipped(s));
// Latched: quiet frames do not lower it.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 5.0);
CHECK(meterClipped(s));
s = clearMasterMeterClip(s);
CHECK(!meterClipped(s));
// And the UI's own sample latches it too, when the loud block IS the one sampled.
s = advanceMasterMeter(s, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(meterClipped(s));
}
static void testGrLampLitOnlyWhileTheLimiterReduces() {
MasterMeterUi s;
CHECK(!grLampLit(s));
// A gain of 1 is no reduction, however long it is held.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1);
CHECK(s.reductionDb == 0.0);
CHECK(!grLampLit(s));
// ~6 dB of reduction lights it.
s = advanceMasterMeter(s, {0.5, 0.5, 0.5, false}, 0.1);
CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3);
CHECK(grLampLit(s));
// It decays at the meter's own rate rather than snapping dark, so a transient catch is
// visible for more than the single frame it happened on.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1);
CHECK(grLampLit(s));
CHECK(s.reductionDb < 6.0206);
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 1.0);
CHECK(!grLampLit(s));
CHECK(s.reductionDb == 0.0);
}
// The tick repaints only on a change, so what counts as a change has to cover every drawn
// quantity — and only those.
static void testDrawEqualityCoversTheDrawnQuantities() {
MasterMeterUi a;
CHECK(meterDrawEqual(a, a));
MasterMeterUi loud = advanceMasterMeter(a, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(!meterDrawEqual(a, loud)); // bar + hold tick moved
MasterMeterUi clipped = a;
clipped.left.clip = true;
CHECK(!meterDrawEqual(a, clipped)); // the cap appeared
MasterMeterUi lamp = a;
lamp.reductionDb = kGrLampFloorDb;
CHECK(!meterDrawEqual(a, lamp)); // the lamp lit
// Reduction that does not cross the lamp's floor draws identically — the state differs,
// the picture does not, and a repaint there would be pure cost.
MasterMeterUi graze = a;
graze.reductionDb = kGrLampFloorDb / 2.0;
CHECK(meterDrawEqual(a, graze));
}
static void testDegenerateColumnYieldsNothing() {
const MeterRects m = meterRects(Rect::ltrb(0, 0, 0, 0), LaneSplit::Stereo);
CHECK(m.field.empty() && m.barA.empty() && m.barB.empty());
}
int main() {
testColumnDividesIntoGutterAndBarField();
testMonoDrawsOneWideBarAndStereoDrawsTwo();
testBarCountFollowsTheWaveformsOwnLaneSplit();
testDbAxisSpansTheFieldAndClamps();
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
testPeakHoldSitsForItsFullWindowThenReleases();
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
testGrLampLitOnlyWhileTheLimiterReduces();
testDrawEqualityCoversTheDrawnQuantities();
testDegenerateColumnYieldsNothing();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
return 1;
}
std::printf("master_meter tests passed\n");
return 0;
}
+4 -4
View File
@@ -131,10 +131,10 @@ static void testDeckBandIsBottomAnchoredAtTheEditorFloor() {
CHECK(b.decks.bottom() == kEditorMinHeight - kPad); CHECK(b.decks.bottom() == kEditorMinHeight - kPad);
} }
// At a representative two-row deck height (216px — the ceiling test_deck_groups.cpp bounds // At the shipped two-row deck height (216px — what test_deck_groups.cpp pins the deck to by
// the wrapped deck to), the waveform gets exactly what the floor's own height leaves it: an // construction, at and above the floor width), the waveform gets exactly what the floor's own
// equality, not a bound, so a floor-height change that quietly ate into the waveform's slack // height leaves it: an equality, not a bound, so a floor-height change that quietly ate into
// would fail here rather than only widen/narrow a `>=`. // the waveform's slack would fail here rather than only widen/narrow a `>=`.
static void testWaveformGetsExactlyTheFloorsRemainingHeightAtATwoRowDeck() { static void testWaveformGetsExactlyTheFloorsRemainingHeightAtATwoRowDeck() {
constexpr int twoRowDeckH = 216; constexpr int twoRowDeckH = 216;
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, twoRowDeckH); const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, twoRowDeckH);