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
|
||||
@@ -10,7 +10,7 @@ two small identity/helper headers this directory owns outright
|
||||
The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
|
||||
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
||||
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||
`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||
`core/wire` and is documented there — this directory consumes it but does not own it.
|
||||
|
||||
@@ -110,7 +110,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
|
||||
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
||||
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
|
||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, the velocity-curve box derivation, and `dragModifiers()` — THE modifier read for every drag surface and gesture resolver, so the editor cannot grow a second modifier grammar — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
|
||||
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -47,9 +47,9 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
|
||||
|
||||
// Alt-click delete is retired (the spec's right-click supersedes it — one grammar, no
|
||||
// migration on either side): every gesture here routes through the shared resolver.
|
||||
const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
const SplineEdit edit = resolveSplineEdit(
|
||||
editedCurve(), box, ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft, x, y);
|
||||
const SplineGesture gesture =
|
||||
dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft;
|
||||
const SplineEdit edit = resolveSplineEdit(editedCurve(), box, gesture, x, y);
|
||||
if (edit.kind == SplineEditKind::kToggleHard) {
|
||||
if (editedCurve().toggleHard(static_cast<std::size_t>(edit.index))) commitAndReload();
|
||||
return;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/deck_values.h" // snapDeckParamNorm (Shift's whole-unit table)
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / layoutDeck
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
@@ -97,6 +98,7 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||
dragParamId_ = inner ? static_cast<int>(curve) : hit.id;
|
||||
dragInnerCellId_ = inner ? hit.id : -1;
|
||||
dragKnobStartValue_ = deckControlNorm(dragParamId_);
|
||||
dragMods_ = dragModifiers();
|
||||
// Processor-side knobs (voice count / master gain) are transient live writes with no
|
||||
// parameter-set mutation, so they need no rollback snapshot.
|
||||
dragStartParams_ = params_;
|
||||
@@ -150,7 +152,20 @@ void ReaSamplerEditor::dragDeck(int x, int y) {
|
||||
// value at grab (up = increase), so the value tracks relative motion and never jumps on
|
||||
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
|
||||
(void)x;
|
||||
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
|
||||
const DragModifiers mods = dragModifiers();
|
||||
if (mods != dragMods_) {
|
||||
// Re-anchor (see dragMods_). Reading the anchor back off the control also means a Shift
|
||||
// RELEASE re-anchors from the SNAPPED value, so the knob does not spring back.
|
||||
dragKnobStartValue_ = deckControlNorm(dragParamId_);
|
||||
dragStartY_ = y;
|
||||
dragMods_ = mods;
|
||||
}
|
||||
double norm = knobDragValue(dragKnobStartValue_, y - dragStartY_, mods);
|
||||
// The preview-velocity sentinel (-2) and the discrete controls have no whole unit to snap to.
|
||||
if (mods.shift && dragParamId_ >= 0) {
|
||||
norm = snapDeckParamNorm(static_cast<DeckParam>(dragParamId_), norm);
|
||||
}
|
||||
applyDeckKnob(dragParamId_, norm);
|
||||
// A live control is delivered on every move, not only on release — that is the whole
|
||||
// point: the note already sounding tracks the hand on the knob.
|
||||
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
|
||||
|
||||
@@ -35,9 +35,8 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||
|
||||
const DeckEnableState gates = deckEnableState();
|
||||
const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates);
|
||||
const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0
|
||||
? SplineGesture::kControlLeft
|
||||
: SplineGesture::kLeft;
|
||||
const SplineGesture gesture =
|
||||
dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft;
|
||||
|
||||
// The staged envelope's draggable node and the drawn contour's node are mutually exclusive
|
||||
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
|
||||
@@ -107,6 +106,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||
envNode_ = envNodeHit.node;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
dragMods_ = dragModifiers();
|
||||
dragStartEnv_ = env;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartParams_ = params_;
|
||||
@@ -213,9 +213,20 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
|
||||
const double rate = liveSampleRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay,
|
||||
totalSeconds, envClampBounds(), dx,
|
||||
y - dragStartY_);
|
||||
const DragModifiers mods = dragModifiers();
|
||||
if (mods != dragMods_) {
|
||||
// Re-anchor (see dragMods_): the node's CURRENT params and the cursor's current
|
||||
// position become the origin, so the flip changes only the rate. Re-packing the
|
||||
// grab envelope is what makes that true for the delta this resolver measures.
|
||||
dragStartEnv_ =
|
||||
packEnvelope(overlayEnv_, params_.play, frames, params_.startPoint.value_or(0));
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
dragMods_ = mods;
|
||||
}
|
||||
const StageEnvelope edited =
|
||||
resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds, envClampBounds(),
|
||||
x - dragStartX_, y - dragStartY_, mods);
|
||||
unpackEnvelope(overlayEnv_, edited, params_.play);
|
||||
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
|
||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel)
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type)
|
||||
#include "core/instrument/ui/keyboard_strip.h" // noteName (the one note-naming source)
|
||||
#include "core/instrument/ui/param_taper.h" // DragModifiers (the shared interaction law)
|
||||
|
||||
#ifdef _WIN32
|
||||
#include "wdltypes.h"
|
||||
@@ -70,6 +71,15 @@ inline std::string sampleLabel(const std::vector<instrument::map::SampleChoice>&
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
// THE modifier read, for every drag surface and every gesture resolver. One helper so the editor
|
||||
// cannot grow a second modifier grammar. GetKeyState rather than WM_MOUSEMOVE's wParam because a
|
||||
// modifier can be pressed or released with the mouse standing still, and the re-anchor has to see
|
||||
// that on the next move it does get.
|
||||
inline instrument::ui::DragModifiers dragModifiers() {
|
||||
return instrument::ui::DragModifiers{(GetKeyState(VK_SHIFT) & 0x8000) != 0,
|
||||
(GetKeyState(VK_CONTROL) & 0x8000) != 0};
|
||||
}
|
||||
|
||||
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against
|
||||
// `KitBox` (component_geometry). This is the single boundary that bridges them so every
|
||||
// draw routes through the shared kit (theme roles + draw_kit).
|
||||
|
||||
@@ -519,6 +519,12 @@ private:
|
||||
// delta from this anchor, so a grab never jumps the value.
|
||||
double dragKnobStartValue_ = 0.0;
|
||||
|
||||
// The modifier state the in-flight drag is anchored to. Every transition of it — press OR
|
||||
// release — RE-ANCHORS the drag: current value and current cursor become the new origin, so
|
||||
// the value is continuous across the flip and only the rate changes. Without that, rescaling
|
||||
// an accumulated absolute delta in place jumps by (1 - kFineDragScale) x the accumulation.
|
||||
instrument::ui::DragModifiers dragMods_{};
|
||||
|
||||
// Which velocity curve the popup is editing; kNone = closed. Never persisted. Every writer
|
||||
// of kNone must also cancel a live curve-node drag (closeCurvePopup does both) — an Esc
|
||||
// mid-drag that closed the popup without cancelling the drag used to leave editedCurve()'s
|
||||
|
||||
Reference in New Issue
Block a user