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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user