Merge Γ-W3-T1: two categorical deck rows and a double-height MASTER bus deck, an exact filter tie-line at a 1028 row block, and the instrument reload decoupled from VST3 activation
This commit is contained in:
@@ -311,7 +311,7 @@ anything for a trigger shape.
|
||||
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle.
|
||||
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws.
|
||||
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. Per-channel and stage-agnostic — the MASTER column's own state (both channels plus the gain-reduction lamp) composes it in `ui/master_meter`.
|
||||
|
||||
### `map/`
|
||||
|
||||
@@ -326,11 +326,11 @@ anything for a trigger shape.
|
||||
### `ui/`
|
||||
|
||||
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for), and `kEditorCeilingWidth`, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from `knob_deck.h` since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in `test_deck_groups_measured.cpp`, the one place that already includes both headers. Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — bake Hold cell, bake, preview, velocity knob cell, loop enable, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (`sample_chrome.h` records the cost). Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
||||
- `bake_hold` — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from `sample_chrome` on the same axis `deck_values` was split from `knob_deck` — that says where the cell is, this says what its position means.
|
||||
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
||||
- `waveform_view` — the WAVEFORM band's interior: `resolveLaneSplit` is THE lane-split decision (two lanes only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane), free of any pixel geometry so the meter's bar count can ask the same question without a band rect; `waveformSurface` folds it and then measures it against the band, which is why its `laneCount` can still report 1 for a Stereo split on a band too thin to divide. It also yields **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
||||
- **The four marks.** One grammar — line + shaped cap + label — over START / LOOP / END / XFADE. `markerHandleRect` IS the cap: every mark's is the same rect shape, only the glyph inside differs, which is what keeps the claim arbitration seeing one nominal cap area. `capAtPoint` resolves caps in the REVERSE of the column order, so any coincident PAIR stays separable (one answers its cap, the other its column) and the crossfade — the one mark with no column — can never be shadowed. `layoutMarkLabels` places the promoted (grabbed/hovered) mark first and suppresses any box that would overlap one already placed. `crossfadeWedgeHeight` is the ONE ramp both the audible region and the ingredient ghost draw, because they are the same fade weight over the two spans it mixes.
|
||||
- `loop_marks` — the loop enable's state machine, split from the geometry above on the axis the surface already has: that says where a mark is, this says what the loop IS. `SampleLoop::hasLoop` is the single authority and `resolveLoopMarks`/`applyLoopMarks` are its only two folds — the resolve re-parks on `defaultLoopBounds` only when the span is one `resolveLoop` would refuse (so a user's off keeps its positions and `parked` separates the two OFF states), and the write folds collapse-to-off in and ties the crossfade to the SPAN rather than to the enable. Links `loop_span` so the span the user is offered and the span the engine accepts stay one definition.
|
||||
@@ -339,7 +339,7 @@ anything for a trigger shape.
|
||||
- `param_taper` — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and `kStageTimeMaxSeconds`, the ONE home of the stage-time ceiling that `envelope_overlay`'s `kGateStageMaxSeconds` and `deck_values`' `kEnvTimeMaxSeconds` alias), the centre-expanded semitone-depth map, `DragModifiers`/`kFineDragScale`/`fineDrag`, the `UnitCategory` axis, and the four whole-unit snaps Shift applies. Extracted from `deck_values` because it has THREE consumers in two dependency layers — the knob's needle (`deck_values`), the AHDSR schematic axis and its drag inverse (`envelope_overlay`/`envelope_edit`, which sit *below* `deck_values`), and the VST3 host's `toPlain`/`toNormalized`. **Three functions that agree today is a defect, not an implementation choice**; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
|
||||
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. `knobDragValue` is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
|
||||
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, the categorical row law, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. **Row membership is a property of the GROUP (`DeckRow`), never a wrap outcome** — the greedy whole-group wrap it replaced is gone, and the layout is the specified arrangement by construction at every width. Both categorical rows are justified SPACE-BETWEEN inside the row block (slack divided equally among the (n−1) gutters, integer residue to the leftmost, never below `kDeckGroupGap`, decks never stretched); a `DeckRow::Spanning` group is right-anchored OUTSIDE that block at `kDeckSpanningH` and takes no part in either row's justification. Below the width the block needs, gutters floor and the row overruns right rather than wrapping — the editor clamps its window above that, so the degrade only has to be defined. A spanning group reads `cellIds` DOWN, one fixed `kDeckCellW` slot per declared id at successive row baselines (reserves advance the slot), plus an optional full-height readout `column`; the run-division law below is horizontal only, and applying it vertically would stretch a lone knob over the whole box. A `DeckRadioDesc` may be `passive` — same corner slot, skipped by the hit-test, so a readout lamp cannot grow a gesture. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
||||
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
|
||||
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
|
||||
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
|
||||
@@ -357,7 +357,8 @@ anything for a trigger shape.
|
||||
drag the bank model and the WAV codec in behind it. The shell keeps only the controls the
|
||||
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||
labels for them.
|
||||
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
|
||||
- `master_meter` — the MASTER column's interior, split from `knob_deck` on the axis `sample_chrome` has to `sample_bands`: that says where the column is, this lays out inside it (22 px numeral gutter · 4 · 36 px bar field) and holds the per-instance UI state the bars draw from. `kMeterColumnW` is the SUM of those three, exported so `deck_groups`' MASTER descriptor reserves exactly what the interior consumes — the column is banked to grow, and a reserve that did not track it would underfill or overrun silently. **Bar count takes a RESOLVED `LaneSplit`, the same value `waveform_view`'s `resolveLaneSplit` answers** — a mono source under stereo mode is dual-mono, and two identical bars would be a lie. Also owns `meterTickNumeralled` (the spec-pinned 0/−12/−24/−36/−48/−60 numeral set, beside the tick step it derives from), `meterNumeralRect` (bottom-clamped, so the floor tick's numeral cannot hang out of the gutter), and `meterSingleLaneState` — the one bar folds both channels PER FIELD, never picking a whole channel by level. Composes `engine/meter_ballistics` per channel and gives the gain-reduction lamp the peak tick's own hold-then-release, without which a catch smaller than 20 dB × the UI period is dark again before it has been drawn twice; the audio thread's clip flag is ORed in because it is the only latch that sees every block. `meterDrawEqual` is what lets the UI tick repaint on change alone.
|
||||
- `deck_groups` — also home to `deckParamCommit` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there; it now discharges that reservation as the double-height bus deck — gain, the limiter enable, one reserved slot, the meter column and the GR lamp. FILTER's `Band|Notch` rides its caption slack rather than the knob row: that is the −92 px that makes the SOUND row fit its block, and putting it back breaks the fit. VOICE's `Retrig|Legato` deliberately stays in the knob row — VOICE's caption row is the binding side, so moving it there makes the group 226 rather than 164.
|
||||
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
|
||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
|
||||
@@ -87,3 +87,9 @@ reasampler_test(limiter LINK limiter)
|
||||
|
||||
reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp)
|
||||
reasampler_test(meter_ballistics LINK meter_ballistics)
|
||||
|
||||
# The meter's ACCUMULATE half, beside the ballistics that consume it. Header-only (the folds
|
||||
# sit on the audio thread's per-block path), hence INTERFACE.
|
||||
add_library(meter_accumulate INTERFACE)
|
||||
target_include_directories(meter_accumulate INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
reasampler_test(meter_accumulate LINK meter_accumulate)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// meter_accumulate.h — the master meter's ACCUMULATE half: the audio thread's block-rate fold
|
||||
// into the two windows the UI drains, and the drain that starts the next window. The ballistics
|
||||
// that run on what comes out are meter_ballistics'. Header-only — the folds sit on the audio
|
||||
// thread's per-block path. The folds are templated on the accumulator ONLY so the
|
||||
// drain-inside-the-fold interleave below can be pinned deterministically instead of raced for.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// A lock-backed std::atomic<float> would put a mutex on the audio thread; assert the freedom
|
||||
// rather than assume it.
|
||||
static_assert(std::atomic<float>::is_always_lock_free,
|
||||
"the meter folds run on the audio thread and must be lock-free");
|
||||
|
||||
// The two windows' identity elements: a peak window that has seen nothing reports silence, a
|
||||
// gain window that has seen nothing reports no reduction. They are what a consume reinstalls,
|
||||
// so they live beside the folds rather than at the reader.
|
||||
inline constexpr float kMeterPeakIdentity = 0.f;
|
||||
inline constexpr float kMeterGainIdentity = 1.f;
|
||||
|
||||
// Folds one block's reading into its accumulator — a running max for a peak, a running min for
|
||||
// the limiter's gain — so the ~47 blocks that elapse between two 500 ms UI frames at 48 kHz/512
|
||||
// all reach the meter instead of the one it happened to sample.
|
||||
//
|
||||
// An UNCONDITIONAL read-modify-write, and that is the whole point. The UI's consume is an
|
||||
// exchange that can land between a plain load and its store, and a load-compare-store fold
|
||||
// would then drop the block outright: it decided against storing by comparing with a window the
|
||||
// UI has since taken, so that block's reading enters neither the old window nor the new one.
|
||||
// The CAS retries against whatever the consume left, which makes `acc >= blockPeak` hold on
|
||||
// exit however the two interleave. STRONG, so the loop is bounded by the interference it is
|
||||
// written against: the audio thread is the only writer besides the UI's single consume, and
|
||||
// weak's permitted spurious failure would make an unbounded retry count reachable with no
|
||||
// interference at all. Three calls per block, so the strong form costs nothing measurable.
|
||||
// Relaxed throughout: the accumulators are advisory and order no other state. `Accumulator` is
|
||||
// templated only so a test can pin the interleave; it must behave as std::atomic<float>.
|
||||
template <class Accumulator>
|
||||
inline void foldPeak(Accumulator& acc, float blockPeak) {
|
||||
float seen = acc.load(std::memory_order_relaxed);
|
||||
while (!acc.compare_exchange_strong(seen, seen > blockPeak ? seen : blockPeak,
|
||||
std::memory_order_relaxed,
|
||||
std::memory_order_relaxed)) {
|
||||
}
|
||||
}
|
||||
|
||||
template <class Accumulator>
|
||||
inline void foldMinGain(Accumulator& acc, float blockMinGain) {
|
||||
float seen = acc.load(std::memory_order_relaxed);
|
||||
while (!acc.compare_exchange_strong(seen, seen < blockMinGain ? seen : blockMinGain,
|
||||
std::memory_order_relaxed,
|
||||
std::memory_order_relaxed)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Takes what the window accumulated and reinstalls the identity element, which IS what starts
|
||||
// the next window — so exactly one reader may consume (the shell's MasterBusMeter states who).
|
||||
// Concrete: only the folds have the interleave a test seam buys, and a template over one
|
||||
// instantiation models nothing.
|
||||
inline float consumePeak(std::atomic<float>& acc) {
|
||||
return acc.exchange(kMeterPeakIdentity, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
inline float consumeMinGain(std::atomic<float>& acc) {
|
||||
return acc.exchange(kMeterGainIdentity, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -104,6 +104,12 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
}
|
||||
}
|
||||
|
||||
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b) {
|
||||
return a.relativePath == b.relativePath && a.rootNote == b.rootNote &&
|
||||
a.channelCount == b.channelCount && a.loop.hasLoop == b.loop.hasLoop &&
|
||||
a.loop.start == b.loop.start && a.loop.end == b.loop.end;
|
||||
}
|
||||
|
||||
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
|
||||
const std::vector<std::string>& ids) {
|
||||
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
|
||||
|
||||
@@ -85,6 +85,13 @@ std::vector<std::string> referencedSampleIds(const std::string& selectionId);
|
||||
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
const std::vector<std::string>& ids);
|
||||
|
||||
// True when two refs would build the same SampleData: path plus every intrinsic
|
||||
// resolveCapture folds. displayName is excluded on purpose — it is a label, never a decode
|
||||
// input. Exists so a caller holding an ALREADY-DECODED sample can ask whether a refresh moved
|
||||
// what that sample was decoded from; comparing the fields at the call site instead would go
|
||||
// stale the first time this struct gains one.
|
||||
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b);
|
||||
|
||||
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
|
||||
// for the ids the instance references?
|
||||
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
|
||||
|
||||
@@ -20,13 +20,11 @@ reasampler_test(capture_browser LINK capture_browser)
|
||||
reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
|
||||
|
||||
# sample_bands is PRIVATE: the lane split is used internally and nothing in the public
|
||||
# header needs it.
|
||||
# sample_bands is PUBLIC since resolveLaneSplit answers in its LaneSplit — the meter's bar
|
||||
# count consumes that answer, so the type is part of this module's surface, not an internal.
|
||||
reasampler_pure_library(waveform_view
|
||||
SOURCES waveform_view.cpp
|
||||
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands)
|
||||
# sample_bands is linked directly here because the test exercises the lane metrics that
|
||||
# waveform_view does not re-export.
|
||||
LINK PUBLIC editor_geometry peaks sample_bands)
|
||||
reasampler_test(waveform_view LINK waveform_view sample_bands)
|
||||
|
||||
# The loop enable's state machine. Links loop_span for the park bounds — the span the user is
|
||||
@@ -57,17 +55,37 @@ reasampler_test(envelope_edit LINK envelope_edit)
|
||||
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_test(knob_deck LINK knob_deck)
|
||||
|
||||
# The spanning deck's meter column, split from knob_deck on the axis sample_chrome has to
|
||||
# sample_bands: that says where the column is, this lays out inside it. sample_bands is PUBLIC
|
||||
# for LaneSplit — the bar count is the SAME resolved decision the waveform's lane split is.
|
||||
reasampler_pure_library(master_meter
|
||||
SOURCES master_meter.cpp
|
||||
LINK PUBLIC editor_geometry sample_bands meter_ballistics)
|
||||
# waveform_view is linked for the test only: proving the bar count is not a second rule takes
|
||||
# the real waveformSurface fold, over channel mode x source channel count.
|
||||
reasampler_test(master_meter LINK master_meter waveform_view)
|
||||
|
||||
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
|
||||
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
|
||||
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
|
||||
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
|
||||
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
|
||||
# master_meter is PRIVATE: MASTER's descriptor reserves the meter column's own kMeterColumnW,
|
||||
# but nothing in deck_groups.h names a meter type, so the edge stops at this TU.
|
||||
reasampler_pure_library(deck_groups
|
||||
SOURCES deck_groups.cpp
|
||||
LINK PUBLIC knob_deck velocity_curve peaks curve_law)
|
||||
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
|
||||
# needs the band allocator deck_groups itself has no reason to depend on.
|
||||
reasampler_test(deck_groups LINK deck_groups sample_bands)
|
||||
LINK PUBLIC knob_deck velocity_curve peaks curve_law PRIVATE master_meter)
|
||||
# WHICH descriptors the deck carries, and how they resolve to a layout — no window-floor budget
|
||||
# assertion here, so this target needs neither sample_bands nor master_meter.
|
||||
reasampler_test(deck_groups LINK deck_groups)
|
||||
# The width-BUDGET half, split out on the same seam PRIVATE master_meter already draws above:
|
||||
# the deck-fits-the-floor-window assertion needs the band allocator, and the MASTER-reserve
|
||||
# identity needs the column width the PRIVATE edge on deck_groups does not re-export.
|
||||
reasampler_test(deck_groups_measured LINK deck_groups sample_bands master_meter)
|
||||
# The commit-tier + overlay-selection state machine, split out of deck_groups_tests on the seam
|
||||
# those fixtures already had: deckParamCommit/liveCommitFor and the overlay predicates are pure
|
||||
# control-id/enum logic that touches no layout, so this target needs no sample_bands/master_meter.
|
||||
reasampler_test(deck_groups_state LINK deck_groups)
|
||||
|
||||
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
|
||||
# envelope_overlay/envelope_edit, which stay engine-free — the staged envelopes touch no curve).
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "core/instrument/ui/master_meter.h" // kMeterColumnW (what the column's interior needs)
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
@@ -11,8 +13,9 @@ int id(DeckParam p) { return static_cast<int>(p); }
|
||||
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
|
||||
// no wider than its knob row — the ceiling is PITCH ENV's, whose caption row lands exactly on
|
||||
// its four-cell knob row at 23. Raising it reflows the deck's first row.
|
||||
// no wider than its knob row; the binding group is PITCH ENV, which reaches its four-cell knob
|
||||
// row at 47 (AMP, the next tightest, at 55). Well inside the ceiling — raising it would widen
|
||||
// the CONTOUR row, which has 152px of slack, not the SOUND row.
|
||||
constexpr int kEnvModeSegW = 23;
|
||||
} // namespace
|
||||
|
||||
@@ -63,7 +66,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
id(DeckParam::kFilterModAmt),
|
||||
id(DeckParam::kFilterVel),
|
||||
id(DeckParam::kFilterKeyTrack)};
|
||||
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
|
||||
// The morph law rides the caption slack. Moving it back to the knob row costs the
|
||||
// group 92px and the SOUND row stops fitting its block.
|
||||
filter.captionToggle2 = {id(DeckParam::kFilterLaw), 44};
|
||||
out.push_back(std::move(filter));
|
||||
}
|
||||
{
|
||||
@@ -126,12 +131,21 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
out.push_back(std::move(voice));
|
||||
}
|
||||
{
|
||||
// The lower slot is reserved and draws NOTHING: blank reads as breathing room where a
|
||||
// dashed placeholder would read as unfinished. It is one cell, not two — a second
|
||||
// would spend 60 of the layout's whole 82px budget on a control nobody has named.
|
||||
DeckGroupDesc master;
|
||||
master.id = kGroupMaster;
|
||||
master.captionWidth = 46;
|
||||
master.cellIds = {id(DeckParam::kMasterGain)};
|
||||
master.captionRadio = {id(DeckParam::kMasterGr), /*passive=*/true};
|
||||
master.captionToggle = {id(DeckParam::kLimiterEnable), 32};
|
||||
master.cellIds = {id(DeckParam::kMasterGain), -1};
|
||||
// The reserve IS what the interior consumes — read from master_meter rather than
|
||||
// restated, so the two cannot drift when the column grows into MASTER's banked room.
|
||||
master.column = {id(DeckParam::kMasterMeter), kMeterColumnW};
|
||||
out.push_back(std::move(master));
|
||||
}
|
||||
for (DeckGroupDesc& d : out) d.row = deckRowFor(static_cast<DeckGroupId>(d.id));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -257,6 +271,11 @@ LiveCommit deckParamCommit(DeckParam id) {
|
||||
case DeckParam::kVoiceMode:
|
||||
case DeckParam::kMonoTrigger:
|
||||
case DeckParam::kMasterGain:
|
||||
case DeckParam::kLimiterEnable:
|
||||
// MASTER's two readouts reach no parameter at all — the same footing as the overlay
|
||||
// radios above.
|
||||
case DeckParam::kMasterMeter:
|
||||
case DeckParam::kMasterGr:
|
||||
case DeckParam::kCount: // not a control
|
||||
return LiveCommit::Reload;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,11 @@ enum class DeckParam {
|
||||
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
||||
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
||||
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
||||
kLimiterEnable, // master-bus limiter Off | On caption toggle (MASTER group)
|
||||
// MASTER's two readouts. Neither reaches a parameter: the meter's only gesture is the
|
||||
// click that clears its latched clip cap, and the bubble is a passive lamp.
|
||||
kMasterMeter,
|
||||
kMasterGr,
|
||||
kCount
|
||||
};
|
||||
|
||||
@@ -103,14 +108,10 @@ enum DeckGroupId {
|
||||
kGroupMaster,
|
||||
};
|
||||
|
||||
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice
|
||||
// IS, Contour is how it moves over time, Spanning is what happens after the mixer.
|
||||
enum class DeckRow { Sound, Contour, Spanning };
|
||||
|
||||
// Which row a group belongs to. Membership is a property of the GROUP; width is a property of
|
||||
// its descriptor — separating them is what lets the row law be settled while the descriptors
|
||||
// are still moving. Total over DeckGroupId by an exhaustive switch with no default, so a group
|
||||
// added without a row cannot silently become Sound.
|
||||
// Which row a group belongs to (the row vocabulary itself is knob_deck's — the layout is what
|
||||
// reads it). Membership is a property of the GROUP; width is a property of its descriptor.
|
||||
// Total over DeckGroupId by an exhaustive switch with no default, so a group added without a
|
||||
// row cannot silently become Sound.
|
||||
DeckRow deckRowFor(DeckGroupId group);
|
||||
|
||||
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
|
||||
|
||||
@@ -8,10 +8,19 @@ namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
|
||||
// already carries its own breathing room around the 28px knob), plus the optional row
|
||||
// toggle after a kDeckToggleGap.
|
||||
// The knob-row width of a group: cells side by side (no inter-cell gap — the 60px cell
|
||||
// already carries its own breathing room around the 40px knob), plus the optional row
|
||||
// toggle after a kDeckToggleGap. A spanning group's cells stack, so its knob row is one
|
||||
// cell wide plus whatever readout column sits beside it.
|
||||
int knobRowWidth(const DeckGroupDesc& g) {
|
||||
if (g.row == DeckRow::Spanning) {
|
||||
int w = g.cellIds.empty() ? 0 : kDeckCellW;
|
||||
if (g.column.id >= 0) {
|
||||
if (w > 0) w += kDeckColumnGap;
|
||||
w += g.column.width;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
int w = static_cast<int>(g.cellIds.size()) * kDeckCellW;
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (w > 0) w += kDeckToggleGap;
|
||||
@@ -29,6 +38,24 @@ int captionRowWidth(const DeckGroupDesc& g) {
|
||||
return w;
|
||||
}
|
||||
|
||||
// One knob cell inside `cell`: the centered dial square, its concentric inner disc, and the
|
||||
// label band beneath.
|
||||
DeckCellLayout layoutCell(int id, const Rect& cell) {
|
||||
DeckCellLayout c;
|
||||
c.id = id;
|
||||
c.cell = cell;
|
||||
const int knobLeft = cell.x + (cell.width - kDeckKnobSize) / 2;
|
||||
const int knobTop = cell.y + 4;
|
||||
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
|
||||
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
|
||||
innerTopPx + kDeckInnerDialSize);
|
||||
const int labelTop = knobTop + kDeckKnobSize + 4;
|
||||
c.label = Rect::ltrb(cell.x, labelTop, cell.right(), labelTop + kDeckCellLabelH);
|
||||
return c;
|
||||
}
|
||||
|
||||
// Place one group's inner geometry given its box.
|
||||
DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
DeckGroupLayout out;
|
||||
@@ -46,7 +73,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
|
||||
out.captionRadio = DeckRadioLayout{
|
||||
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
|
||||
radioTop + kDeckRadioSize)};
|
||||
radioTop + kDeckRadioSize),
|
||||
g.captionRadio.passive};
|
||||
captionRight = out.captionRadio.box.x - kDeckToggleGap;
|
||||
out.caption.width = captionRight - out.caption.x;
|
||||
}
|
||||
@@ -65,10 +93,36 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
placeToggle(g.captionToggle, out.captionToggle);
|
||||
placeToggle(g.captionToggle2, out.captionToggle2);
|
||||
|
||||
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
|
||||
|
||||
if (g.row == DeckRow::Spanning) {
|
||||
// FIXED slots down the left column, one per declared id (reserves advance the slot
|
||||
// without drawing a cell), spaced by a whole row pitch so slot k lands exactly on
|
||||
// categorical row k's knob baseline. Deliberately NOT the run-division law below.
|
||||
int slotTop = cellTop;
|
||||
for (int id : g.cellIds) {
|
||||
if (id >= 0) {
|
||||
out.cells.push_back(layoutCell(
|
||||
id, Rect::ltrb(innerLeft, slotTop, innerLeft + kDeckCellW,
|
||||
slotTop + kDeckCellH)));
|
||||
}
|
||||
slotTop += kDeckGroupH + kDeckRowGap;
|
||||
}
|
||||
if (g.column.id >= 0) {
|
||||
// ONE rect spanning every slot, not a readout per row. Right-anchored off
|
||||
// innerRight rather than measured past the cell slot, so a wider caption
|
||||
// reserve on this group can never detach the column from the padding.
|
||||
const int colX = innerRight - g.column.width;
|
||||
out.column = DeckColumnLayout{
|
||||
g.column.id, Rect::ltrb(colX, cellTop, colX + g.column.width,
|
||||
box.bottom() - kDeckGroupPadY)};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
|
||||
// id, reserves included). Integer division puts an indivisible residue in symmetric end
|
||||
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
|
||||
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
|
||||
const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
|
||||
int presentCells = 0;
|
||||
for (int id : g.cellIds) {
|
||||
@@ -78,19 +132,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
|
||||
for (int id : g.cellIds) {
|
||||
if (id < 0) continue;
|
||||
DeckCellLayout c;
|
||||
c.id = id;
|
||||
c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
|
||||
const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
|
||||
const int knobTop = cellTop + 4;
|
||||
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
|
||||
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
|
||||
innerTopPx + kDeckInnerDialSize);
|
||||
const int labelTop = knobTop + kDeckKnobSize + 4;
|
||||
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
|
||||
out.cells.push_back(c);
|
||||
out.cells.push_back(layoutCell(id, Rect::ltrb(x, cellTop, x + cellW,
|
||||
cellTop + kDeckCellH)));
|
||||
x += cellW;
|
||||
}
|
||||
if (g.rowToggle.id >= 0) {
|
||||
@@ -107,65 +150,110 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The gutters between `count` groups whose widths total `total`, justified space-between
|
||||
// inside `blockW`. Empty for a single group.
|
||||
std::vector<int> justifyGutters(int count, int total, int blockW) {
|
||||
const int gutters = count - 1;
|
||||
if (gutters <= 0) return {};
|
||||
const int slack = blockW - total;
|
||||
if (slack < gutters * kDeckGroupGap) {
|
||||
// The block cannot hold the row: minimum gutters, and the row overruns to the right
|
||||
// rather than wrapping — see layoutDeck's header note for when this degrade applies.
|
||||
return std::vector<int>(static_cast<std::size_t>(gutters), kDeckGroupGap);
|
||||
}
|
||||
const int base = slack / gutters;
|
||||
const int residue = slack % gutters; // both non-negative: slack >= gutters * 12 > 0
|
||||
std::vector<int> out(static_cast<std::size_t>(gutters), base);
|
||||
for (int i = 0; i < residue; ++i) ++out[static_cast<std::size_t>(i)];
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int deckGroupWidth(const DeckGroupDesc& g) {
|
||||
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
|
||||
}
|
||||
|
||||
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth) {
|
||||
if (groups.empty()) return 0;
|
||||
int rows = 1;
|
||||
int x = 0;
|
||||
int deckRowCount(const std::vector<DeckGroupDesc>& groups) {
|
||||
bool sound = false, contour = false;
|
||||
for (const DeckGroupDesc& g : groups) {
|
||||
const int w = deckGroupWidth(g);
|
||||
if (x > 0 && x + kDeckGroupGap + w > availWidth) {
|
||||
++rows;
|
||||
x = w;
|
||||
} else {
|
||||
x += (x > 0 ? kDeckGroupGap : 0) + w;
|
||||
}
|
||||
if (g.row == DeckRow::Sound) sound = true;
|
||||
else if (g.row == DeckRow::Contour) contour = true;
|
||||
}
|
||||
return rows;
|
||||
return (sound ? 1 : 0) + (contour ? 1 : 0);
|
||||
}
|
||||
|
||||
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth) {
|
||||
const int rows = deckRowCount(groups, availWidth);
|
||||
if (rows == 0) return 0;
|
||||
return rows * kDeckGroupH + (rows - 1) * kDeckRowGap;
|
||||
int deckHeight(const std::vector<DeckGroupDesc>& groups) {
|
||||
const int rows = deckRowCount(groups);
|
||||
int h = rows > 0 ? rows * kDeckGroupH + (rows - 1) * kDeckRowGap : 0;
|
||||
for (const DeckGroupDesc& g : groups) {
|
||||
if (g.row == DeckRow::Spanning) h = (std::max)(h, kDeckSpanningH);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
|
||||
int availWidth) {
|
||||
DeckLayout out;
|
||||
if (groups.empty()) return out;
|
||||
int x = left;
|
||||
int y = top;
|
||||
bool rowHasGroup = false;
|
||||
out.rowCount = 1;
|
||||
for (const DeckGroupDesc& g : groups) {
|
||||
const int w = deckGroupWidth(g);
|
||||
if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) {
|
||||
// Wrap: whole trailing group onto the next row (mirror of deckRowCount).
|
||||
++out.rowCount;
|
||||
x = left;
|
||||
y += kDeckGroupH + kDeckRowGap;
|
||||
rowHasGroup = false;
|
||||
}
|
||||
if (rowHasGroup) x += kDeckGroupGap;
|
||||
const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
|
||||
out.groups.push_back(layoutGroup(g, box));
|
||||
x = box.right();
|
||||
rowHasGroup = true;
|
||||
|
||||
// Partition by the group's OWN row (indices, so the OUTPUT keeps deck order — the shell
|
||||
// and the tests pair a layout with the descriptor at the same position).
|
||||
std::vector<std::size_t> rows[2];
|
||||
std::vector<std::size_t> spanning;
|
||||
for (std::size_t i = 0; i < groups.size(); ++i) {
|
||||
const DeckRow row = groups[i].row;
|
||||
if (row == DeckRow::Spanning) spanning.push_back(i);
|
||||
else rows[row == DeckRow::Contour ? 1 : 0].push_back(i);
|
||||
}
|
||||
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
|
||||
|
||||
// The spanning decks take the right edge; the row block is what is left of them.
|
||||
int spanTotal = 0;
|
||||
for (std::size_t i : spanning) spanTotal += deckGroupWidth(groups[i]);
|
||||
if (!spanning.empty()) {
|
||||
spanTotal += (static_cast<int>(spanning.size()) - 1) * kDeckGroupGap;
|
||||
}
|
||||
const int blockW = availWidth - (spanning.empty() ? 0 : spanTotal + kDeckGroupGap);
|
||||
|
||||
std::vector<Rect> boxes(groups.size());
|
||||
int y = top;
|
||||
for (const std::vector<std::size_t>& row : rows) {
|
||||
if (row.empty()) continue; // an absent category collapses; it leaves no empty band
|
||||
++out.rowCount;
|
||||
int total = 0;
|
||||
for (std::size_t i : row) total += deckGroupWidth(groups[i]);
|
||||
const std::vector<int> gutters =
|
||||
justifyGutters(static_cast<int>(row.size()), total, blockW);
|
||||
int x = left;
|
||||
for (std::size_t k = 0; k < row.size(); ++k) {
|
||||
const int w = deckGroupWidth(groups[row[k]]);
|
||||
boxes[row[k]] = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
|
||||
x += w;
|
||||
if (k < gutters.size()) x += gutters[k];
|
||||
}
|
||||
y += kDeckGroupH + kDeckRowGap;
|
||||
}
|
||||
|
||||
int sx = left + availWidth - spanTotal;
|
||||
for (std::size_t i : spanning) {
|
||||
const int w = deckGroupWidth(groups[i]);
|
||||
boxes[i] = Rect::ltrb(sx, top, sx + w, top + kDeckSpanningH);
|
||||
sx += w + kDeckGroupGap;
|
||||
}
|
||||
|
||||
out.groups.reserve(groups.size());
|
||||
for (std::size_t i = 0; i < groups.size(); ++i) {
|
||||
out.groups.push_back(layoutGroup(groups[i], boxes[i]));
|
||||
}
|
||||
out.height = deckHeight(groups);
|
||||
return out;
|
||||
}
|
||||
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
for (const DeckGroupLayout& g : layout.groups) {
|
||||
if (!contains(g.box, x, y)) continue;
|
||||
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
|
||||
if (g.captionRadio.id >= 0 && !g.captionRadio.passive &&
|
||||
contains(g.captionRadio.box, x, y)) {
|
||||
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
|
||||
}
|
||||
for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
|
||||
@@ -185,7 +273,13 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
|
||||
}
|
||||
}
|
||||
return {}; // inside the box but on fence/padding — a miss (groups never overlap)
|
||||
if (g.column.id >= 0 && contains(g.column.box, x, y)) {
|
||||
return {DeckHitKind::Column, g.column.id, -1, false};
|
||||
}
|
||||
// Inside the box but on fence/padding — a miss. First-match is exact while the boxes
|
||||
// are disjoint, which they are at every width the row block fits; under the sub-floor
|
||||
// overrun an overrunning row can reach the spanning deck and the row group answers.
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
// knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free
|
||||
// like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of
|
||||
// action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
|
||||
// param_slider's — a knob cell here is just a rect the shell composes it into.
|
||||
//
|
||||
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
|
||||
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob
|
||||
// row of equal-width cells (knob centered, label band beneath). A group may also place one
|
||||
// two-segment toggle in the knob row after its cells. Groups that must keep stable
|
||||
// geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows
|
||||
// neighbouring groups.
|
||||
//
|
||||
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
|
||||
// that does not fit the remaining width starts a new row (whole groups only, never
|
||||
// split); the first group of a row always places even if wider than the row.
|
||||
// param_slider's — a knob cell here is just a rect the shell composes it into. Group/row
|
||||
// composition and the justification law are this directory's own CLAUDE.md's to describe.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -37,8 +27,10 @@ inline constexpr int kDeckGroupPadY = 4; // group box vertical inner paddin
|
||||
inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
|
||||
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
|
||||
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
|
||||
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
|
||||
inline constexpr int kDeckRowGap = 8; // gap between the deck's two categorical rows,
|
||||
// and between the spanning deck's stacked slots
|
||||
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
|
||||
inline constexpr int kDeckColumnGap = 8; // the spanning deck's cell column -> its readout column
|
||||
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
|
||||
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
|
||||
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
|
||||
@@ -46,6 +38,15 @@ inline constexpr int kDeckInnerDialSize = 20;
|
||||
// One group box: padding + caption + gap + cell row + padding.
|
||||
inline constexpr int kDeckGroupH =
|
||||
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
|
||||
// The spanning deck's box: it stands across both categorical rows AND the seam between them,
|
||||
// which is what lets its two cell slots land on the two rows' own knob baselines.
|
||||
inline constexpr int kDeckSpanningH = 2 * kDeckGroupH + kDeckRowGap;
|
||||
|
||||
// The deck's two categorical rows, plus the row-spanning bus deck. Sound is what the voice
|
||||
// IS, Contour is how it moves over time, Spanning is what happens after the mixer. Declared
|
||||
// here rather than with the group inventory because the LAYOUT is what reads it; which group
|
||||
// sits in which row is deck_groups' deckRowFor.
|
||||
enum class DeckRow { Sound, Contour, Spanning };
|
||||
|
||||
// --- The deck's width budget at the editor's floor ------------------------------------
|
||||
// DECLARATIONS of budget, not measurements: nothing here is computed from a descriptor, and a
|
||||
@@ -53,10 +54,14 @@ inline constexpr int kDeckGroupH =
|
||||
// from the first two — kDeckRowBlockW + kDeckGroupGap + kDeckSpanningW + 2*kPad — and the
|
||||
// identity is asserted in test_deck_groups.cpp rather than coded, so the allocator keeps no
|
||||
// include edge to this header.
|
||||
inline constexpr int kDeckRowBlockW = 1020; // the block both categorical rows justify inside
|
||||
// 1028 is the width at which the justification law puts BOTH rows' filter groups on the same
|
||||
// right edge (x = 640 block-relative) AND divides row 1's slack into three equal gutters. The
|
||||
// narrower 1020 delivered neither: 40 px over three gutters is 13⅓, so the law produced
|
||||
// 14/13/13 and left row 1's filter edge 2 px past row 2's.
|
||||
inline constexpr int kDeckRowBlockW = 1028; // the block both categorical rows justify inside
|
||||
inline constexpr int kDeckSpanningW = 142; // the right-anchored spanning deck, outside the block
|
||||
// The hard ceiling the floor may not exceed lives beside the floor itself, in sample_bands.h's
|
||||
// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 90px,
|
||||
// kEditorCeilingWidth — a window fact, not a deck one. Today's gap between the two is 82px,
|
||||
// the whole width budget for the life of this layout (asserted in test_deck_groups.cpp) — see
|
||||
// instrument-control-surface.md §1.6 before spending any of it.
|
||||
|
||||
@@ -67,15 +72,29 @@ struct DeckToggleDesc {
|
||||
};
|
||||
|
||||
// A single-square corner radio (an exclusive selector across groups, so the group itself
|
||||
// carries no state). id -1 = absent.
|
||||
// carries no state). id -1 = absent. `passive` reuses the same slot for a READOUT lamp: the
|
||||
// hit-test skips it entirely, so the shell cannot accidentally grow a gesture on it.
|
||||
struct DeckRadioDesc {
|
||||
int id = -1;
|
||||
bool passive = false;
|
||||
};
|
||||
|
||||
// A full-height readout column beside a spanning group's cell slots (the output meter). Only
|
||||
// a Spanning group may carry one — a categorical row's groups have no height to span.
|
||||
// id -1 = absent.
|
||||
struct DeckColumnDesc {
|
||||
int id = -1;
|
||||
int width = 0;
|
||||
};
|
||||
|
||||
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
|
||||
// reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
|
||||
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
|
||||
// reserves for the caption text (this module does not measure text).
|
||||
//
|
||||
// A SPANNING group reads `cellIds` down instead of across: one FIXED kDeckCellW slot per
|
||||
// declared id, at successive row baselines, reserves included. The run-division law above is
|
||||
// horizontal only — applied vertically it would stretch a lone knob over the whole box.
|
||||
struct DeckGroupDesc {
|
||||
int id = 0; // shell group id (opaque here)
|
||||
int captionWidth = 60;
|
||||
@@ -87,6 +106,8 @@ struct DeckGroupDesc {
|
||||
DeckToggleDesc captionToggle2;
|
||||
std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above)
|
||||
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
|
||||
DeckRow row = DeckRow::Sound;
|
||||
DeckColumnDesc column; // Spanning groups only; id -1 = none
|
||||
};
|
||||
|
||||
// --- Laid-out geometry ---------------------------------------------------------------
|
||||
@@ -100,6 +121,12 @@ struct DeckToggleLayout {
|
||||
struct DeckRadioLayout {
|
||||
int id = -1;
|
||||
Rect box;
|
||||
bool passive = false; // a readout lamp, not a selector — see DeckRadioDesc
|
||||
};
|
||||
|
||||
struct DeckColumnLayout {
|
||||
int id = -1;
|
||||
Rect box;
|
||||
};
|
||||
|
||||
struct DeckCellLayout {
|
||||
@@ -119,34 +146,46 @@ struct DeckGroupLayout {
|
||||
DeckToggleLayout captionToggle2;
|
||||
std::vector<DeckCellLayout> cells;
|
||||
DeckToggleLayout rowToggle; // id -1 when absent
|
||||
DeckColumnLayout column; // id -1 when absent (Spanning groups only)
|
||||
};
|
||||
|
||||
struct DeckLayout {
|
||||
std::vector<DeckGroupLayout> groups;
|
||||
int rowCount = 0;
|
||||
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups
|
||||
int rowCount = 0; // POPULATED categorical rows (0..2). A spanning deck is in neither.
|
||||
int height = 0; // the tallest thing laid out; 0 for no groups
|
||||
};
|
||||
|
||||
// Width of one group box: the wider of its caption row (caption + gap + toggle) and its
|
||||
// knob row (cells + gap + row toggle), plus horizontal padding.
|
||||
// Width of one group box: the wider of its caption row (caption + gap + toggles + radio) and
|
||||
// its knob row, plus horizontal padding. A Spanning group's knob row is one cell wide plus
|
||||
// its readout column, because its cells stack.
|
||||
int deckGroupWidth(const DeckGroupDesc& g);
|
||||
|
||||
// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap.
|
||||
// 0 for an empty list.
|
||||
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
|
||||
// How many of the two categorical rows carry at least one group (0..2). Independent of width:
|
||||
// row membership is the group's own property.
|
||||
int deckRowCount(const std::vector<DeckGroupDesc>& groups);
|
||||
|
||||
// Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell
|
||||
// Total deck height: the categorical rows, or the spanning deck when it is taller. The shell
|
||||
// bottom-anchors a band of exactly this height.
|
||||
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
|
||||
int deckHeight(const std::vector<DeckGroupDesc>& groups);
|
||||
|
||||
// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's
|
||||
// rule. Every rect is absolute.
|
||||
// Lays the groups out from (left, top) within `availWidth`. Every rect is absolute, and
|
||||
// `groups` comes back in DECK order — the same position as the descriptor it was built from,
|
||||
// whichever row that descriptor landed in.
|
||||
//
|
||||
// Spanning groups are right-anchored at `left + availWidth` and take no part in either row's
|
||||
// justification; the ROW BLOCK is what remains to their left. Inside the block each row is
|
||||
// justified SPACE-BETWEEN: groups keep their natural widths and the slack becomes gutters,
|
||||
// divided equally with the integer residue going to the leftmost ones. Decks are never
|
||||
// stretched. Below the width the block needs, every gutter sits at kDeckGroupGap and the row
|
||||
// overflows right rather than wrapping — the shell clamps the window to a floor that fits
|
||||
// (sample_bands' kEditorMinWidth) via checkSizeConstraint, a host-honoured clamp rather than a
|
||||
// guarantee, so this degrade is defined and tested rather than assumed impossible.
|
||||
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
|
||||
int availWidth);
|
||||
|
||||
// --- Hit-test --------------------------------------------------------------------------
|
||||
|
||||
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio };
|
||||
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio, Column };
|
||||
|
||||
struct DeckHit {
|
||||
DeckHitKind kind = DeckHitKind::None;
|
||||
@@ -157,8 +196,9 @@ struct DeckHit {
|
||||
|
||||
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
|
||||
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
|
||||
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
|
||||
// the caption-row corner radio. Everything else — fence, padding, outside — misses.
|
||||
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, an
|
||||
// interactive caption-row corner radio, or a spanning group's readout column. Everything
|
||||
// else — fence, padding, a PASSIVE radio, outside — misses.
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
|
||||
|
||||
// The knob FACE a point lands on. id -1 is a miss.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// master_meter.cpp — see master_meter.h.
|
||||
|
||||
#include "core/instrument/ui/master_meter.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
bool meterTickNumeralled(int db) {
|
||||
// Every OTHER 6 dB tick, which is the 0/−12/−24/−36/−48/−60 set the scale is specified as.
|
||||
return db % (2 * static_cast<int>(kMeterTickStepDb)) == 0;
|
||||
}
|
||||
|
||||
MeterRects meterRects(const Rect& column, LaneSplit split) {
|
||||
MeterRects r;
|
||||
if (column.width < kMeterColumnW || column.height <= 0) return r;
|
||||
r.labels = Rect::ltrb(column.x, column.y, column.x + kMeterLabelW, column.bottom());
|
||||
const int fieldLeft = column.x + kMeterLabelW + kMeterLabelGap;
|
||||
r.field = Rect::ltrb(fieldLeft, column.y, fieldLeft + kMeterFieldW, column.bottom());
|
||||
if (split == LaneSplit::Single) {
|
||||
r.barA = r.field;
|
||||
return r;
|
||||
}
|
||||
const int barW = (kMeterFieldW - kMeterBarGap) / 2;
|
||||
r.barA = Rect::ltrb(fieldLeft, column.y, fieldLeft + barW, column.bottom());
|
||||
const int bLeft = r.barA.right() + kMeterBarGap;
|
||||
r.barB = Rect::ltrb(bLeft, column.y, bLeft + barW, column.bottom());
|
||||
return r;
|
||||
}
|
||||
|
||||
Rect meterNumeralRect(const Rect& labels, int y) {
|
||||
if (labels.empty()) return {};
|
||||
int top = y - 5;
|
||||
if (top < labels.y) top = labels.y;
|
||||
int bottom = top + 10;
|
||||
if (bottom > labels.bottom()) {
|
||||
bottom = labels.bottom();
|
||||
top = bottom - 10 < labels.y ? labels.y : bottom - 10;
|
||||
}
|
||||
return Rect::ltrb(labels.x, top, labels.right(), bottom);
|
||||
}
|
||||
|
||||
int meterDbToY(const Rect& field, double db) {
|
||||
const double norm = engine::meterNormFromDb(db);
|
||||
const int y = field.bottom() - static_cast<int>(norm * field.height + 0.5);
|
||||
if (y < field.y) return field.y;
|
||||
if (y > field.bottom()) return field.bottom();
|
||||
return y;
|
||||
}
|
||||
|
||||
MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block,
|
||||
double elapsedSeconds) {
|
||||
MasterMeterUi next;
|
||||
next.left = engine::advanceMeter(prev.left, block.peakL, elapsedSeconds);
|
||||
next.right = engine::advanceMeter(prev.right, block.peakR, elapsedSeconds);
|
||||
// ORed in unconditionally. Now that the peaks accumulate, advanceMeter's own >= 0 dBFS
|
||||
// check sees the same window and would latch too — but the published flag stays the
|
||||
// definitive one, and it is the half clearMasterBusClip resets.
|
||||
if (block.clip) {
|
||||
next.left.clip = true;
|
||||
next.right.clip = true;
|
||||
}
|
||||
|
||||
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
|
||||
const double reduction = -engine::meterDbFromLinear(block.minGain);
|
||||
// Same hold-then-release shape as the peak tick, for the same reason: at the UI period the
|
||||
// lamp actually runs at, a bare decay retires a catch before it has been drawn twice.
|
||||
if (reduction >= prev.reductionDb) {
|
||||
next.reductionDb = reduction;
|
||||
next.reductionHoldSeconds = engine::kMeterPeakHoldSeconds;
|
||||
} else {
|
||||
next.reductionDb = prev.reductionDb;
|
||||
next.reductionHoldSeconds = prev.reductionHoldSeconds - dt;
|
||||
if (next.reductionHoldSeconds < 0.0) {
|
||||
// Spend the overshoot as fall time so the release does not quantize to whichever
|
||||
// UI frame the hold happened to expire on.
|
||||
const double fallen =
|
||||
next.reductionDb - engine::kMeterFallDbPerSecond * -next.reductionHoldSeconds;
|
||||
next.reductionDb = fallen > reduction ? fallen : reduction;
|
||||
next.reductionHoldSeconds = 0.0;
|
||||
}
|
||||
}
|
||||
if (next.reductionDb < 0.0) next.reductionDb = 0.0;
|
||||
return next;
|
||||
}
|
||||
|
||||
bool meterClipped(const MasterMeterUi& m) { return m.left.clip || m.right.clip; }
|
||||
|
||||
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev) {
|
||||
MasterMeterUi next = prev;
|
||||
next.left = engine::clearMeterClip(prev.left);
|
||||
next.right = engine::clearMeterClip(prev.right);
|
||||
return next;
|
||||
}
|
||||
|
||||
engine::MeterState meterSingleLaneState(const MasterMeterUi& m) {
|
||||
engine::MeterState s;
|
||||
s.levelDb = m.left.levelDb > m.right.levelDb ? m.left.levelDb : m.right.levelDb;
|
||||
s.holdDb = m.left.holdDb > m.right.holdDb ? m.left.holdDb : m.right.holdDb;
|
||||
s.holdRemainingSeconds = m.left.holdRemainingSeconds > m.right.holdRemainingSeconds
|
||||
? m.left.holdRemainingSeconds
|
||||
: m.right.holdRemainingSeconds;
|
||||
s.clip = m.left.clip || m.right.clip;
|
||||
return s;
|
||||
}
|
||||
|
||||
bool grLampLit(const MasterMeterUi& m) { return m.reductionDb >= kGrLampFloorDb; }
|
||||
|
||||
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b) {
|
||||
return a.left.levelDb == b.left.levelDb && a.right.levelDb == b.right.levelDb &&
|
||||
a.left.holdDb == b.left.holdDb && a.right.holdDb == b.right.holdDb &&
|
||||
meterClipped(a) == meterClipped(b) && grLampLit(a) == grLampLit(b);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,101 @@
|
||||
// master_meter.h — the interior of the spanning deck's readout column: the label gutter and
|
||||
// bar field it divides into, the dB->y map its scale draws against, and the per-instance UI
|
||||
// state the bars are drawn from. knob_deck hands over the column rect; this lays out inside
|
||||
// it. Every timed, logged or latched quantity lives here, on the UI thread — the audio thread
|
||||
// publishes raw block magnitudes and converts nothing.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/engine/meter_ballistics.h"
|
||||
#include "core/instrument/ui/editor_geometry.h"
|
||||
#include "core/instrument/ui/sample_bands.h" // LaneSplit
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
inline constexpr int kMeterLabelW = 22; // the numeral gutter, left of the bars
|
||||
inline constexpr int kMeterLabelGap = 4;
|
||||
inline constexpr int kMeterFieldW = 36; // one 36px bar, or two 17px bars kMeterBarGap apart
|
||||
inline constexpr int kMeterBarGap = 2;
|
||||
|
||||
// What the interior consumes, and therefore the width the deck must RESERVE for the column.
|
||||
// knob_deck's MASTER descriptor reads this rather than restating 62 — §1.2 banks MASTER's
|
||||
// growth to 236 as the meter's growth room, so this constant is expected to move.
|
||||
inline constexpr int kMeterColumnW = kMeterLabelW + kMeterLabelGap + kMeterFieldW;
|
||||
|
||||
// A tick every 6 dB up the scale; every other one carries a numeral, and 0 dB draws heavier.
|
||||
inline constexpr double kMeterTickStepDb = 6.0;
|
||||
|
||||
// Whether the tick at `db` carries a numeral. The numeral SET is spec-pinned (0, −12, −24,
|
||||
// −36, −48, −60), so it lives beside the step it is derived from rather than in the painter.
|
||||
bool meterTickNumeralled(int db);
|
||||
|
||||
struct MeterRects {
|
||||
Rect labels; // the numeral gutter
|
||||
Rect field; // the whole bar field
|
||||
Rect barA; // Single: the one wide bar. Stereo: L.
|
||||
Rect barB; // empty() unless Stereo
|
||||
};
|
||||
|
||||
// `split` is the RESOLVED lane decision resolveLaneSplit already folds (channel mode AND the
|
||||
// source's channel count), not "is the instrument in stereo mode": a mono source under stereo
|
||||
// mode is dual-mono, and two identical bars would be a lie. One source, two views, one rule.
|
||||
// A column narrower than kMeterColumnW yields nothing rather than an overrunning field.
|
||||
MeterRects meterRects(const Rect& column, LaneSplit split);
|
||||
|
||||
// y of `db` inside the bar field — kMeterTopDb at the top edge, kMeterFloorDb at the bottom,
|
||||
// linear in dB between, clamped outside.
|
||||
int meterDbToY(const Rect& field, double db);
|
||||
|
||||
// The numeral's label rect for the tick at `y`, kept inside the gutter: the floor tick sits ON
|
||||
// the field's bottom edge, and an unclamped y±5 box would hang below the column.
|
||||
Rect meterNumeralRect(const Rect& labels, int y);
|
||||
|
||||
// The per-instance UI state behind the column. One clip latch per channel (the cap is drawn
|
||||
// once, over whichever of them tripped).
|
||||
struct MasterMeterUi {
|
||||
engine::MeterState left;
|
||||
engine::MeterState right;
|
||||
double reductionDb = 0.0; // how far the limiter is pulling gain down; 0 = not working
|
||||
// The lamp's hold, on the SAME principle (and the same window) as the peak tick's: without
|
||||
// it a catch smaller than kMeterFallDbPerSecond x the UI period is fully decayed by the
|
||||
// next frame, so the lamp is dark again after the single repaint the catch landed on.
|
||||
double reductionHoldSeconds = 0.0;
|
||||
};
|
||||
|
||||
// What the audio thread published SINCE THE LAST READ, in this module's own vocabulary — the
|
||||
// peaks are a max over every block in that window and minGain a min, so no block is discarded
|
||||
// unseen between two UI frames.
|
||||
struct MasterMeterBlock {
|
||||
double peakL = 0.0;
|
||||
double peakR = 0.0;
|
||||
double minGain = 1.0; // the limiter's smallest gain over the window; 1 = no reduction
|
||||
bool clip = false; // the AUDIO thread's own latch
|
||||
};
|
||||
|
||||
MasterMeterUi advanceMasterMeter(MasterMeterUi prev, const MasterMeterBlock& block,
|
||||
double elapsedSeconds);
|
||||
|
||||
bool meterClipped(const MasterMeterUi& m);
|
||||
MasterMeterUi clearMasterMeterClip(MasterMeterUi prev);
|
||||
|
||||
// What the ONE bar shows on a single-lane column: the two channels folded per FIELD, not the
|
||||
// louder channel's whole state. Picking a channel by level would draw a hold tick and a clip
|
||||
// belonging to whichever won on level — inert while L ≡ R on every path that reaches Single,
|
||||
// and wrong the moment they diverge.
|
||||
engine::MeterState meterSingleLaneState(const MasterMeterUi& m);
|
||||
|
||||
// Whether two states would DRAW the same, so the UI tick can repaint only on a change and an
|
||||
// idle editor costs nothing. Compares what the column shows — the bar, the held tick, the cap
|
||||
// and the lamp — not every stored double.
|
||||
bool meterDrawEqual(const MasterMeterUi& a, const MasterMeterUi& b);
|
||||
|
||||
// The lamp reports "the limiter is working", not "a sample grazed the threshold", so it needs
|
||||
// a floor rather than a bare non-zero test. 0.5 dB is a CHOSEN floor, not a measurement — the
|
||||
// spec asks only for "a small floor". It is bounded on BOTH sides: raising it hides genuine
|
||||
// catches, since the limiter's ceiling is only −0.3 dBTP; lowering it turns the lamp into a
|
||||
// "some sample crossed the ceiling" light, because the gain law is ceiling/peak and so reports
|
||||
// an arbitrarily small reduction for a peak arbitrarily close to the ceiling.
|
||||
inline constexpr double kGrLampFloorDb = 0.5;
|
||||
bool grLampLit(const MasterMeterUi& m);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -21,12 +21,12 @@ inline constexpr int kPad = 8;
|
||||
// this allocator is deliberately independent of the deck (it takes deckHeight as a parameter
|
||||
// for exactly that reason), so the derivation is asserted in test_deck_groups.cpp — the one
|
||||
// place that already includes both headers — rather than coded as an include edge.
|
||||
inline constexpr int kEditorMinWidth = 1190;
|
||||
inline constexpr int kEditorMinWidth = 1198;
|
||||
inline constexpr int kEditorMinHeight = 680;
|
||||
|
||||
// The hard ceiling the floor above may not exceed; the window itself still grows freely above
|
||||
// it. A window fact, sibling of kEditorMinWidth/kEditorMinHeight, not a deck one — moved here
|
||||
// from knob_deck.h for that reason. The gap to the floor (today: 90px) is the deck's whole
|
||||
// from knob_deck.h for that reason. The gap to the floor (today: 82px) is the deck's whole
|
||||
// width budget, spent once; the identity is asserted in test_deck_groups.cpp, the one place
|
||||
// that already includes both this header and knob_deck.h.
|
||||
inline constexpr int kEditorCeilingWidth = 1280;
|
||||
@@ -55,7 +55,7 @@ struct SampleBands {
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own
|
||||
// wrapped height (from knob_deck) — the only interior measurement the allocator needs, so
|
||||
// height (from knob_deck) — the only interior measurement the allocator needs, so
|
||||
// the deck band is exactly as tall as its content. Pure.
|
||||
SampleBands computeSampleBands(int w, int h, int deckHeight);
|
||||
|
||||
|
||||
@@ -24,13 +24,15 @@ OverlayArea waveformOverlayArea(const Rect& band) {
|
||||
return OverlayArea{band.empty() ? Rect{} : band};
|
||||
}
|
||||
|
||||
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels) {
|
||||
return (stereoMode && sourceChannels >= 2) ? LaneSplit::Stereo : LaneSplit::Single;
|
||||
}
|
||||
|
||||
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels) {
|
||||
WaveformSurface s;
|
||||
if (band.empty()) return s;
|
||||
s.overlay = waveformOverlayArea(band);
|
||||
const bool twoLanes = stereoMode && sourceChannels >= 2;
|
||||
const WaveformLanes lanes =
|
||||
waveformLanes(band, twoLanes ? LaneSplit::Stereo : LaneSplit::Single);
|
||||
const WaveformLanes lanes = waveformLanes(band, resolveLaneSplit(stereoMode, sourceChannels));
|
||||
s.upper = lanes.upper;
|
||||
s.lower = lanes.lower;
|
||||
// Derived from the resolved lanes, not `twoLanes` — a stereo split's integer division
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, OverlayArea, contains
|
||||
#include "core/instrument/ui/sample_bands.h" // LaneSplit (resolveLaneSplit's answer)
|
||||
#include "core/audio/peaks.h" // AudioSample (float), Envelope
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
@@ -35,9 +36,16 @@ struct WaveformSurface {
|
||||
// the band-stack allocator's kWaveformMinHeight floor.
|
||||
};
|
||||
|
||||
// Resolves the surface for a waveform band. Two lanes need BOTH stereo mode and a source
|
||||
// that has a second channel to show: a mono source under stereo mode is dual-mono, so a
|
||||
// second lane would be the redundant duplicate single-lane mode exists to avoid.
|
||||
// THE lane-split decision, free of any pixel geometry: two lanes need BOTH stereo mode and a
|
||||
// source that has a second channel to show, since a mono source under stereo mode is dual-mono
|
||||
// and a second lane would be the redundant duplicate single-lane mode exists to avoid. The one
|
||||
// home of that rule — the meter's bar count is the SAME question and reads it here, rather than
|
||||
// inferring it from a band rect it has no business knowing about.
|
||||
LaneSplit resolveLaneSplit(bool stereoMode, int sourceChannels);
|
||||
|
||||
// Resolves the surface for a waveform band, folding the split above and then measuring it
|
||||
// against the band: WaveformSurface::laneCount can still report 1 for a Stereo split on a band
|
||||
// too thin to divide, which is a geometry fact and not a second rule.
|
||||
WaveformSurface waveformSurface(const Rect& band, bool stereoMode, int sourceChannels);
|
||||
|
||||
// THE overlay area, standalone — same value as WaveformSurface::overlay, for the hit-test
|
||||
|
||||
Reference in New Issue
Block a user