Merge Γ-W1-T1: one taper, one modifier law, the 10 s stage ceiling

This commit is contained in:
2026-08-01 21:33:01 -04:00
25 changed files with 1519 additions and 220 deletions
+11 -6
View File
@@ -312,15 +312,20 @@ anything for a trigger shape.
- **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.
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `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.
- `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` /
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position maps and their clamps),
`resetDeckParam` (the double-click reset — the defaults are READ off a default-constructed
`PlaySeconds`, so there is no second table of defaults to drift), and `formatEnvTimeMs`, the
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position binding and its clamps, over
`param_taper`'s maps), `resetDeckParam` (the double-click reset — the defaults are READ off a
default-constructed `PlaySeconds`, so there is no second table of defaults to drift, and the
value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be
"simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit
table, and where each control's full scale enters — a whole DISPLAYED percent is a different
norm step at 0..100 %, 0..200 % and ±100 %), and `formatEnvTimeMs`, the
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
two stage times are comparable at a glance. A display-unit decision only — nothing about the
stored representation changes. Links the header-only `play_seconds`, deliberately not
@@ -329,14 +334,14 @@ anything for a trigger shape.
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
labels for them.
- `deck_groups` — also home to `isLiveDeckParam` 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.
- `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_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.
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
## Gotchas
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample.
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned, and it is not linear in seconds either** — it does NOT line up with the waveform under it, and each of its four equal stage slots is filled by `param_taper`'s own norm, so a node's position within its slot IS its knob's needle position. Two stages therefore cannot be compared by eye at a 10:1 ratio; the ms labels carry the number. Only a sustain-less AHD's x-axis is wall-clock/PCM-aligned and linear. Content-fit auto-scale and a minimum drawn stage width were both considered and REJECTED — the first moves the axis under the hand, the second decouples the drawn position from the value and breaks the drag inverse.
- **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
+20 -4
View File
@@ -34,11 +34,17 @@ reasampler_pure_library(browser_scroll
LINK PUBLIC capture_browser sample_chrome)
reasampler_test(browser_scroll LINK browser_scroll)
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
reasampler_pure_library(param_slider
SOURCES param_slider.cpp
LINK PUBLIC editor_geometry param_taper)
reasampler_test(param_slider LINK param_slider)
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
reasampler_test(envelope_overlay LINK envelope_overlay)
reasampler_pure_library(envelope_overlay
SOURCES envelope_overlay.cpp
LINK PUBLIC editor_geometry curve_law param_taper)
# sample_bands is linked for the test only: the tapered-axis legibility assertion is judged at the
# editor's own floor width, read from the allocator rather than copied as a number.
reasampler_test(envelope_overlay LINK envelope_overlay sample_bands)
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
reasampler_test(envelope_edit LINK envelope_edit)
@@ -76,7 +82,7 @@ reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands)
# the filter's MorphLaw — an enum, so no filter symbol is linked.
reasampler_pure_library(deck_values
SOURCES deck_values.cpp
LINK PUBLIC deck_groups play_seconds envelope_overlay)
LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain)
reasampler_test(deck_values LINK deck_values)
# The bake Hold knob's value domain. Links the ladder alone — it computes no geometry, so it
@@ -88,3 +94,13 @@ reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_g
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
# proving that takes a curve of each domain mapped through the one curveBox.
reasampler_test(curve_popup LINK curve_popup velocity_curve)
# The ONE norm<->value taper and modifier vocabulary every variable control shares. Declared
# last, but it sits at the BOTTOM of this directory's dependency order: param_slider,
# envelope_overlay and deck_values all read it — which is exactly why it could not stay inside
# deck_values, which sits above envelope_overlay.
reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_law)
# envelope_overlay and sample_bands are linked for the test only: the finest-drag-step assertion
# is judged against the envelope node drag at the editor's own floor width (the sharper of the
# taper's two consumers), read from the allocator/overlay rather than copied as a number.
reasampler_test(param_taper LINK param_taper envelope_overlay sample_bands)
+190 -40
View File
@@ -3,9 +3,11 @@
#include "core/instrument/ui/deck_values.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
#include "core/instrument/engine/master_gain.h" // the dB taper the whole-dB snap reads
#include "core/util/clamp01.h"
#include "core/util/curve_law.h" // the ONE curve-exponent domain
@@ -14,13 +16,6 @@ namespace reasampler::instrument::ui {
using engine::filter::MorphLaw;
using util::clamp01;
namespace {
double secToNorm(double seconds) { return clamp01(seconds / kEnvTimeMaxSeconds); }
double normToSec(double norm) { return clamp01(norm) * kEnvTimeMaxSeconds; }
} // namespace
double deckParamNorm(DeckParam id, const PlaySeconds& play) {
switch (id) {
case DeckParam::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
@@ -28,31 +23,32 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case DeckParam::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case DeckParam::kAttack: return secToNorm(play.adsr.attackSeconds);
case DeckParam::kHold: return secToNorm(play.adsr.holdSeconds);
case DeckParam::kDecay: return secToNorm(play.adsr.decaySeconds);
case DeckParam::kAttack: return timeNormFromSeconds(play.adsr.attackSeconds);
case DeckParam::kHold: return timeNormFromSeconds(play.adsr.holdSeconds);
case DeckParam::kDecay: return timeNormFromSeconds(play.adsr.decaySeconds);
case DeckParam::kSustain: return clamp01(play.adsr.sustainLevel);
case DeckParam::kRelease: return secToNorm(play.adsr.releaseSeconds);
case DeckParam::kRelease: return timeNormFromSeconds(play.adsr.releaseSeconds);
case DeckParam::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve);
case DeckParam::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
case DeckParam::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
case DeckParam::kTrigLength: return clamp01(play.trigger.lengthFraction);
case DeckParam::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
case DeckParam::kTrigAttack: return timeNormFromSeconds(play.trigAhd.attackSeconds);
case DeckParam::kTrigHold: return clamp01(play.trigAhd.holdFraction);
case DeckParam::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
case DeckParam::kTrigDecay: return timeNormFromSeconds(play.trigAhd.decaySeconds);
case DeckParam::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve);
case DeckParam::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 1.0 : 0.0;
case DeckParam::kPitchEnvAttack: return secToNorm(play.pitchEnv.shape.attackSeconds);
case DeckParam::kPitchEnvAttack:
return timeNormFromSeconds(play.pitchEnv.shape.attackSeconds);
case DeckParam::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
case DeckParam::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
case DeckParam::kPitchEnvDecay:
return timeNormFromSeconds(play.pitchEnv.shape.decaySeconds);
case DeckParam::kPitchEnvAttackCurve:
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
case DeckParam::kPitchEnvDecayCurve:
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
case DeckParam::kPitchEnvDepth:
// Signed depth centred at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
return depthNormFromSemitones(play.pitchEnv.peakSemitones, kPitchDepthMaxSemis);
// Filter. The four tone controls ARE the module's normalized positions — stored and
// shown as-is, so the knob travel is exactly filter_params' own law.
case DeckParam::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
@@ -65,20 +61,23 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
case DeckParam::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
case DeckParam::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
case DeckParam::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds);
case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds);
case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds);
case DeckParam::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
case DeckParam::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
case DeckParam::kFilterEnvRelease:
return timeNormFromSeconds(play.filter.env.releaseSeconds);
case DeckParam::kFilterEnvAttackCurve:
return util::knobNormFromCurve(play.filter.env.attackCurve);
case DeckParam::kFilterEnvDecayCurve:
return util::knobNormFromCurve(play.filter.env.decayCurve);
case DeckParam::kFilterEnvReleaseCurve:
return util::knobNormFromCurve(play.filter.env.releaseCurve);
case DeckParam::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
case DeckParam::kFilterTrigAttack:
return timeNormFromSeconds(play.filter.trigEnv.attackSeconds);
case DeckParam::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
case DeckParam::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
case DeckParam::kFilterTrigDecay:
return timeNormFromSeconds(play.filter.trigEnv.decaySeconds);
case DeckParam::kFilterTrigAttackCurve:
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
case DeckParam::kFilterTrigDecayCurve:
@@ -110,11 +109,11 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
case DeckParam::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
case DeckParam::kAttack: play.adsr.attackSeconds = normToSec(value); break;
case DeckParam::kHold: play.adsr.holdSeconds = normToSec(value); break;
case DeckParam::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case DeckParam::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
case DeckParam::kRelease: play.adsr.releaseSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
@@ -123,26 +122,26 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
// nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break;
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kTrigAttackCurve:
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kTrigDecayCurve:
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
case DeckParam::kPitchEnvAttack:
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kPitchEnvHold:
play.pitchEnv.shape.holdFraction = clamp01(value); break;
case DeckParam::kPitchEnvDecay:
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kPitchEnvAttackCurve:
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvDecayCurve:
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis);
break;
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
case DeckParam::kFilterLaw:
@@ -162,15 +161,15 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
case DeckParam::kFilterKeyTrack:
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
case DeckParam::kFilterEnvAttack:
play.filter.env.attackSeconds = normToSec(value); break;
play.filter.env.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterEnvHold:
play.filter.env.holdSeconds = normToSec(value); break;
play.filter.env.holdSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterEnvDecay:
play.filter.env.decaySeconds = normToSec(value); break;
play.filter.env.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterEnvSustain:
play.filter.env.sustainLevel = clamp01(value); break;
case DeckParam::kFilterEnvRelease:
play.filter.env.releaseSeconds = normToSec(value); break;
play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterEnvAttackCurve:
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterEnvDecayCurve:
@@ -178,11 +177,11 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
case DeckParam::kFilterEnvReleaseCurve:
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterTrigAttack:
play.filter.trigEnv.attackSeconds = normToSec(value); break;
play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterTrigHold:
play.filter.trigEnv.holdFraction = clamp01(value); break;
case DeckParam::kFilterTrigDecay:
play.filter.trigEnv.decaySeconds = normToSec(value); break;
play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break;
case DeckParam::kFilterTrigAttackCurve:
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
case DeckParam::kFilterTrigDecayCurve:
@@ -197,9 +196,160 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
enforceGateUnavailableWhileDrawn(play);
}
// deckParamNorm and setDeckParam carry each id's MAP — which taper, which clamp; these two carry
// only its LOCATION, which is the whole mechanism of the taper-free reset (see deck_values.h for
// why they are exposed beyond that one caller). A toggle, radio or curve cell has no reset gesture
// and resolves to null.
double* deckDoubleField(DeckParam id, PlaySeconds& p) {
switch (id) {
case DeckParam::kAttack: return &p.adsr.attackSeconds;
case DeckParam::kHold: return &p.adsr.holdSeconds;
case DeckParam::kDecay: return &p.adsr.decaySeconds;
case DeckParam::kSustain: return &p.adsr.sustainLevel;
case DeckParam::kRelease: return &p.adsr.releaseSeconds;
case DeckParam::kAttackCurve: return &p.adsr.attackCurve;
case DeckParam::kDecayCurve: return &p.adsr.decayCurve;
case DeckParam::kReleaseCurve: return &p.adsr.releaseCurve;
case DeckParam::kTrigLength: return &p.trigger.lengthFraction;
case DeckParam::kTrigAttack: return &p.trigAhd.attackSeconds;
case DeckParam::kTrigHold: return &p.trigAhd.holdFraction;
case DeckParam::kTrigDecay: return &p.trigAhd.decaySeconds;
case DeckParam::kTrigAttackCurve: return &p.trigAhd.attackCurve;
case DeckParam::kTrigDecayCurve: return &p.trigAhd.decayCurve;
case DeckParam::kPitchEnvAttack: return &p.pitchEnv.shape.attackSeconds;
case DeckParam::kPitchEnvHold: return &p.pitchEnv.shape.holdFraction;
case DeckParam::kPitchEnvDecay: return &p.pitchEnv.shape.decaySeconds;
case DeckParam::kPitchEnvAttackCurve: return &p.pitchEnv.shape.attackCurve;
case DeckParam::kPitchEnvDecayCurve: return &p.pitchEnv.shape.decayCurve;
case DeckParam::kPitchEnvDepth: return &p.pitchEnv.peakSemitones;
case DeckParam::kFilterModAmt: return &p.filter.modAmount;
case DeckParam::kFilterVel: return &p.filter.velAmount;
case DeckParam::kFilterKeyTrack: return &p.filter.keyTrack;
case DeckParam::kFilterEnvAttack: return &p.filter.env.attackSeconds;
case DeckParam::kFilterEnvHold: return &p.filter.env.holdSeconds;
case DeckParam::kFilterEnvDecay: return &p.filter.env.decaySeconds;
case DeckParam::kFilterEnvSustain: return &p.filter.env.sustainLevel;
case DeckParam::kFilterEnvRelease: return &p.filter.env.releaseSeconds;
case DeckParam::kFilterEnvAttackCurve: return &p.filter.env.attackCurve;
case DeckParam::kFilterEnvDecayCurve: return &p.filter.env.decayCurve;
case DeckParam::kFilterEnvReleaseCurve: return &p.filter.env.releaseCurve;
case DeckParam::kFilterTrigAttack: return &p.filter.trigEnv.attackSeconds;
case DeckParam::kFilterTrigHold: return &p.filter.trigEnv.holdFraction;
case DeckParam::kFilterTrigDecay: return &p.filter.trigEnv.decaySeconds;
case DeckParam::kFilterTrigAttackCurve: return &p.filter.trigEnv.attackCurve;
case DeckParam::kFilterTrigDecayCurve: return &p.filter.trigEnv.decayCurve;
default: return nullptr;
}
}
// The filter's four tone controls store their NORMALIZED position, as floats, and their law is
// wire-frozen — hence a second resolver rather than a widened first one.
float* deckFloatField(DeckParam id, PlaySeconds& p) {
switch (id) {
case DeckParam::kFilterMorph: return &p.filter.settings.morphNorm;
case DeckParam::kFilterCutoff: return &p.filter.settings.cutoffNorm;
case DeckParam::kFilterQ: return &p.filter.settings.resonanceNorm;
case DeckParam::kFilterDrive: return &p.filter.settings.driveNorm;
default: return nullptr;
}
}
void resetDeckParam(DeckParam id, PlaySeconds& play) {
const PlaySeconds defaults;
setDeckParam(id, play, deckParamNorm(id, defaults), 0);
PlaySeconds defaults;
if (double* dst = deckDoubleField(id, play)) {
*dst = *deckDoubleField(id, defaults);
return;
}
if (float* dst = deckFloatField(id, play)) *dst = *deckFloatField(id, defaults);
}
UnitCategory deckParamUnit(DeckParam id) {
switch (id) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigDecay:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvDecay:
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigDecay:
return UnitCategory::Milliseconds;
case DeckParam::kPitchEnvDepth:
return UnitCategory::Semitones;
// The filter's four tone controls read out in Hz / Q / drive depth but snap in whole
// percent of the normalized position they STORE — display and snap are independent axes.
case DeckParam::kSustain:
case DeckParam::kTrigLength:
case DeckParam::kTrigHold:
case DeckParam::kPitchEnvHold:
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ:
case DeckParam::kFilterDrive:
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterTrigHold:
return UnitCategory::Percent;
case DeckParam::kAttackCurve:
case DeckParam::kDecayCurve:
case DeckParam::kReleaseCurve:
case DeckParam::kTrigAttackCurve:
case DeckParam::kTrigDecayCurve:
case DeckParam::kPitchEnvAttackCurve:
case DeckParam::kPitchEnvDecayCurve:
case DeckParam::kFilterEnvAttackCurve:
case DeckParam::kFilterEnvDecayCurve:
case DeckParam::kFilterEnvReleaseCurve:
case DeckParam::kFilterTrigAttackCurve:
case DeckParam::kFilterTrigDecayCurve:
return UnitCategory::Exponent;
case DeckParam::kMasterGain:
return UnitCategory::Decibels;
default:
// Toggles, radios, the curve-popup cells, and the already-integer voice count.
return UnitCategory::None;
}
}
double snapDeckParamNorm(DeckParam id, double norm) {
switch (deckParamUnit(id)) {
case UnitCategory::Milliseconds:
return timeNormFromSeconds(snapSecondsToWholeMs(timeSecondsFromNorm(norm)));
case UnitCategory::Semitones:
return depthNormFromSemitones(
snapSemitonesToWhole(depthSemitonesFromNorm(norm, kPitchDepthMaxSemis)),
kPitchDepthMaxSemis);
case UnitCategory::Exponent:
return util::knobNormFromCurve(snapExponentToWhole(util::curveFromKnobNorm(norm)));
case UnitCategory::Decibels:
return engine::masterGainNormFromDb(
std::nearbyint(engine::masterGainDbFromNorm(norm)));
case UnitCategory::Percent:
switch (id) {
case DeckParam::kFilterModAmt:
case DeckParam::kFilterVel:
return deckNormFromBipolar(
snapFractionToWholePercent(deckBipolarFromNorm(norm)));
case DeckParam::kKeyTrack:
case DeckParam::kFilterKeyTrack:
return clamp01(
snapFractionToWholePercent(clamp01(norm) * kKeyTrackMax) / kKeyTrackMax);
default:
return clamp01(snapFractionToWholePercent(clamp01(norm)));
}
case UnitCategory::None:
break;
}
return norm;
}
void formatEnvTimeMs(double seconds, char* buf, std::size_t len) {
+32 -10
View File
@@ -11,14 +11,15 @@
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
#include "core/instrument/ui/deck_groups.h" // DeckParam
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
namespace reasampler::instrument::ui {
using map::PlaySeconds;
// Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly what the
// parameter set stores. READ from the overlay's schematic scale rather than restated: the AHDSR
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
// parameter set stores. An ALIAS of the overlay's schematic domain, which is itself an alias of
// the taper's; param_taper.h owns why the number has one home.
inline constexpr double kEnvTimeMaxSeconds = kGateStageMaxSeconds;
// Pitch depth throw: +/-kVelocityPitchRangeSemitones, centred. The one throw the pitch
@@ -28,10 +29,11 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
inline constexpr double kKeyTrackMax = 2.0;
// The normalized [0,1] a control shows: seconds over the ceiling, levels and fractions as-is,
// signed depths centred at 0.5, curve exponents over their logarithmic travel. Controls backed
// by per-instance state rather than the parameter set (voice count, master gain, the pitch
// key-track scalar, preview velocity) are not here — the shell reads those from the processor.
// The normalized [0,1] a control shows: stage times through the shared time taper, levels and
// fractions as-is, signed depths through the centre-expanded depth taper, curve exponents over
// their logarithmic travel. Controls backed by per-instance state rather than the parameter set
// (voice count, master gain, the pitch key-track scalar, preview velocity) are not here — the
// shell reads those from the processor.
double deckParamNorm(DeckParam id, const PlaySeconds& play);
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
@@ -39,13 +41,33 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play);
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
// Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no
// second table of defaults to drift from the real one. It arrives via the norm round trip, so
// landing EXACTLY on a stage time (0.003 s attack, 0.060 s release) depends on
// kEnvTimeMaxSeconds being a power of two — x/2^n*2^n is lossless, an arbitrary ceiling is not.
// Move that ceiling off a power of two and a reset lands a mantissa bit off its own default.
// second table of defaults to drift from the real one, and the value is COPIED rather than
// round-tripped through norm -> value. That bypass is MANDATORY: a reset must land on the stored
// default bit for bit, and no round trip through a log taper over a non-power-of-two ceiling can
// promise that for every control. Never "simplify" it back into a round trip.
// For knob-valued controls — a toggle has no reset gesture.
void resetDeckParam(DeckParam id, PlaySeconds& play);
// The ADDRESS of the one stored field `id` owns — the mechanism resetDeckParam bypasses the taper
// with. Exposed beyond that one caller so a test can verify a reset (or any other mutation)
// against the actual stored field rather than its normalized read-back, which deckParamNorm does
// not guarantee is injective. Null for a control with no reset gesture (a toggle, radio, or
// curve-popup cell) or one whose value lives outside PlaySeconds (master gain, key-track).
double* deckDoubleField(DeckParam id, PlaySeconds& p);
// The filter's four tone controls store their normalized position as float — see deckFloatField's
// definition for why that is a second resolver rather than a widened first one.
float* deckFloatField(DeckParam id, PlaySeconds& p);
// THE snap-unit table: which whole unit Shift snaps each control to. Includes the deck's
// processor-side ids (voice count, master gain), which have no entry in the two functions above
// because their VALUE lives outside the parameter set — the unit does not.
UnitCategory deckParamUnit(DeckParam id);
// Applies that snap to a control's normalized value. Snapping happens in the DISPLAYED unit, so
// this is where each control's full scale enters: 0..100 %, 0..200 % and +/-100 % all snap to a
// whole displayed percent and therefore take different norm steps.
double snapDeckParamNorm(DeckParam id, double norm);
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
// one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short
// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator.
+99 -40
View File
@@ -11,8 +11,8 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveFromMidLevel;
using util::curveMidLevel;
using util::curveFromLevelAt;
using util::curveLevelAt;
namespace {
@@ -23,11 +23,25 @@ double secondsPerPixel(const Rect& area, double totalSeconds) {
return totalSeconds / static_cast<double>(w);
}
// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
// dragged handle tracks the cursor 1:1.
double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0;
// The exact inverse of gatePolyline's tapered stage placement: a stage's drawn offset inside its
// slot is slot * timeNormFromSeconds(t), so a pixel delta moves the NORM by dx/slot — never the
// seconds by a fixed rate. Reading the same taper the draw does is what makes a dragged handle
// track the cursor at both ends of the range instead of only near the ceiling.
double gateStageFromPixels(double grabSeconds, const Rect& area, double dxPixels) {
const double slot = gateStageSlotPx(area);
if (slot <= 0.0) return grabSeconds;
return timeSecondsFromNorm(timeNormFromSeconds(grabSeconds) + dxPixels / slot);
}
// Shift's snaps, applied to the resolved param before its clamp so the domain edge always wins.
double snappedSeconds(double seconds, const DragModifiers& m) {
return m.shift ? snapSecondsToWholeMs(seconds) : seconds;
}
double snappedFraction(double fraction, const DragModifiers& m) {
return m.shift ? snapFractionToWholePercent(fraction) : fraction;
}
double snappedExponent(double exponent, const DragModifiers& m) {
return m.shift ? snapExponentToWhole(exponent) : exponent;
}
// Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
@@ -91,10 +105,35 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
return s;
}
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
// curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law).
// The pixel bounds of the segment a curve knot rides, by node — read off the SAME polyline the
// draw built (never re-derived), so the drag's phi can never disagree with knotVtx's.
struct SegmentPixels {
int x0 = 0;
int x1 = 0;
bool ok = false;
};
SegmentPixels segmentPixels(const std::vector<EnvVertex>& poly, EnvNode knot) {
EnvNode startNode, endNode;
switch (knot) {
case EnvNode::AttackCurve: startNode = EnvNode::Origin; endNode = EnvNode::AttackEnd; break;
case EnvNode::DecayCurve: startNode = EnvNode::HoldEnd; endNode = EnvNode::DecayEnd; break;
case EnvNode::ReleaseCurve: startNode = EnvNode::ReleaseStart; endNode = EnvNode::ReleaseEnd; break;
default: return {};
}
SegmentPixels s;
bool haveStart = false, haveEnd = false;
for (const EnvVertex& v : poly) {
if (v.node == startNode) { s.x0 = v.x; haveStart = true; }
else if (v.node == endNode) { s.x1 = v.x; haveEnd = true; }
}
s.ok = haveStart && haveEnd;
return s;
}
// A knot drag: the grab-time level at `phi` (the phi the knot's own drawn x implies — see
// knotPhi) shifted by the pixel delta, read back through curve_law's inverse at that same phi.
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
const Rect& area, int dyPixels) {
double phi, const Rect& area, double dyPixels) {
const SegmentLevels seg = segmentLevels(grabEnv, knot);
if (!seg.ok) return grabExponent;
const double span = seg.end - seg.start;
@@ -103,9 +142,9 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
// level travel — a segment thinner than that is visually a no-op drag anyway.
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
const double grabLevel = seg.start + span * curveMidLevel(grabExponent);
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / span);
const double grabLevel = seg.start + span * curveLevelAt(phi, grabExponent);
const double newLevel = grabLevel - dyPixels * levelPerPixel(area);
return curveFromLevelAt(phi, (newLevel - seg.start) / span);
}
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
@@ -150,15 +189,29 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
int dxPixels, int dyPixels, const DragModifiers& mods) {
StageEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
const Rect& rect = area.rect;
const double secPerPx = secondsPerPixel(rect, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx;
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
// Fine drag scales the PIXEL delta, so it composes with every axis below (the tapered
// schematic, the 1:1 wall clock, the level and the exponent) without a second rule.
const double scale = fineDrag(mods) ? kFineDragScale : 1.0;
const double dx = static_cast<double>(dxPixels) * scale;
const double dy = static_cast<double>(dyPixels) * scale;
const double dSec = dx * secPerPx;
// A curve knot's phi is read off the same polyline knotVtx drew, so the drag inverts the
// exact phi the knot is sitting at rather than assuming the segment midpoint.
double curvePhi = 0.5;
if (node == EnvNode::AttackCurve || node == EnvNode::DecayCurve ||
node == EnvNode::ReleaseCurve) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(grabEnv, area, totalSeconds);
const SegmentPixels sp = segmentPixels(poly, node);
if (sp.ok) curvePhi = knotPhi(sp.x0, sp.x1);
}
if (grabEnv.kind == EnvKind::Ahdsr) {
switch (node) {
@@ -166,38 +219,43 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
out.attackSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.attackSeconds, rect, dx), mods), 0.0,
bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds =
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
out.holdSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.holdSeconds, rect, dx), mods), 0.0,
bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
out.decaySeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.decaySeconds, rect, dx), mods), 0.0,
bounds.maxDecaySeconds);
const double dLevel = -dy * levelPerPixel(rect);
out.sustainLevel =
std::clamp(snappedFraction(grabEnv.sustainLevel + dLevel, mods), 0.0, 1.0);
break;
}
case EnvNode::ReleaseStart:
// The release runs from this node to the anchored right edge, so dragging LEFT
// (negative dx) lengthens it — the delta enters with the opposite sign.
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
out.releaseSeconds = std::clamp(
snappedSeconds(gateStageFromPixels(grabEnv.releaseSeconds, rect, -dx), mods), 0.0,
bounds.maxReleaseSeconds);
break;
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
out.attackCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::ReleaseCurve:
out.releaseCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
out.releaseCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, curvePhi, rect, dy), mods);
break;
default:
break;
@@ -209,8 +267,8 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
const AhdSplit s = splitAhdSeconds(grabEnv);
switch (node) {
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
out.attackSeconds = std::clamp(snappedSeconds(grabEnv.attackSeconds + dSec, mods), 0.0,
bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd: {
// Hold is a fraction of what attack and decay left, so the node's pixel motion
@@ -218,7 +276,7 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// nothing the drag could express.
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
if (rem <= 0.0) break;
out.holdFraction = clamp01((s.hold + dSec) / rem);
out.holdFraction = clamp01(snappedFraction((s.hold + dSec) / rem, mods));
break;
}
case EnvNode::DecayEnd: {
@@ -231,17 +289,18 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
// unchanged rather than divided by zero.
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
if (denom > 1e-9) {
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds);
out.decaySeconds = std::clamp(snappedSeconds(grabEnv.decaySeconds + dSec / denom, mods),
0.0, bounds.maxDecaySeconds);
}
break;
}
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
out.attackCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
break;
default:
break;
+6 -3
View File
@@ -51,15 +51,18 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
// Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time
// (the shell snapshots it on button-down so the delta is absolute, not accumulated);
// `dxPixels`/`dyPixels` is the pixel delta since grab.
// * X delta -> the node's time param, at the same scale the forward map drew it, clamped to
// [0, per-param max].
// * X delta -> the node's time param, through the same map the forward draw used — the tapered
// slot on an AHDSR, 1:1 wall clock on an AHD — clamped to [0, per-param max].
// * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve
// exponent. Ignored for time-only nodes.
// * `mods` carries the shared interaction law (param_taper.h): Ctrl scales the pixel delta,
// Shift snaps the resolved param to a whole unit of its own category before the clamp. The
// shell RE-ANCHORS on every modifier transition, so `mods` is constant across one delta.
// * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns
// `grabEnv` unchanged.
// Only the dragged node's param(s) change. Pure.
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
int dxPixels, int dyPixels, const DragModifiers& mods = {});
} // namespace reasampler::instrument::ui
+19 -12
View File
@@ -9,8 +9,7 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveMap;
using util::curveMidLevel;
using util::curveLevelAt;
int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width);
@@ -23,7 +22,7 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
return area.x + static_cast<int>(px + 0.5);
}
double gatePxPerSecond(const Rect& area) {
double gateStageSlotPx(const Rect& area) {
const int w = std::max(0, area.width);
if (w <= 0) return 0.0;
// The four timed stages share the canvas minus their four separation bases and the last
@@ -31,7 +30,7 @@ double gatePxPerSecond(const Rect& area) {
// puts the plateau's end one separation short of the right edge rather than a fixed
// fraction of the way across.
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds);
return usable / 4.0;
}
int levelToY(const Rect& area, double level) {
@@ -46,6 +45,12 @@ int levelToY(const Rect& area, double level) {
return area.y + static_cast<int>(dy);
}
double knotPhi(int x0, int x1) {
if (x1 == x0) return 0.5;
const int mid = (x0 + x1) / 2;
return static_cast<double>(mid - x0) / static_cast<double>(x1 - x0);
}
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
AhdSplit out;
const double span = std::max(0.0, env.spanSeconds);
@@ -90,14 +95,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool
}
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
// pixel midpoint, its level read through curve_law.h's own law (the knot/dial pairing's home).
// pixel midpoint. Its level is read at the phi that midpoint's TRUNCATED x actually implies
// (knotPhi), not always phi = 0.5 — an odd-pixel span would otherwise draw the knot a half
// pixel off the curve its own vertices trace. curve_law.h owns the knot/dial pairing.
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
double endLevel, double exponent) {
const double u = curveMidLevel(exponent);
const double level = startLevel + (endLevel - startLevel) * u;
EnvVertex v;
v.node = node;
v.x = (x0 + x1) / 2;
const double u = curveLevelAt(knotPhi(x0, x1), exponent);
const double level = startLevel + (endLevel - startLevel) * u;
v.y = levelToY(area, level);
v.level = level;
v.knot = true;
@@ -114,17 +121,17 @@ std::vector<EnvVertex> gatePolyline(const StageEnvelope& env, const Rect& area)
const int W = std::max(1, area.width);
const double sep = static_cast<double>(kGateNodeSepPx);
const double pps = gatePxPerSecond(area);
const double slot = gateStageSlotPx(area);
const double xMax = static_cast<double>(W - 1);
// The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart —
// the sustain->release join, and the node the user drags — sits a release-length to its
// left. Everything the release does not take is the sustain plateau, so a zero release
// leaves the plateau running to within one separation of the edge.
double xAttack = sep + a * pps;
double xHold = xAttack + sep + h * pps;
double xDecay = xHold + sep + d * pps;
double xPlateau = xMax - sep - r * pps;
double xAttack = sep + slot * timeNormFromSeconds(a);
double xHold = xAttack + sep + slot * timeNormFromSeconds(h);
double xDecay = xHold + sep + slot * timeNormFromSeconds(d);
double xPlateau = xMax - sep - slot * timeNormFromSeconds(r);
const double xRelease = xMax;
// Keep every node separated when the four stages together would overrun the canvas: the
+19 -7
View File
@@ -9,6 +9,7 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
#include "core/instrument/ui/param_taper.h" // kStageTimeMaxSeconds + the stage-time taper
#include "core/util/curve_law.h" // the ONE per-segment curve law
namespace reasampler::instrument::ui {
@@ -80,14 +81,18 @@ struct EnvVertex {
inline constexpr int kGateNodeSepPx = 8;
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
inline constexpr double kGateStageMaxSeconds = 2.0;
// span at most this. An ALIAS of the taper's own domain end, not a second constant: a maxed knob
// lands exactly at the canvas edge (at which point the sustain plateau has shrunk to nothing)
// only while the two agree.
inline constexpr double kGateStageMaxSeconds = kStageTimeMaxSeconds;
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// the cursor 1:1.
double gatePxPerSecond(const Rect& area);
// Width of ONE of the AHDSR schematic's four equal stage slots, independent of the sample's
// actual duration. A stage of `t` seconds fills slot * timeNormFromSeconds(t) pixels of it — the
// axis IS the knob's taper, so a node's position within its slot is that knob's needle position
// drawn a second way. That is what keeps a 3 ms attack legible at a 10 s ceiling (linear in
// seconds put it under a pixel) and what lets envelope_edit's inverse stay the EXACT inverse of
// this draw. Only the AHDSR schematic is tapered; an AHD stays 1:1 wall-clock.
double gateStageSlotPx(const Rect& area);
// Maps a staged envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the
@@ -110,6 +115,13 @@ int timeToX(const Rect& area, double totalSeconds, double t);
// clamped. Shared with envelope_edit's node hit-test.
int levelToY(const Rect& area, double level);
// The normalized phi a curve knot's TRUNCATED integer x actually lands at within its bounding
// segment [x0, x1] — exactly 0.5 only when the span is even. Shared with envelope_edit's knot
// drag so the draw and its inverse read the same phi off the same formula rather than two
// copies that could drift apart. x0 == x1 (no interior) returns 0.5; callers never place a knot
// there.
double knotPhi(int x0, int x1);
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
+4 -2
View File
@@ -135,11 +135,13 @@ KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double v
knob.centerY - knob.radius * std::cos(rad)};
}
double knobDragValue(double startValue, int dyPixels, int dragRangePixels) {
double knobDragValue(double startValue, int dyPixels, const DragModifiers& mods,
int dragRangePixels) {
const double start = clamp01(startValue);
if (dragRangePixels <= 0) return start;
const double dy = static_cast<double>(dyPixels) * (fineDrag(mods) ? kFineDragScale : 1.0);
// Screen y grows downward: an upward drag (negative dy) increases the value.
return clamp01(start - static_cast<double>(dyPixels) / dragRangePixels);
return clamp01(start - dy / dragRangePixels);
}
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
+10 -2
View File
@@ -15,6 +15,7 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
#include "core/instrument/ui/param_taper.h" // DragModifiers (the shared interaction law)
namespace reasampler::instrument::ui {
@@ -129,8 +130,15 @@ KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double v
// Maps a vertical drag onto a knob value: `startValue` is the value at drag start,
// `dyPixels` the pointer's y displacement (down = positive). Up increases, down
// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range.
double knobDragValue(double startValue, int dyPixels,
// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range. Ctrl in
// `mods` scales the rate (param_taper.h); Shift's snap is NOT applied here — the whole
// unit it snaps to is a property of the control's unit category, which this module does
// not know, so the caller applies it to the returned norm.
//
// The drag is grab-anchored ABSOLUTE, which is why the caller must re-anchor `startValue`
// and its own grab y on every modifier transition: rescaling an accumulated delta in place
// would jump the value by (1 - kFineDragScale) x whatever had accumulated.
double knobDragValue(double startValue, int dyPixels, const DragModifiers& mods = {},
int dragRangePixels = kKnobDragRangePixels);
// Control a point lands on, given laid-out `rows`. Returns the control id whose
+86
View File
@@ -0,0 +1,86 @@
// param_taper.cpp — see param_taper.h. Pure value math; no host types.
#include "core/instrument/ui/param_taper.h"
#include <cmath>
namespace reasampler::instrument::ui {
namespace {
// The output quanta (header: EXACT PREIMAGE). Powers of TEN on purpose: std::round(v*S)/S is the
// correctly-rounded double of k/S, which is the same double a decimal literal of k/S parses to —
// so a default written as 0.003 or 0.060 lands on the grid exactly. A power-of-two quantum would
// not have that property against decimal literals.
constexpr double kSecondsPerQuantum = 1e9; // 1 ns
constexpr double kSemitonesPerQuantum = 1e6; // 1 micro-semitone
// std::nearbyint reads the CURRENT FP rounding mode (MXCSR) — not exclusively ours on a DAW's UI
// thread. Under round-toward-zero it can drop a grid value by a whole quantum, which is exactly
// what the quantization scheme exists to prevent. std::round (half-away-from-zero) is the same
// regardless of that mode, which is what makes the EXACT PREIMAGE guarantee (header) structural.
double resolveTo(double value, double perUnit) { return std::round(value * perUnit) / perUnit; }
// The shifted-log offsets. Both are FITTED AGAINST THE CEILING above them, which is why the
// ceiling could not be raised in a later track: doing the two apart means fitting twice.
constexpr double kTimeOffsetSeconds = 0.003; // -> 10 ms at 0.181, 100 ms at 0.436
constexpr double kDepthOffsetSemitones = 3.0; // -> +/-7 st at 0.548 of each half-travel
double timeSpan() { return std::log1p(kStageTimeMaxSeconds / kTimeOffsetSeconds); }
double depthSpan(double maxSemitones) {
return std::log1p(maxSemitones / kDepthOffsetSemitones);
}
} // namespace
double timeNormFromSeconds(double seconds) {
if (!(seconds > 0.0)) return 0.0; // also catches NaN
if (seconds >= kStageTimeMaxSeconds) return 1.0;
return std::log1p(seconds / kTimeOffsetSeconds) / timeSpan();
}
double timeSecondsFromNorm(double norm) {
if (!(norm > 0.0)) return 0.0;
if (norm >= 1.0) return kStageTimeMaxSeconds;
return resolveTo(kTimeOffsetSeconds * std::expm1(norm * timeSpan()), kSecondsPerQuantum);
}
double depthNormFromSemitones(double semitones, double maxSemitones) {
if (!(maxSemitones > 0.0)) return 0.5;
if (semitones == 0.0) return 0.5; // the centre is EXACT, so a knob parked there persists
const double mag = std::fabs(semitones); // no depth at all
if (!(mag < maxSemitones)) return semitones > 0.0 ? 1.0 : 0.0;
const double u = std::log1p(mag / kDepthOffsetSemitones) / depthSpan(maxSemitones);
return semitones > 0.0 ? 0.5 + 0.5 * u : 0.5 - 0.5 * u;
}
double depthSemitonesFromNorm(double norm, double maxSemitones) {
if (!(maxSemitones > 0.0)) return 0.0;
if (!(norm > 0.0)) return -maxSemitones; // also catches NaN
if (norm >= 1.0) return maxSemitones;
if (norm == 0.5) return 0.0;
const double u = std::fabs(norm - 0.5) * 2.0;
const double mag = resolveTo(kDepthOffsetSemitones * std::expm1(u * depthSpan(maxSemitones)),
kSemitonesPerQuantum);
return norm > 0.5 ? mag : -mag;
}
double snapSecondsToWholeMs(double seconds) {
if (!(seconds > 0.0)) return 0.0;
return std::round(seconds * 1000.0) / 1000.0;
}
double snapFractionToWholePercent(double fraction) {
return std::round(fraction * 100.0) / 100.0;
}
double snapSemitonesToWhole(double semitones) { return std::round(semitones); }
// Rounding lands on 1..10; anything under half a unit clamps to the domain floor rather than to
// zero, which is not an exponent. 1.0, the linear neutral, is therefore one snap from centre.
double snapExponentToWhole(double exponent) {
return util::clampCurve(std::round(util::clampCurve(exponent)));
}
} // namespace reasampler::instrument::ui
+87
View File
@@ -0,0 +1,87 @@
// param_taper.h — THE norm <-> value tapers every variable control shares, plus the modifier
// vocabulary its drag surfaces read. Extracted from deck_values because three consumers in two
// dependency layers read it — the knob's needle, the AHDSR overlay's schematic axis and its drag
// inverse, and the VST3 host's normalization — and three functions that agree today is a defect.
#pragma once
#include "core/util/curve_law.h" // the exponent domain the whole-number snap clamps into
namespace reasampler::instrument::ui {
// --- the interaction law's modifiers -------------------------------------------------------
// Ctrl divides the drag rate by 20. Shift+Ctrl is SHIFT, Ctrl ignored: with the output quantized
// to whole units a finer drag yields the same sequence of values, so that is an identity rather
// than a compromise — do not "fix" it into a compounded scale.
inline constexpr double kFineDragScale = 0.05;
struct DragModifiers {
bool shift = false; // snap to whole units of the control's displayed unit
bool ctrl = false; // fine drag
bool operator==(const DragModifiers& o) const { return shift == o.shift && ctrl == o.ctrl; }
bool operator!=(const DragModifiers& o) const { return !(*this == o); }
};
inline bool fineDrag(const DragModifiers& m) { return m.ctrl && !m.shift; }
// Which whole unit Shift snaps a control to. Derived from the control's UNIT rather than from a
// per-widget list, so a control added later inherits the law by naming its category.
enum class UnitCategory {
None, // discrete, already-integer, or non-scalar controls — Shift changes nothing
Milliseconds,
Semitones,
Percent,
Exponent,
Decibels,
};
// --- the two tapers ------------------------------------------------------------------------
//
// EXACT PREIMAGE, and why it is structural rather than lucky. A host's reset-to-default arrives
// as toPlain(defaultNorm) with no editor-side bypass available, so every default must satisfy
// toPlain(toNormalized(d)) == d BITWISE. No transcendental map delivers that at an arbitrary
// interior point — the image of toPlain is sparser there than the doubles around it — so both
// maps below resolve their output onto a fixed decimal quantum, via std::round rather than
// std::nearbyint: round is half-away-from-zero regardless of the caller's FP rounding mode, so
// the quantization is mode-independent, not just decimal-exact. That turns the guarantee into
// "every value on the quantum grid round-trips exactly" instead of a libm/MXCSR coincidence that
// a compiler upgrade or a host's UI thread could take away. Both quanta sit roughly 3.7-4 orders
// below the finest reachable drag step (time ~3.98, depth ~3.71), so nothing observable is
// quantized. The converse, toNormalized(toPlain(n)) == n at arbitrary n, is NOT required and must
// not be demanded: no log map satisfies it in double, and requiring it would rule out the shape
// the range needs.
// The stage-time domain's upper end — the value at norm 1, and the one home of that number:
// envelope_overlay's kGateStageMaxSeconds and deck_values' kEnvTimeMaxSeconds are both aliases
// of it, so the schematic's canvas edge and the knob's ceiling cannot drift apart. Once the
// instrument reports VST3 parameters this endpoint is a frozen host normalization — moving it
// re-interprets every automation point already recorded in projects we do not own.
inline constexpr double kStageTimeMaxSeconds = 10.0;
// Shifted-log: exactly 0 s at norm 0, exactly kStageTimeMaxSeconds at norm 1, monotone
// throughout, low end expanded so 10 ms sits at ~0.18 of the travel and 100 ms at ~0.44. A pure
// log cannot include zero and zero is a required value, which is what the offset buys.
double timeNormFromSeconds(double seconds);
double timeSecondsFromNorm(double norm);
// Signed depth, symmetric about norm 0.5: exactly 0 semitones at centre, exactly +/-maxSemitones
// at the ends, monotone, with the musically useful middle expanded so +/-7 st reaches ~0.55 of
// each half-travel. The throw is a PARAMETER — the +/-24 st depth constant has its own home in
// the engine's value layer and is not restated here.
double depthNormFromSemitones(double semitones, double maxSemitones);
double depthSemitonesFromNorm(double norm, double maxSemitones);
// --- Shift's whole-unit snaps, in the VALUE domain -------------------------------------------
//
// Stated over values rather than norms because "whole unit" means whole unit of what the control
// DISPLAYS: two controls sharing a category can have different full scales, so the norm step is
// the caller's business and the unit is this module's.
double snapSecondsToWholeMs(double seconds);
double snapFractionToWholePercent(double fraction); // 1.0 == 100 %
double snapSemitonesToWhole(double semitones);
double snapExponentToWhole(double exponent); // clamped into curve_law's own domain
} // namespace reasampler::instrument::ui
+20 -14
View File
@@ -1,8 +1,8 @@
#pragma once
// curve_law — the ONE per-segment envelope curve law: the exponent domain, the map from a
// stage's normalized position to its normalized level, and the mid-segment inverse the
// overlay knot drags through. Header-only and dependency-free so the engine evaluator, the
// overlay's forward map, and its inverse all read the same law rather than three copies.
// stage's normalized position to its normalized level, and that map's inverse (mid-segment is
// the special case). Header-only and dependency-free so the engine evaluator, the overlay's
// forward map, and its inverse all read the same law rather than three copies.
#include <cmath>
@@ -58,19 +58,25 @@ inline double knobNormFromCurve(double exponent) {
return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
}
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
// one value: both resolve through this law, not through each other.
inline double curveMidLevel(double exponent) { return curveMap(0.5, clampCurve(exponent)); }
// The normalized level at an arbitrary segment position phi in (0,1), and its inverse. A
// knot's DRAWN x truncates to an integer, which lands it off phi = 0.5 whenever its segment's
// pixel span is odd; reading the knot's y through the phi its own x actually implies (rather
// than assuming 0.5) is what keeps the knot on the trace its own vertices draw.
inline double curveLevelAt(double phi, double exponent) { return curveMap(phi, clampCurve(exponent)); }
// Mid-level -> exponent: u = 0.5^p, so p = ln(u)/ln(0.5). Out-of-domain u clamps to the
// Level -> exponent at phi: u = phi^p, so p = ln(u)/ln(phi). Out-of-domain u clamps to the
// exponent endpoints rather than producing a non-finite exponent.
inline double curveFromMidLevel(double midLevel) {
const double lo = curveMidLevel(kCurveMax); // smallest reachable mid-level
const double hi = curveMidLevel(kCurveMin); // largest
if (!(midLevel > lo)) return kCurveMax; // also catches NaN
if (midLevel >= hi) return kCurveMin;
return clampCurve(std::log(midLevel) / std::log(0.5));
inline double curveFromLevelAt(double phi, double level) {
const double lo = curveLevelAt(phi, kCurveMax); // smallest reachable level at this phi
const double hi = curveLevelAt(phi, kCurveMin); // largest
if (!(level > lo)) return kCurveMax; // also catches NaN
if (level >= hi) return kCurveMin;
return clampCurve(std::log(level) / std::log(phi));
}
// The segment-MIDPOINT (phi = 0.5) case — the knot's placement whenever its pixel span is
// even. Kept under its own name for the existing callers/tests that assume that case.
inline double curveMidLevel(double exponent) { return curveLevelAt(0.5, exponent); }
inline double curveFromMidLevel(double midLevel) { return curveFromLevelAt(0.5, midLevel); }
} // namespace reasampler::util
+2 -2
View File
@@ -10,7 +10,7 @@ two small identity/helper headers this directory owns outright
The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`param_slider`, `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`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it.
@@ -110,7 +110,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
## Gotchas
+3 -3
View File
@@ -47,9 +47,9 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
// Alt-click delete is retired (the spec's right-click supersedes it — one grammar, no
// migration on either side): every gesture here routes through the shared resolver.
const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
const SplineEdit edit = resolveSplineEdit(
editedCurve(), box, ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft, x, y);
const SplineGesture gesture =
dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft;
const SplineEdit edit = resolveSplineEdit(editedCurve(), box, gesture, x, y);
if (edit.kind == SplineEditKind::kToggleHard) {
if (editedCurve().toggleHard(static_cast<std::size_t>(edit.index))) commitAndReload();
return;
+16 -1
View File
@@ -6,6 +6,7 @@
#ifdef _WIN32
#include "core/instrument/ui/deck_values.h" // snapDeckParamNorm (Shift's whole-unit table)
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / layoutDeck
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
#include "shell/instrument/editor_internal.h"
@@ -97,6 +98,7 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
dragParamId_ = inner ? static_cast<int>(curve) : hit.id;
dragInnerCellId_ = inner ? hit.id : -1;
dragKnobStartValue_ = deckControlNorm(dragParamId_);
dragMods_ = dragModifiers();
// Processor-side knobs (voice count / master gain) are transient live writes with no
// parameter-set mutation, so they need no rollback snapshot.
dragStartParams_ = params_;
@@ -150,7 +152,20 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
// value at grab (up = increase), so the value tracks relative motion and never jumps on
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
(void)x;
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
const DragModifiers mods = dragModifiers();
if (mods != dragMods_) {
// Re-anchor (see dragMods_). Reading the anchor back off the control also means a Shift
// RELEASE re-anchors from the SNAPPED value, so the knob does not spring back.
dragKnobStartValue_ = deckControlNorm(dragParamId_);
dragStartY_ = y;
dragMods_ = mods;
}
double norm = knobDragValue(dragKnobStartValue_, y - dragStartY_, mods);
// The preview-velocity sentinel (-2) and the discrete controls have no whole unit to snap to.
if (mods.shift && dragParamId_ >= 0) {
norm = snapDeckParamNorm(static_cast<DeckParam>(dragParamId_), norm);
}
applyDeckKnob(dragParamId_, norm);
// A live control is delivered on every move, not only on release — that is the whole
// point: the note already sounding tracks the hand on the knob.
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
+17 -6
View File
@@ -35,9 +35,8 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
const DeckEnableState gates = deckEnableState();
const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates);
const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0
? SplineGesture::kControlLeft
: SplineGesture::kLeft;
const SplineGesture gesture =
dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft;
// The staged envelope's draggable node and the drawn contour's node are mutually exclusive
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
@@ -107,6 +106,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
envNode_ = envNodeHit.node;
dragStartX_ = x;
dragStartY_ = y;
dragMods_ = dragModifiers();
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartParams_ = params_;
@@ -213,9 +213,20 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const double rate = liveSampleRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay,
totalSeconds, envClampBounds(), dx,
y - dragStartY_);
const DragModifiers mods = dragModifiers();
if (mods != dragMods_) {
// Re-anchor (see dragMods_): the node's CURRENT params and the cursor's current
// position become the origin, so the flip changes only the rate. Re-packing the
// grab envelope is what makes that true for the delta this resolver measures.
dragStartEnv_ =
packEnvelope(overlayEnv_, params_.play, frames, params_.startPoint.value_or(0));
dragStartX_ = x;
dragStartY_ = y;
dragMods_ = mods;
}
const StageEnvelope edited =
resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds, envClampBounds(),
x - dragStartX_, y - dragStartY_, mods);
unpackEnvelope(overlayEnv_, edited, params_.play);
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
invalidate(); // live feedback; commit on WM_LBUTTONUP
+10
View File
@@ -15,6 +15,7 @@
#include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel)
#include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type)
#include "core/instrument/ui/keyboard_strip.h" // noteName (the one note-naming source)
#include "core/instrument/ui/param_taper.h" // DragModifiers (the shared interaction law)
#ifdef _WIN32
#include "wdltypes.h"
@@ -70,6 +71,15 @@ inline std::string sampleLabel(const std::vector<instrument::map::SampleChoice>&
#ifdef _WIN32
// THE modifier read, for every drag surface and every gesture resolver. One helper so the editor
// cannot grow a second modifier grammar. GetKeyState rather than WM_MOUSEMOVE's wParam because a
// modifier can be pressed or released with the mouse standing still, and the re-anchor has to see
// that on the next move it does get.
inline instrument::ui::DragModifiers dragModifiers() {
return instrument::ui::DragModifiers{(GetKeyState(VK_SHIFT) & 0x8000) != 0,
(GetKeyState(VK_CONTROL) & 0x8000) != 0};
}
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against
// `KitBox` (component_geometry). This is the single boundary that bridges them so every
// draw routes through the shared kit (theme roles + draw_kit).
+6
View File
@@ -519,6 +519,12 @@ private:
// delta from this anchor, so a grab never jumps the value.
double dragKnobStartValue_ = 0.0;
// The modifier state the in-flight drag is anchored to. Every transition of it — press OR
// release — RE-ANCHORS the drag: current value and current cursor become the new origin, so
// the value is continuous across the flip and only the rate changes. Without that, rescaling
// an accumulated absolute delta in place jumps by (1 - kFineDragScale) x the accumulation.
instrument::ui::DragModifiers dragMods_{};
// Which velocity curve the popup is editing; kNone = closed. Never persisted. Every writer
// of kNone must also cancel a live curve-node drag (closeCurvePopup does both) — an Esc
// mid-drag that closed the popup without cancelling the drag used to leave editedCurve()'s