One taper, one modifier law: extract param_taper, raise the stage ceiling to 10 s, and make the AHDSR schematic axis the taper itself

This commit is contained in:
2026-08-01 19:09:19 -04:00
parent e589addc54
commit 3eb72d01c4
23 changed files with 1208 additions and 193 deletions
+10 -5
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 cell/knob/label sizes and `sample_bands`' editor floor move as a pair** — wider cells need a wider floor width or the deck wraps to a fourth row. 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, and the deck has fourteen pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **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
@@ -336,7 +341,7 @@ anything for a trigger shape.
## 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.
+17 -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,10 @@ 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)
reasampler_test(param_taper LINK param_taper)
+194 -39
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,12 +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) {
@@ -28,31 +24,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 +62,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 +110,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 +123,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 +162,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 +178,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 +197,164 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
enforceGateUnavailableWhileDrawn(play);
}
namespace {
// The ADDRESS of the one stored field a knob id owns. deckParamNorm and setDeckParam carry each
// id's MAP — which taper, which clamp; this carries only its LOCATION, which is the whole
// mechanism of the taper-free reset. 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;
}
}
} // namespace
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) {
+22 -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,23 @@ 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 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.
+58 -34
View File
@@ -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]).
@@ -94,7 +108,7 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
// 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).
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
const Rect& area, int dyPixels) {
const Rect& area, double dyPixels) {
const SegmentLevels seg = segmentLevels(grabEnv, knot);
if (!seg.ok) return grabExponent;
const double span = seg.end - seg.start;
@@ -104,7 +118,7 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab
// 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);
const double newLevel = grabLevel - dyPixels * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / span);
}
@@ -150,15 +164,19 @@ 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;
if (grabEnv.kind == EnvKind::Ahdsr) {
switch (node) {
@@ -166,38 +184,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, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dy), mods);
break;
case EnvNode::ReleaseCurve:
out.releaseCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
out.releaseCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dy), mods);
break;
default:
break;
@@ -209,8 +232,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 +241,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 +254,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, rect, dy), mods);
break;
case EnvNode::DecayCurve:
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
out.decayCurve = snappedExponent(
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, 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
+7 -7
View File
@@ -23,7 +23,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 +31,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) {
@@ -114,17 +114,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
+12 -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
+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
+82
View File
@@ -0,0 +1,82 @@
// 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: nearbyint(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
double resolveTo(double value, double perUnit) { return std::nearbyint(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::nearbyint(seconds * 1000.0) / 1000.0;
}
double snapFractionToWholePercent(double fraction) {
return std::nearbyint(fraction * 100.0) / 100.0;
}
double snapSemitonesToWhole(double semitones) { return std::nearbyint(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::nearbyint(util::clampCurve(exponent)));
}
} // namespace reasampler::instrument::ui
+84
View File
@@ -0,0 +1,84 @@
// 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. That turns the guarantee into
// "every value on the quantum grid round-trips exactly" instead of a libm coincidence that a
// compiler upgrade could take away. Both quanta sit four or more orders below the finest
// reachable drag step, 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