Merge Γ-W1-T1: one taper, one modifier law, the 10 s stage ceiling
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 deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
||||
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
|
||||
from the editor shell on the same axis `deck_groups` was split from `knob_deck`: `deck_groups`
|
||||
says which controls exist, this says what each one's value MEANS. Holds `deckParamNorm` /
|
||||
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position maps and their clamps),
|
||||
`resetDeckParam` (the double-click reset — the defaults are READ off a default-constructed
|
||||
`PlaySeconds`, so there is no second table of defaults to drift), and `formatEnvTimeMs`, the
|
||||
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position binding and its clamps, over
|
||||
`param_taper`'s maps), `resetDeckParam` (the double-click reset — the defaults are READ off a
|
||||
default-constructed `PlaySeconds`, so there is no second table of defaults to drift, and the
|
||||
value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be
|
||||
"simplified" back into a norm round trip), `deckParamUnit`/`snapDeckParamNorm` (THE snap-unit
|
||||
table, and where each control's full scale enters — a whole DISPLAYED percent is a different
|
||||
norm step at 0..100 %, 0..200 % and ±100 %), and `formatEnvTimeMs`, the
|
||||
ONE time-constant formatter: every displayed time constant reads in **ms**, never seconds, so
|
||||
two stage times are comparable at a glance. A display-unit decision only — nothing about the
|
||||
stored representation changes. Links the header-only `play_seconds`, deliberately not
|
||||
@@ -329,14 +334,14 @@ anything for a trigger shape.
|
||||
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||
labels for them.
|
||||
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
|
||||
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
|
||||
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
|
||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample.
|
||||
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned, and it is not linear in seconds either** — it does NOT line up with the waveform under it, and each of its four equal stage slots is filled by `param_taper`'s own norm, so a node's position within its slot IS its knob's needle position. Two stages therefore cannot be compared by eye at a 10:1 ratio; the ms labels carry the number. Only a sustain-less AHD's x-axis is wall-clock/PCM-aligned and linear. Content-fit auto-scale and a minimum drawn stage width were both considered and REJECTED — the first moves the axis under the hand, the second decouples the drawn position from the value and breaks the drag inverse.
|
||||
- **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
|
||||
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
|
||||
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||
|
||||
@@ -34,11 +34,17 @@ reasampler_pure_library(browser_scroll
|
||||
LINK PUBLIC capture_browser sample_chrome)
|
||||
reasampler_test(browser_scroll LINK browser_scroll)
|
||||
|
||||
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
|
||||
reasampler_pure_library(param_slider
|
||||
SOURCES param_slider.cpp
|
||||
LINK PUBLIC editor_geometry param_taper)
|
||||
reasampler_test(param_slider LINK param_slider)
|
||||
|
||||
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
|
||||
reasampler_test(envelope_overlay LINK envelope_overlay)
|
||||
reasampler_pure_library(envelope_overlay
|
||||
SOURCES envelope_overlay.cpp
|
||||
LINK PUBLIC editor_geometry curve_law param_taper)
|
||||
# sample_bands is linked for the test only: the tapered-axis legibility assertion is judged at the
|
||||
# editor's own floor width, read from the allocator rather than copied as a number.
|
||||
reasampler_test(envelope_overlay LINK envelope_overlay sample_bands)
|
||||
|
||||
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
|
||||
reasampler_test(envelope_edit LINK envelope_edit)
|
||||
@@ -76,7 +82,7 @@ reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands)
|
||||
# the filter's MorphLaw — an enum, so no filter symbol is linked.
|
||||
reasampler_pure_library(deck_values
|
||||
SOURCES deck_values.cpp
|
||||
LINK PUBLIC deck_groups play_seconds envelope_overlay)
|
||||
LINK PUBLIC deck_groups play_seconds envelope_overlay param_taper master_gain)
|
||||
reasampler_test(deck_values LINK deck_values)
|
||||
|
||||
# The bake Hold knob's value domain. Links the ladder alone — it computes no geometry, so it
|
||||
@@ -88,3 +94,13 @@ reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_g
|
||||
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
|
||||
# proving that takes a curve of each domain mapped through the one curveBox.
|
||||
reasampler_test(curve_popup LINK curve_popup velocity_curve)
|
||||
|
||||
# The ONE norm<->value taper and modifier vocabulary every variable control shares. Declared
|
||||
# last, but it sits at the BOTTOM of this directory's dependency order: param_slider,
|
||||
# envelope_overlay and deck_values all read it — which is exactly why it could not stay inside
|
||||
# deck_values, which sits above envelope_overlay.
|
||||
reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_law)
|
||||
# envelope_overlay and sample_bands are linked for the test only: the finest-drag-step assertion
|
||||
# is judged against the envelope node drag at the editor's own floor width (the sharper of the
|
||||
# taper's two consumers), read from the allocator/overlay rather than copied as a number.
|
||||
reasampler_test(param_taper LINK param_taper envelope_overlay sample_bands)
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#include "core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
|
||||
#include "core/instrument/engine/master_gain.h" // the dB taper the whole-dB snap reads
|
||||
#include "core/util/clamp01.h"
|
||||
#include "core/util/curve_law.h" // the ONE curve-exponent domain
|
||||
|
||||
@@ -14,13 +16,6 @@ namespace reasampler::instrument::ui {
|
||||
using engine::filter::MorphLaw;
|
||||
using util::clamp01;
|
||||
|
||||
namespace {
|
||||
|
||||
double secToNorm(double seconds) { return clamp01(seconds / kEnvTimeMaxSeconds); }
|
||||
double normToSec(double norm) { return clamp01(norm) * kEnvTimeMaxSeconds; }
|
||||
|
||||
} // namespace
|
||||
|
||||
double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||
switch (id) {
|
||||
case DeckParam::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
||||
@@ -28,31 +23,32 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||
case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
|
||||
case DeckParam::kFilterEnvMode: return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
|
||||
case DeckParam::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
|
||||
case DeckParam::kAttack: return secToNorm(play.adsr.attackSeconds);
|
||||
case DeckParam::kHold: return secToNorm(play.adsr.holdSeconds);
|
||||
case DeckParam::kDecay: return secToNorm(play.adsr.decaySeconds);
|
||||
case DeckParam::kAttack: return timeNormFromSeconds(play.adsr.attackSeconds);
|
||||
case DeckParam::kHold: return timeNormFromSeconds(play.adsr.holdSeconds);
|
||||
case DeckParam::kDecay: return timeNormFromSeconds(play.adsr.decaySeconds);
|
||||
case DeckParam::kSustain: return clamp01(play.adsr.sustainLevel);
|
||||
case DeckParam::kRelease: return secToNorm(play.adsr.releaseSeconds);
|
||||
case DeckParam::kRelease: return timeNormFromSeconds(play.adsr.releaseSeconds);
|
||||
case DeckParam::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve);
|
||||
case DeckParam::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
|
||||
case DeckParam::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
|
||||
case DeckParam::kTrigLength: return clamp01(play.trigger.lengthFraction);
|
||||
case DeckParam::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
|
||||
case DeckParam::kTrigAttack: return timeNormFromSeconds(play.trigAhd.attackSeconds);
|
||||
case DeckParam::kTrigHold: return clamp01(play.trigAhd.holdFraction);
|
||||
case DeckParam::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
|
||||
case DeckParam::kTrigDecay: return timeNormFromSeconds(play.trigAhd.decaySeconds);
|
||||
case DeckParam::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve);
|
||||
case DeckParam::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
|
||||
case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 1.0 : 0.0;
|
||||
case DeckParam::kPitchEnvAttack: return secToNorm(play.pitchEnv.shape.attackSeconds);
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
return timeNormFromSeconds(play.pitchEnv.shape.attackSeconds);
|
||||
case DeckParam::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
|
||||
case DeckParam::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
return timeNormFromSeconds(play.pitchEnv.shape.decaySeconds);
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
// Signed depth centred at 0.5 (0.5 == 0 semitones).
|
||||
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
||||
return depthNormFromSemitones(play.pitchEnv.peakSemitones, kPitchDepthMaxSemis);
|
||||
// Filter. The four tone controls ARE the module's normalized positions — stored and
|
||||
// shown as-is, so the knob travel is exactly filter_params' own law.
|
||||
case DeckParam::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
|
||||
@@ -65,20 +61,23 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
||||
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
||||
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
||||
case DeckParam::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
|
||||
case DeckParam::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
|
||||
case DeckParam::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
|
||||
case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds);
|
||||
case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds);
|
||||
case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds);
|
||||
case DeckParam::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
|
||||
case DeckParam::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
return timeNormFromSeconds(play.filter.env.releaseSeconds);
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
return util::knobNormFromCurve(play.filter.env.attackCurve);
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
return util::knobNormFromCurve(play.filter.env.decayCurve);
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
return util::knobNormFromCurve(play.filter.env.releaseCurve);
|
||||
case DeckParam::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
return timeNormFromSeconds(play.filter.trigEnv.attackSeconds);
|
||||
case DeckParam::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
|
||||
case DeckParam::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
return timeNormFromSeconds(play.filter.trigEnv.decaySeconds);
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
@@ -110,11 +109,11 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
case DeckParam::kPitchEngine:
|
||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
break;
|
||||
case DeckParam::kAttack: play.adsr.attackSeconds = normToSec(value); break;
|
||||
case DeckParam::kHold: play.adsr.holdSeconds = normToSec(value); break;
|
||||
case DeckParam::kDecay: play.adsr.decaySeconds = normToSec(value); break;
|
||||
case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
||||
case DeckParam::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
|
||||
case DeckParam::kRelease: play.adsr.releaseSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||
@@ -123,26 +122,26 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
// nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||
break;
|
||||
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break;
|
||||
case DeckParam::kTrigAttack: play.trigAhd.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
|
||||
case DeckParam::kTrigDecay: play.trigAhd.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
|
||||
play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kPitchEnvHold:
|
||||
play.pitchEnv.shape.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
|
||||
play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
||||
play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis);
|
||||
break;
|
||||
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
||||
case DeckParam::kFilterLaw:
|
||||
@@ -162,15 +161,15 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
||||
case DeckParam::kFilterEnvAttack:
|
||||
play.filter.env.attackSeconds = normToSec(value); break;
|
||||
play.filter.env.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvHold:
|
||||
play.filter.env.holdSeconds = normToSec(value); break;
|
||||
play.filter.env.holdSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvDecay:
|
||||
play.filter.env.decaySeconds = normToSec(value); break;
|
||||
play.filter.env.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvSustain:
|
||||
play.filter.env.sustainLevel = clamp01(value); break;
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
play.filter.env.releaseSeconds = normToSec(value); break;
|
||||
play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
@@ -178,11 +177,11 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
play.filter.trigEnv.attackSeconds = normToSec(value); break;
|
||||
play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterTrigHold:
|
||||
play.filter.trigEnv.holdFraction = clamp01(value); break;
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
play.filter.trigEnv.decaySeconds = normToSec(value); break;
|
||||
play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break;
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
@@ -197,9 +196,160 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
||||
enforceGateUnavailableWhileDrawn(play);
|
||||
}
|
||||
|
||||
// deckParamNorm and setDeckParam carry each id's MAP — which taper, which clamp; these two carry
|
||||
// only its LOCATION, which is the whole mechanism of the taper-free reset (see deck_values.h for
|
||||
// why they are exposed beyond that one caller). A toggle, radio or curve cell has no reset gesture
|
||||
// and resolves to null.
|
||||
double* deckDoubleField(DeckParam id, PlaySeconds& p) {
|
||||
switch (id) {
|
||||
case DeckParam::kAttack: return &p.adsr.attackSeconds;
|
||||
case DeckParam::kHold: return &p.adsr.holdSeconds;
|
||||
case DeckParam::kDecay: return &p.adsr.decaySeconds;
|
||||
case DeckParam::kSustain: return &p.adsr.sustainLevel;
|
||||
case DeckParam::kRelease: return &p.adsr.releaseSeconds;
|
||||
case DeckParam::kAttackCurve: return &p.adsr.attackCurve;
|
||||
case DeckParam::kDecayCurve: return &p.adsr.decayCurve;
|
||||
case DeckParam::kReleaseCurve: return &p.adsr.releaseCurve;
|
||||
case DeckParam::kTrigLength: return &p.trigger.lengthFraction;
|
||||
case DeckParam::kTrigAttack: return &p.trigAhd.attackSeconds;
|
||||
case DeckParam::kTrigHold: return &p.trigAhd.holdFraction;
|
||||
case DeckParam::kTrigDecay: return &p.trigAhd.decaySeconds;
|
||||
case DeckParam::kTrigAttackCurve: return &p.trigAhd.attackCurve;
|
||||
case DeckParam::kTrigDecayCurve: return &p.trigAhd.decayCurve;
|
||||
case DeckParam::kPitchEnvAttack: return &p.pitchEnv.shape.attackSeconds;
|
||||
case DeckParam::kPitchEnvHold: return &p.pitchEnv.shape.holdFraction;
|
||||
case DeckParam::kPitchEnvDecay: return &p.pitchEnv.shape.decaySeconds;
|
||||
case DeckParam::kPitchEnvAttackCurve: return &p.pitchEnv.shape.attackCurve;
|
||||
case DeckParam::kPitchEnvDecayCurve: return &p.pitchEnv.shape.decayCurve;
|
||||
case DeckParam::kPitchEnvDepth: return &p.pitchEnv.peakSemitones;
|
||||
case DeckParam::kFilterModAmt: return &p.filter.modAmount;
|
||||
case DeckParam::kFilterVel: return &p.filter.velAmount;
|
||||
case DeckParam::kFilterKeyTrack: return &p.filter.keyTrack;
|
||||
case DeckParam::kFilterEnvAttack: return &p.filter.env.attackSeconds;
|
||||
case DeckParam::kFilterEnvHold: return &p.filter.env.holdSeconds;
|
||||
case DeckParam::kFilterEnvDecay: return &p.filter.env.decaySeconds;
|
||||
case DeckParam::kFilterEnvSustain: return &p.filter.env.sustainLevel;
|
||||
case DeckParam::kFilterEnvRelease: return &p.filter.env.releaseSeconds;
|
||||
case DeckParam::kFilterEnvAttackCurve: return &p.filter.env.attackCurve;
|
||||
case DeckParam::kFilterEnvDecayCurve: return &p.filter.env.decayCurve;
|
||||
case DeckParam::kFilterEnvReleaseCurve: return &p.filter.env.releaseCurve;
|
||||
case DeckParam::kFilterTrigAttack: return &p.filter.trigEnv.attackSeconds;
|
||||
case DeckParam::kFilterTrigHold: return &p.filter.trigEnv.holdFraction;
|
||||
case DeckParam::kFilterTrigDecay: return &p.filter.trigEnv.decaySeconds;
|
||||
case DeckParam::kFilterTrigAttackCurve: return &p.filter.trigEnv.attackCurve;
|
||||
case DeckParam::kFilterTrigDecayCurve: return &p.filter.trigEnv.decayCurve;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// The filter's four tone controls store their NORMALIZED position, as floats, and their law is
|
||||
// wire-frozen — hence a second resolver rather than a widened first one.
|
||||
float* deckFloatField(DeckParam id, PlaySeconds& p) {
|
||||
switch (id) {
|
||||
case DeckParam::kFilterMorph: return &p.filter.settings.morphNorm;
|
||||
case DeckParam::kFilterCutoff: return &p.filter.settings.cutoffNorm;
|
||||
case DeckParam::kFilterQ: return &p.filter.settings.resonanceNorm;
|
||||
case DeckParam::kFilterDrive: return &p.filter.settings.driveNorm;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void resetDeckParam(DeckParam id, PlaySeconds& play) {
|
||||
const PlaySeconds defaults;
|
||||
setDeckParam(id, play, deckParamNorm(id, defaults), 0);
|
||||
PlaySeconds defaults;
|
||||
if (double* dst = deckDoubleField(id, play)) {
|
||||
*dst = *deckDoubleField(id, defaults);
|
||||
return;
|
||||
}
|
||||
if (float* dst = deckFloatField(id, play)) *dst = *deckFloatField(id, defaults);
|
||||
}
|
||||
|
||||
UnitCategory deckParamUnit(DeckParam id) {
|
||||
switch (id) {
|
||||
case DeckParam::kAttack:
|
||||
case DeckParam::kHold:
|
||||
case DeckParam::kDecay:
|
||||
case DeckParam::kRelease:
|
||||
case DeckParam::kTrigAttack:
|
||||
case DeckParam::kTrigDecay:
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
case DeckParam::kFilterEnvAttack:
|
||||
case DeckParam::kFilterEnvHold:
|
||||
case DeckParam::kFilterEnvDecay:
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
return UnitCategory::Milliseconds;
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
return UnitCategory::Semitones;
|
||||
// The filter's four tone controls read out in Hz / Q / drive depth but snap in whole
|
||||
// percent of the normalized position they STORE — display and snap are independent axes.
|
||||
case DeckParam::kSustain:
|
||||
case DeckParam::kTrigLength:
|
||||
case DeckParam::kTrigHold:
|
||||
case DeckParam::kPitchEnvHold:
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
case DeckParam::kFilterMorph:
|
||||
case DeckParam::kFilterCutoff:
|
||||
case DeckParam::kFilterQ:
|
||||
case DeckParam::kFilterDrive:
|
||||
case DeckParam::kFilterModAmt:
|
||||
case DeckParam::kFilterVel:
|
||||
case DeckParam::kFilterEnvSustain:
|
||||
case DeckParam::kFilterTrigHold:
|
||||
return UnitCategory::Percent;
|
||||
case DeckParam::kAttackCurve:
|
||||
case DeckParam::kDecayCurve:
|
||||
case DeckParam::kReleaseCurve:
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
return UnitCategory::Exponent;
|
||||
case DeckParam::kMasterGain:
|
||||
return UnitCategory::Decibels;
|
||||
default:
|
||||
// Toggles, radios, the curve-popup cells, and the already-integer voice count.
|
||||
return UnitCategory::None;
|
||||
}
|
||||
}
|
||||
|
||||
double snapDeckParamNorm(DeckParam id, double norm) {
|
||||
switch (deckParamUnit(id)) {
|
||||
case UnitCategory::Milliseconds:
|
||||
return timeNormFromSeconds(snapSecondsToWholeMs(timeSecondsFromNorm(norm)));
|
||||
case UnitCategory::Semitones:
|
||||
return depthNormFromSemitones(
|
||||
snapSemitonesToWhole(depthSemitonesFromNorm(norm, kPitchDepthMaxSemis)),
|
||||
kPitchDepthMaxSemis);
|
||||
case UnitCategory::Exponent:
|
||||
return util::knobNormFromCurve(snapExponentToWhole(util::curveFromKnobNorm(norm)));
|
||||
case UnitCategory::Decibels:
|
||||
return engine::masterGainNormFromDb(
|
||||
std::nearbyint(engine::masterGainDbFromNorm(norm)));
|
||||
case UnitCategory::Percent:
|
||||
switch (id) {
|
||||
case DeckParam::kFilterModAmt:
|
||||
case DeckParam::kFilterVel:
|
||||
return deckNormFromBipolar(
|
||||
snapFractionToWholePercent(deckBipolarFromNorm(norm)));
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterKeyTrack:
|
||||
return clamp01(
|
||||
snapFractionToWholePercent(clamp01(norm) * kKeyTrackMax) / kKeyTrackMax);
|
||||
default:
|
||||
return clamp01(snapFractionToWholePercent(clamp01(norm)));
|
||||
}
|
||||
case UnitCategory::None:
|
||||
break;
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
void formatEnvTimeMs(double seconds, char* buf, std::size_t len) {
|
||||
|
||||
@@ -11,14 +11,15 @@
|
||||
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
|
||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
|
||||
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using map::PlaySeconds;
|
||||
|
||||
// Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly what the
|
||||
// parameter set stores. READ from the overlay's schematic scale rather than restated: the AHDSR
|
||||
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
|
||||
// parameter set stores. An ALIAS of the overlay's schematic domain, which is itself an alias of
|
||||
// the taper's; param_taper.h owns why the number has one home.
|
||||
inline constexpr double kEnvTimeMaxSeconds = kGateStageMaxSeconds;
|
||||
|
||||
// Pitch depth throw: +/-kVelocityPitchRangeSemitones, centred. The one throw the pitch
|
||||
@@ -28,10 +29,11 @@ inline constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
|
||||
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
|
||||
inline constexpr double kKeyTrackMax = 2.0;
|
||||
|
||||
// The normalized [0,1] a control shows: seconds over the ceiling, levels and fractions as-is,
|
||||
// signed depths centred at 0.5, curve exponents over their logarithmic travel. Controls backed
|
||||
// by per-instance state rather than the parameter set (voice count, master gain, the pitch
|
||||
// key-track scalar, preview velocity) are not here — the shell reads those from the processor.
|
||||
// The normalized [0,1] a control shows: stage times through the shared time taper, levels and
|
||||
// fractions as-is, signed depths through the centre-expanded depth taper, curve exponents over
|
||||
// their logarithmic travel. Controls backed by per-instance state rather than the parameter set
|
||||
// (voice count, master gain, the pitch key-track scalar, preview velocity) are not here — the
|
||||
// shell reads those from the processor.
|
||||
double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
||||
|
||||
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
|
||||
@@ -39,13 +41,33 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
|
||||
|
||||
// Resets `id` to its default. The default IS what a fresh PlaySeconds carries, so there is no
|
||||
// second table of defaults to drift from the real one. It arrives via the norm round trip, so
|
||||
// landing EXACTLY on a stage time (0.003 s attack, 0.060 s release) depends on
|
||||
// kEnvTimeMaxSeconds being a power of two — x/2^n*2^n is lossless, an arbitrary ceiling is not.
|
||||
// Move that ceiling off a power of two and a reset lands a mantissa bit off its own default.
|
||||
// second table of defaults to drift from the real one, and the value is COPIED rather than
|
||||
// round-tripped through norm -> value. That bypass is MANDATORY: a reset must land on the stored
|
||||
// default bit for bit, and no round trip through a log taper over a non-power-of-two ceiling can
|
||||
// promise that for every control. Never "simplify" it back into a round trip.
|
||||
// For knob-valued controls — a toggle has no reset gesture.
|
||||
void resetDeckParam(DeckParam id, PlaySeconds& play);
|
||||
|
||||
// The ADDRESS of the one stored field `id` owns — the mechanism resetDeckParam bypasses the taper
|
||||
// with. Exposed beyond that one caller so a test can verify a reset (or any other mutation)
|
||||
// against the actual stored field rather than its normalized read-back, which deckParamNorm does
|
||||
// not guarantee is injective. Null for a control with no reset gesture (a toggle, radio, or
|
||||
// curve-popup cell) or one whose value lives outside PlaySeconds (master gain, key-track).
|
||||
double* deckDoubleField(DeckParam id, PlaySeconds& p);
|
||||
// The filter's four tone controls store their normalized position as float — see deckFloatField's
|
||||
// definition for why that is a second resolver rather than a widened first one.
|
||||
float* deckFloatField(DeckParam id, PlaySeconds& p);
|
||||
|
||||
// THE snap-unit table: which whole unit Shift snaps each control to. Includes the deck's
|
||||
// processor-side ids (voice count, master gain), which have no entry in the two functions above
|
||||
// because their VALUE lives outside the parameter set — the unit does not.
|
||||
UnitCategory deckParamUnit(DeckParam id);
|
||||
|
||||
// Applies that snap to a control's normalized value. Snapping happens in the DISPLAYED unit, so
|
||||
// this is where each control's full scale enters: 0..100 %, 0..200 % and +/-100 % all snap to a
|
||||
// whole displayed percent and therefore take different norm steps.
|
||||
double snapDeckParamNorm(DeckParam id, double norm);
|
||||
|
||||
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
|
||||
// one unit so two stage times are comparable at a glance. Sub-10 ms keeps one decimal so a short
|
||||
// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator.
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01;
|
||||
using util::curveFromMidLevel;
|
||||
using util::curveMidLevel;
|
||||
using util::curveFromLevelAt;
|
||||
using util::curveLevelAt;
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -23,11 +23,25 @@ double secondsPerPixel(const Rect& area, double totalSeconds) {
|
||||
return totalSeconds / static_cast<double>(w);
|
||||
}
|
||||
|
||||
// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
|
||||
// dragged handle tracks the cursor 1:1.
|
||||
double gateSecondsPerPixel(const Rect& area) {
|
||||
const double pps = gatePxPerSecond(area);
|
||||
return pps > 0.0 ? 1.0 / pps : 0.0;
|
||||
// The exact inverse of gatePolyline's tapered stage placement: a stage's drawn offset inside its
|
||||
// slot is slot * timeNormFromSeconds(t), so a pixel delta moves the NORM by dx/slot — never the
|
||||
// seconds by a fixed rate. Reading the same taper the draw does is what makes a dragged handle
|
||||
// track the cursor at both ends of the range instead of only near the ceiling.
|
||||
double gateStageFromPixels(double grabSeconds, const Rect& area, double dxPixels) {
|
||||
const double slot = gateStageSlotPx(area);
|
||||
if (slot <= 0.0) return grabSeconds;
|
||||
return timeSecondsFromNorm(timeNormFromSeconds(grabSeconds) + dxPixels / slot);
|
||||
}
|
||||
|
||||
// Shift's snaps, applied to the resolved param before its clamp so the domain edge always wins.
|
||||
double snappedSeconds(double seconds, const DragModifiers& m) {
|
||||
return m.shift ? snapSecondsToWholeMs(seconds) : seconds;
|
||||
}
|
||||
double snappedFraction(double fraction, const DragModifiers& m) {
|
||||
return m.shift ? snapFractionToWholePercent(fraction) : fraction;
|
||||
}
|
||||
double snappedExponent(double exponent, const DragModifiers& m) {
|
||||
return m.shift ? snapExponentToWhole(exponent) : exponent;
|
||||
}
|
||||
|
||||
// Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
|
||||
@@ -91,10 +105,35 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
|
||||
// curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law).
|
||||
// The pixel bounds of the segment a curve knot rides, by node — read off the SAME polyline the
|
||||
// draw built (never re-derived), so the drag's phi can never disagree with knotVtx's.
|
||||
struct SegmentPixels {
|
||||
int x0 = 0;
|
||||
int x1 = 0;
|
||||
bool ok = false;
|
||||
};
|
||||
SegmentPixels segmentPixels(const std::vector<EnvVertex>& poly, EnvNode knot) {
|
||||
EnvNode startNode, endNode;
|
||||
switch (knot) {
|
||||
case EnvNode::AttackCurve: startNode = EnvNode::Origin; endNode = EnvNode::AttackEnd; break;
|
||||
case EnvNode::DecayCurve: startNode = EnvNode::HoldEnd; endNode = EnvNode::DecayEnd; break;
|
||||
case EnvNode::ReleaseCurve: startNode = EnvNode::ReleaseStart; endNode = EnvNode::ReleaseEnd; break;
|
||||
default: return {};
|
||||
}
|
||||
SegmentPixels s;
|
||||
bool haveStart = false, haveEnd = false;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.node == startNode) { s.x0 = v.x; haveStart = true; }
|
||||
else if (v.node == endNode) { s.x1 = v.x; haveEnd = true; }
|
||||
}
|
||||
s.ok = haveStart && haveEnd;
|
||||
return s;
|
||||
}
|
||||
|
||||
// A knot drag: the grab-time level at `phi` (the phi the knot's own drawn x implies — see
|
||||
// knotPhi) shifted by the pixel delta, read back through curve_law's inverse at that same phi.
|
||||
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
|
||||
const Rect& area, int dyPixels) {
|
||||
double phi, const Rect& area, double dyPixels) {
|
||||
const SegmentLevels seg = segmentLevels(grabEnv, knot);
|
||||
if (!seg.ok) return grabExponent;
|
||||
const double span = seg.end - seg.start;
|
||||
@@ -103,9 +142,9 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab
|
||||
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
|
||||
// level travel — a segment thinner than that is visually a no-op drag anyway.
|
||||
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
|
||||
const double grabLevel = seg.start + span * curveMidLevel(grabExponent);
|
||||
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
|
||||
return curveFromMidLevel((newLevel - seg.start) / span);
|
||||
const double grabLevel = seg.start + span * curveLevelAt(phi, grabExponent);
|
||||
const double newLevel = grabLevel - dyPixels * levelPerPixel(area);
|
||||
return curveFromLevelAt(phi, (newLevel - seg.start) / span);
|
||||
}
|
||||
|
||||
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
|
||||
@@ -150,15 +189,29 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
|
||||
|
||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
int dxPixels, int dyPixels, const DragModifiers& mods) {
|
||||
StageEnvelope out = grabEnv;
|
||||
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
|
||||
|
||||
const Rect& rect = area.rect;
|
||||
const double secPerPx = secondsPerPixel(rect, totalSeconds);
|
||||
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
|
||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
||||
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
|
||||
// Fine drag scales the PIXEL delta, so it composes with every axis below (the tapered
|
||||
// schematic, the 1:1 wall clock, the level and the exponent) without a second rule.
|
||||
const double scale = fineDrag(mods) ? kFineDragScale : 1.0;
|
||||
const double dx = static_cast<double>(dxPixels) * scale;
|
||||
const double dy = static_cast<double>(dyPixels) * scale;
|
||||
const double dSec = dx * secPerPx;
|
||||
|
||||
// A curve knot's phi is read off the same polyline knotVtx drew, so the drag inverts the
|
||||
// exact phi the knot is sitting at rather than assuming the segment midpoint.
|
||||
double curvePhi = 0.5;
|
||||
if (node == EnvNode::AttackCurve || node == EnvNode::DecayCurve ||
|
||||
node == EnvNode::ReleaseCurve) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(grabEnv, area, totalSeconds);
|
||||
const SegmentPixels sp = segmentPixels(poly, node);
|
||||
if (sp.ok) curvePhi = knotPhi(sp.x0, sp.x1);
|
||||
}
|
||||
|
||||
if (grabEnv.kind == EnvKind::Ahdsr) {
|
||||
switch (node) {
|
||||
@@ -166,38 +219,43 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
||||
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
|
||||
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
||||
out.attackSeconds = std::clamp(
|
||||
snappedSeconds(gateStageFromPixels(grabEnv.attackSeconds, rect, dx), mods), 0.0,
|
||||
bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd:
|
||||
out.holdSeconds =
|
||||
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
|
||||
out.holdSeconds = std::clamp(
|
||||
snappedSeconds(gateStageFromPixels(grabEnv.holdSeconds, rect, dx), mods), 0.0,
|
||||
bounds.maxHoldSeconds);
|
||||
break;
|
||||
case EnvNode::DecayEnd: {
|
||||
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
|
||||
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
out.decaySeconds = std::clamp(
|
||||
snappedSeconds(gateStageFromPixels(grabEnv.decaySeconds, rect, dx), mods), 0.0,
|
||||
bounds.maxDecaySeconds);
|
||||
const double dLevel = -dy * levelPerPixel(rect);
|
||||
out.sustainLevel =
|
||||
std::clamp(snappedFraction(grabEnv.sustainLevel + dLevel, mods), 0.0, 1.0);
|
||||
break;
|
||||
}
|
||||
case EnvNode::ReleaseStart:
|
||||
// The release runs from this node to the anchored right edge, so dragging LEFT
|
||||
// (negative dx) lengthens it — the delta enters with the opposite sign.
|
||||
out.releaseSeconds =
|
||||
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
out.releaseSeconds = std::clamp(
|
||||
snappedSeconds(gateStageFromPixels(grabEnv.releaseSeconds, rect, -dx), mods), 0.0,
|
||||
bounds.maxReleaseSeconds);
|
||||
break;
|
||||
case EnvNode::AttackCurve:
|
||||
out.attackCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
||||
out.attackCurve = snappedExponent(
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
|
||||
break;
|
||||
case EnvNode::DecayCurve:
|
||||
out.decayCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
||||
out.decayCurve = snappedExponent(
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
|
||||
break;
|
||||
case EnvNode::ReleaseCurve:
|
||||
out.releaseCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
|
||||
out.releaseCurve = snappedExponent(
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, curvePhi, rect, dy), mods);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -209,8 +267,8 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
||||
const AhdSplit s = splitAhdSeconds(grabEnv);
|
||||
switch (node) {
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
|
||||
out.attackSeconds = std::clamp(snappedSeconds(grabEnv.attackSeconds + dSec, mods), 0.0,
|
||||
bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd: {
|
||||
// Hold is a fraction of what attack and decay left, so the node's pixel motion
|
||||
@@ -218,7 +276,7 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
||||
// nothing the drag could express.
|
||||
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
|
||||
if (rem <= 0.0) break;
|
||||
out.holdFraction = clamp01((s.hold + dSec) / rem);
|
||||
out.holdFraction = clamp01(snappedFraction((s.hold + dSec) / rem, mods));
|
||||
break;
|
||||
}
|
||||
case EnvNode::DecayEnd: {
|
||||
@@ -231,17 +289,18 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
||||
// unchanged rather than divided by zero.
|
||||
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
|
||||
if (denom > 1e-9) {
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds);
|
||||
out.decaySeconds = std::clamp(snappedSeconds(grabEnv.decaySeconds + dSec / denom, mods),
|
||||
0.0, bounds.maxDecaySeconds);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EnvNode::AttackCurve:
|
||||
out.attackCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
||||
out.attackCurve = snappedExponent(
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
|
||||
break;
|
||||
case EnvNode::DecayCurve:
|
||||
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
||||
out.decayCurve = snappedExponent(
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01;
|
||||
using util::curveMap;
|
||||
using util::curveMidLevel;
|
||||
using util::curveLevelAt;
|
||||
|
||||
int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
const int w = std::max(0, area.width);
|
||||
@@ -23,7 +22,7 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
return area.x + static_cast<int>(px + 0.5);
|
||||
}
|
||||
|
||||
double gatePxPerSecond(const Rect& area) {
|
||||
double gateStageSlotPx(const Rect& area) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0) return 0.0;
|
||||
// The four timed stages share the canvas minus their four separation bases and the last
|
||||
@@ -31,7 +30,7 @@ double gatePxPerSecond(const Rect& area) {
|
||||
// puts the plateau's end one separation short of the right edge rather than a fixed
|
||||
// fraction of the way across.
|
||||
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
|
||||
return usable / (4.0 * kGateStageMaxSeconds);
|
||||
return usable / 4.0;
|
||||
}
|
||||
|
||||
int levelToY(const Rect& area, double level) {
|
||||
@@ -46,6 +45,12 @@ int levelToY(const Rect& area, double level) {
|
||||
return area.y + static_cast<int>(dy);
|
||||
}
|
||||
|
||||
double knotPhi(int x0, int x1) {
|
||||
if (x1 == x0) return 0.5;
|
||||
const int mid = (x0 + x1) / 2;
|
||||
return static_cast<double>(mid - x0) / static_cast<double>(x1 - x0);
|
||||
}
|
||||
|
||||
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
|
||||
AhdSplit out;
|
||||
const double span = std::max(0.0, env.spanSeconds);
|
||||
@@ -90,14 +95,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool
|
||||
}
|
||||
|
||||
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
|
||||
// pixel midpoint, its level read through curve_law.h's own law (the knot/dial pairing's home).
|
||||
// pixel midpoint. Its level is read at the phi that midpoint's TRUNCATED x actually implies
|
||||
// (knotPhi), not always phi = 0.5 — an odd-pixel span would otherwise draw the knot a half
|
||||
// pixel off the curve its own vertices trace. curve_law.h owns the knot/dial pairing.
|
||||
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
|
||||
double endLevel, double exponent) {
|
||||
const double u = curveMidLevel(exponent);
|
||||
const double level = startLevel + (endLevel - startLevel) * u;
|
||||
EnvVertex v;
|
||||
v.node = node;
|
||||
v.x = (x0 + x1) / 2;
|
||||
const double u = curveLevelAt(knotPhi(x0, x1), exponent);
|
||||
const double level = startLevel + (endLevel - startLevel) * u;
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
v.knot = true;
|
||||
@@ -114,17 +121,17 @@ std::vector<EnvVertex> gatePolyline(const StageEnvelope& env, const Rect& area)
|
||||
|
||||
const int W = std::max(1, area.width);
|
||||
const double sep = static_cast<double>(kGateNodeSepPx);
|
||||
const double pps = gatePxPerSecond(area);
|
||||
const double slot = gateStageSlotPx(area);
|
||||
const double xMax = static_cast<double>(W - 1);
|
||||
|
||||
// The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart —
|
||||
// the sustain->release join, and the node the user drags — sits a release-length to its
|
||||
// left. Everything the release does not take is the sustain plateau, so a zero release
|
||||
// leaves the plateau running to within one separation of the edge.
|
||||
double xAttack = sep + a * pps;
|
||||
double xHold = xAttack + sep + h * pps;
|
||||
double xDecay = xHold + sep + d * pps;
|
||||
double xPlateau = xMax - sep - r * pps;
|
||||
double xAttack = sep + slot * timeNormFromSeconds(a);
|
||||
double xHold = xAttack + sep + slot * timeNormFromSeconds(h);
|
||||
double xDecay = xHold + sep + slot * timeNormFromSeconds(d);
|
||||
double xPlateau = xMax - sep - slot * timeNormFromSeconds(r);
|
||||
const double xRelease = xMax;
|
||||
|
||||
// Keep every node separated when the four stages together would overrun the canvas: the
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
|
||||
#include "core/instrument/ui/param_taper.h" // kStageTimeMaxSeconds + the stage-time taper
|
||||
#include "core/util/curve_law.h" // the ONE per-segment curve law
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
@@ -80,14 +81,18 @@ struct EnvVertex {
|
||||
inline constexpr int kGateNodeSepPx = 8;
|
||||
|
||||
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
|
||||
// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at
|
||||
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
|
||||
inline constexpr double kGateStageMaxSeconds = 2.0;
|
||||
// span at most this. An ALIAS of the taper's own domain end, not a second constant: a maxed knob
|
||||
// lands exactly at the canvas edge (at which point the sustain plateau has shrunk to nothing)
|
||||
// only while the two agree.
|
||||
inline constexpr double kGateStageMaxSeconds = kStageTimeMaxSeconds;
|
||||
|
||||
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
|
||||
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
|
||||
// the cursor 1:1.
|
||||
double gatePxPerSecond(const Rect& area);
|
||||
// Width of ONE of the AHDSR schematic's four equal stage slots, independent of the sample's
|
||||
// actual duration. A stage of `t` seconds fills slot * timeNormFromSeconds(t) pixels of it — the
|
||||
// axis IS the knob's taper, so a node's position within its slot is that knob's needle position
|
||||
// drawn a second way. That is what keeps a 3 ms attack legible at a 10 s ceiling (linear in
|
||||
// seconds put it under a pixel) and what lets envelope_edit's inverse stay the EXACT inverse of
|
||||
// this draw. Only the AHDSR schematic is tapered; an AHD stays 1:1 wall-clock.
|
||||
double gateStageSlotPx(const Rect& area);
|
||||
|
||||
// Maps a staged envelope to polyline vertices inside `area` over a sample of `totalSeconds`
|
||||
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the
|
||||
@@ -110,6 +115,13 @@ int timeToX(const Rect& area, double totalSeconds, double t);
|
||||
// clamped. Shared with envelope_edit's node hit-test.
|
||||
int levelToY(const Rect& area, double level);
|
||||
|
||||
// The normalized phi a curve knot's TRUNCATED integer x actually lands at within its bounding
|
||||
// segment [x0, x1] — exactly 0.5 only when the span is even. Shared with envelope_edit's knot
|
||||
// drag so the draw and its inverse read the same phi off the same formula rather than two
|
||||
// copies that could drift apart. x0 == x1 (no interior) returns 0.5; callers never place a knot
|
||||
// there.
|
||||
double knotPhi(int x0, int x1);
|
||||
|
||||
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
|
||||
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
|
||||
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
|
||||
|
||||
@@ -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,86 @@
|
||||
// param_taper.cpp — see param_taper.h. Pure value math; no host types.
|
||||
|
||||
#include "core/instrument/ui/param_taper.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// The output quanta (header: EXACT PREIMAGE). Powers of TEN on purpose: std::round(v*S)/S is the
|
||||
// correctly-rounded double of k/S, which is the same double a decimal literal of k/S parses to —
|
||||
// so a default written as 0.003 or 0.060 lands on the grid exactly. A power-of-two quantum would
|
||||
// not have that property against decimal literals.
|
||||
constexpr double kSecondsPerQuantum = 1e9; // 1 ns
|
||||
constexpr double kSemitonesPerQuantum = 1e6; // 1 micro-semitone
|
||||
|
||||
// std::nearbyint reads the CURRENT FP rounding mode (MXCSR) — not exclusively ours on a DAW's UI
|
||||
// thread. Under round-toward-zero it can drop a grid value by a whole quantum, which is exactly
|
||||
// what the quantization scheme exists to prevent. std::round (half-away-from-zero) is the same
|
||||
// regardless of that mode, which is what makes the EXACT PREIMAGE guarantee (header) structural.
|
||||
double resolveTo(double value, double perUnit) { return std::round(value * perUnit) / perUnit; }
|
||||
|
||||
// The shifted-log offsets. Both are FITTED AGAINST THE CEILING above them, which is why the
|
||||
// ceiling could not be raised in a later track: doing the two apart means fitting twice.
|
||||
constexpr double kTimeOffsetSeconds = 0.003; // -> 10 ms at 0.181, 100 ms at 0.436
|
||||
constexpr double kDepthOffsetSemitones = 3.0; // -> +/-7 st at 0.548 of each half-travel
|
||||
|
||||
double timeSpan() { return std::log1p(kStageTimeMaxSeconds / kTimeOffsetSeconds); }
|
||||
|
||||
double depthSpan(double maxSemitones) {
|
||||
return std::log1p(maxSemitones / kDepthOffsetSemitones);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
double timeNormFromSeconds(double seconds) {
|
||||
if (!(seconds > 0.0)) return 0.0; // also catches NaN
|
||||
if (seconds >= kStageTimeMaxSeconds) return 1.0;
|
||||
return std::log1p(seconds / kTimeOffsetSeconds) / timeSpan();
|
||||
}
|
||||
|
||||
double timeSecondsFromNorm(double norm) {
|
||||
if (!(norm > 0.0)) return 0.0;
|
||||
if (norm >= 1.0) return kStageTimeMaxSeconds;
|
||||
return resolveTo(kTimeOffsetSeconds * std::expm1(norm * timeSpan()), kSecondsPerQuantum);
|
||||
}
|
||||
|
||||
double depthNormFromSemitones(double semitones, double maxSemitones) {
|
||||
if (!(maxSemitones > 0.0)) return 0.5;
|
||||
if (semitones == 0.0) return 0.5; // the centre is EXACT, so a knob parked there persists
|
||||
const double mag = std::fabs(semitones); // no depth at all
|
||||
if (!(mag < maxSemitones)) return semitones > 0.0 ? 1.0 : 0.0;
|
||||
const double u = std::log1p(mag / kDepthOffsetSemitones) / depthSpan(maxSemitones);
|
||||
return semitones > 0.0 ? 0.5 + 0.5 * u : 0.5 - 0.5 * u;
|
||||
}
|
||||
|
||||
double depthSemitonesFromNorm(double norm, double maxSemitones) {
|
||||
if (!(maxSemitones > 0.0)) return 0.0;
|
||||
if (!(norm > 0.0)) return -maxSemitones; // also catches NaN
|
||||
if (norm >= 1.0) return maxSemitones;
|
||||
if (norm == 0.5) return 0.0;
|
||||
const double u = std::fabs(norm - 0.5) * 2.0;
|
||||
const double mag = resolveTo(kDepthOffsetSemitones * std::expm1(u * depthSpan(maxSemitones)),
|
||||
kSemitonesPerQuantum);
|
||||
return norm > 0.5 ? mag : -mag;
|
||||
}
|
||||
|
||||
double snapSecondsToWholeMs(double seconds) {
|
||||
if (!(seconds > 0.0)) return 0.0;
|
||||
return std::round(seconds * 1000.0) / 1000.0;
|
||||
}
|
||||
|
||||
double snapFractionToWholePercent(double fraction) {
|
||||
return std::round(fraction * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
double snapSemitonesToWhole(double semitones) { return std::round(semitones); }
|
||||
|
||||
// Rounding lands on 1..10; anything under half a unit clamps to the domain floor rather than to
|
||||
// zero, which is not an exponent. 1.0, the linear neutral, is therefore one snap from centre.
|
||||
double snapExponentToWhole(double exponent) {
|
||||
return util::clampCurve(std::round(util::clampCurve(exponent)));
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,87 @@
|
||||
// param_taper.h — THE norm <-> value tapers every variable control shares, plus the modifier
|
||||
// vocabulary its drag surfaces read. Extracted from deck_values because three consumers in two
|
||||
// dependency layers read it — the knob's needle, the AHDSR overlay's schematic axis and its drag
|
||||
// inverse, and the VST3 host's normalization — and three functions that agree today is a defect.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/util/curve_law.h" // the exponent domain the whole-number snap clamps into
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// --- the interaction law's modifiers -------------------------------------------------------
|
||||
|
||||
// Ctrl divides the drag rate by 20. Shift+Ctrl is SHIFT, Ctrl ignored: with the output quantized
|
||||
// to whole units a finer drag yields the same sequence of values, so that is an identity rather
|
||||
// than a compromise — do not "fix" it into a compounded scale.
|
||||
inline constexpr double kFineDragScale = 0.05;
|
||||
|
||||
struct DragModifiers {
|
||||
bool shift = false; // snap to whole units of the control's displayed unit
|
||||
bool ctrl = false; // fine drag
|
||||
|
||||
bool operator==(const DragModifiers& o) const { return shift == o.shift && ctrl == o.ctrl; }
|
||||
bool operator!=(const DragModifiers& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
inline bool fineDrag(const DragModifiers& m) { return m.ctrl && !m.shift; }
|
||||
|
||||
// Which whole unit Shift snaps a control to. Derived from the control's UNIT rather than from a
|
||||
// per-widget list, so a control added later inherits the law by naming its category.
|
||||
enum class UnitCategory {
|
||||
None, // discrete, already-integer, or non-scalar controls — Shift changes nothing
|
||||
Milliseconds,
|
||||
Semitones,
|
||||
Percent,
|
||||
Exponent,
|
||||
Decibels,
|
||||
};
|
||||
|
||||
// --- the two tapers ------------------------------------------------------------------------
|
||||
//
|
||||
// EXACT PREIMAGE, and why it is structural rather than lucky. A host's reset-to-default arrives
|
||||
// as toPlain(defaultNorm) with no editor-side bypass available, so every default must satisfy
|
||||
// toPlain(toNormalized(d)) == d BITWISE. No transcendental map delivers that at an arbitrary
|
||||
// interior point — the image of toPlain is sparser there than the doubles around it — so both
|
||||
// maps below resolve their output onto a fixed decimal quantum, via std::round rather than
|
||||
// std::nearbyint: round is half-away-from-zero regardless of the caller's FP rounding mode, so
|
||||
// the quantization is mode-independent, not just decimal-exact. That turns the guarantee into
|
||||
// "every value on the quantum grid round-trips exactly" instead of a libm/MXCSR coincidence that
|
||||
// a compiler upgrade or a host's UI thread could take away. Both quanta sit roughly 3.7-4 orders
|
||||
// below the finest reachable drag step (time ~3.98, depth ~3.71), so nothing observable is
|
||||
// quantized. The converse, toNormalized(toPlain(n)) == n at arbitrary n, is NOT required and must
|
||||
// not be demanded: no log map satisfies it in double, and requiring it would rule out the shape
|
||||
// the range needs.
|
||||
|
||||
// The stage-time domain's upper end — the value at norm 1, and the one home of that number:
|
||||
// envelope_overlay's kGateStageMaxSeconds and deck_values' kEnvTimeMaxSeconds are both aliases
|
||||
// of it, so the schematic's canvas edge and the knob's ceiling cannot drift apart. Once the
|
||||
// instrument reports VST3 parameters this endpoint is a frozen host normalization — moving it
|
||||
// re-interprets every automation point already recorded in projects we do not own.
|
||||
inline constexpr double kStageTimeMaxSeconds = 10.0;
|
||||
|
||||
// Shifted-log: exactly 0 s at norm 0, exactly kStageTimeMaxSeconds at norm 1, monotone
|
||||
// throughout, low end expanded so 10 ms sits at ~0.18 of the travel and 100 ms at ~0.44. A pure
|
||||
// log cannot include zero and zero is a required value, which is what the offset buys.
|
||||
double timeNormFromSeconds(double seconds);
|
||||
double timeSecondsFromNorm(double norm);
|
||||
|
||||
// Signed depth, symmetric about norm 0.5: exactly 0 semitones at centre, exactly +/-maxSemitones
|
||||
// at the ends, monotone, with the musically useful middle expanded so +/-7 st reaches ~0.55 of
|
||||
// each half-travel. The throw is a PARAMETER — the +/-24 st depth constant has its own home in
|
||||
// the engine's value layer and is not restated here.
|
||||
double depthNormFromSemitones(double semitones, double maxSemitones);
|
||||
double depthSemitonesFromNorm(double norm, double maxSemitones);
|
||||
|
||||
// --- Shift's whole-unit snaps, in the VALUE domain -------------------------------------------
|
||||
//
|
||||
// Stated over values rather than norms because "whole unit" means whole unit of what the control
|
||||
// DISPLAYS: two controls sharing a category can have different full scales, so the norm step is
|
||||
// the caller's business and the unit is this module's.
|
||||
|
||||
double snapSecondsToWholeMs(double seconds);
|
||||
double snapFractionToWholePercent(double fraction); // 1.0 == 100 %
|
||||
double snapSemitonesToWhole(double semitones);
|
||||
double snapExponentToWhole(double exponent); // clamped into curve_law's own domain
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
+20
-14
@@ -1,8 +1,8 @@
|
||||
#pragma once
|
||||
// curve_law — the ONE per-segment envelope curve law: the exponent domain, the map from a
|
||||
// stage's normalized position to its normalized level, and the mid-segment inverse the
|
||||
// overlay knot drags through. Header-only and dependency-free so the engine evaluator, the
|
||||
// overlay's forward map, and its inverse all read the same law rather than three copies.
|
||||
// stage's normalized position to its normalized level, and that map's inverse (mid-segment is
|
||||
// the special case). Header-only and dependency-free so the engine evaluator, the overlay's
|
||||
// forward map, and its inverse all read the same law rather than three copies.
|
||||
|
||||
#include <cmath>
|
||||
|
||||
@@ -58,19 +58,25 @@ inline double knobNormFromCurve(double exponent) {
|
||||
return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
|
||||
}
|
||||
|
||||
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
|
||||
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
|
||||
// one value: both resolve through this law, not through each other.
|
||||
inline double curveMidLevel(double exponent) { return curveMap(0.5, clampCurve(exponent)); }
|
||||
// The normalized level at an arbitrary segment position phi in (0,1), and its inverse. A
|
||||
// knot's DRAWN x truncates to an integer, which lands it off phi = 0.5 whenever its segment's
|
||||
// pixel span is odd; reading the knot's y through the phi its own x actually implies (rather
|
||||
// than assuming 0.5) is what keeps the knot on the trace its own vertices draw.
|
||||
inline double curveLevelAt(double phi, double exponent) { return curveMap(phi, clampCurve(exponent)); }
|
||||
|
||||
// Mid-level -> exponent: u = 0.5^p, so p = ln(u)/ln(0.5). Out-of-domain u clamps to the
|
||||
// Level -> exponent at phi: u = phi^p, so p = ln(u)/ln(phi). Out-of-domain u clamps to the
|
||||
// exponent endpoints rather than producing a non-finite exponent.
|
||||
inline double curveFromMidLevel(double midLevel) {
|
||||
const double lo = curveMidLevel(kCurveMax); // smallest reachable mid-level
|
||||
const double hi = curveMidLevel(kCurveMin); // largest
|
||||
if (!(midLevel > lo)) return kCurveMax; // also catches NaN
|
||||
if (midLevel >= hi) return kCurveMin;
|
||||
return clampCurve(std::log(midLevel) / std::log(0.5));
|
||||
inline double curveFromLevelAt(double phi, double level) {
|
||||
const double lo = curveLevelAt(phi, kCurveMax); // smallest reachable level at this phi
|
||||
const double hi = curveLevelAt(phi, kCurveMin); // largest
|
||||
if (!(level > lo)) return kCurveMax; // also catches NaN
|
||||
if (level >= hi) return kCurveMin;
|
||||
return clampCurve(std::log(level) / std::log(phi));
|
||||
}
|
||||
|
||||
// The segment-MIDPOINT (phi = 0.5) case — the knot's placement whenever its pixel span is
|
||||
// even. Kept under its own name for the existing callers/tests that assume that case.
|
||||
inline double curveMidLevel(double exponent) { return curveLevelAt(0.5, exponent); }
|
||||
inline double curveFromMidLevel(double midLevel) { return curveFromLevelAt(0.5, midLevel); }
|
||||
|
||||
} // namespace reasampler::util
|
||||
|
||||
Reference in New Issue
Block a user