From 3eb72d01c4ae549439b613ca55f325fadfccedc6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:09:19 -0400 Subject: [PATCH] One taper, one modifier law: extract param_taper, raise the stage ceiling to 10 s, and make the AHDSR schematic axis the taper itself --- src/core/instrument/CLAUDE.md | 15 +- src/core/instrument/ui/CMakeLists.txt | 21 +- src/core/instrument/ui/deck_values.cpp | 233 +++++++++++++--- src/core/instrument/ui/deck_values.h | 32 ++- src/core/instrument/ui/envelope_edit.cpp | 92 ++++--- src/core/instrument/ui/envelope_edit.h | 9 +- src/core/instrument/ui/envelope_overlay.cpp | 14 +- src/core/instrument/ui/envelope_overlay.h | 19 +- src/core/instrument/ui/param_slider.cpp | 6 +- src/core/instrument/ui/param_slider.h | 12 +- src/core/instrument/ui/param_taper.cpp | 82 ++++++ src/core/instrument/ui/param_taper.h | 84 ++++++ src/shell/instrument/CLAUDE.md | 4 +- src/shell/instrument/editor_input_curve.cpp | 6 +- src/shell/instrument/editor_input_deck.cpp | 17 +- .../instrument/editor_input_waveform.cpp | 23 +- src/shell/instrument/editor_internal.h | 10 + src/shell/instrument/reasampler_editor.h | 6 + tests/test_deck_values.cpp | 172 +++++++++++- tests/test_envelope_edit.cpp | 163 +++++++++-- tests/test_envelope_overlay.cpp | 91 ++++-- tests/test_param_slider.cpp | 30 +- tests/test_param_taper.cpp | 260 ++++++++++++++++++ 23 files changed, 1208 insertions(+), 193 deletions(-) create mode 100644 src/core/instrument/ui/param_taper.cpp create mode 100644 src/core/instrument/ui/param_taper.h create mode 100644 tests/test_param_taper.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 272c14a..cc82441 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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. diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 831144e..6138e9e 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/ui/deck_values.cpp b/src/core/instrument/ui/deck_values.cpp index 97c58c4..a391fc5 100644 --- a/src/core/instrument/ui/deck_values.cpp +++ b/src/core/instrument/ui/deck_values.cpp @@ -3,9 +3,11 @@ #include "core/instrument/ui/deck_values.h" #include +#include #include #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) { diff --git a/src/core/instrument/ui/deck_values.h b/src/core/instrument/ui/deck_values.h index 8512c46..8c23804 100644 --- a/src/core/instrument/ui/deck_values.h +++ b/src/core/instrument/ui/deck_values.h @@ -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. diff --git a/src/core/instrument/ui/envelope_edit.cpp b/src/core/instrument/ui/envelope_edit.cpp index 01cece9..dc5e2f0 100644 --- a/src/core/instrument/ui/envelope_edit.cpp +++ b/src/core/instrument/ui/envelope_edit.cpp @@ -23,11 +23,25 @@ double secondsPerPixel(const Rect& area, double totalSeconds) { return totalSeconds / static_cast(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(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(dxPixels) * secPerPx; - const double gateDSec = static_cast(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(dxPixels) * scale; + const double dy = static_cast(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(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; diff --git a/src/core/instrument/ui/envelope_edit.h b/src/core/instrument/ui/envelope_edit.h index b36bd05..61f15b1 100644 --- a/src/core/instrument/ui/envelope_edit.h +++ b/src/core/instrument/ui/envelope_edit.h @@ -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 diff --git a/src/core/instrument/ui/envelope_overlay.cpp b/src/core/instrument/ui/envelope_overlay.cpp index a611249..4648aa8 100644 --- a/src/core/instrument/ui/envelope_overlay.cpp +++ b/src/core/instrument/ui/envelope_overlay.cpp @@ -23,7 +23,7 @@ int timeToX(const Rect& area, double totalSeconds, double t) { return area.x + static_cast(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(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 gatePolyline(const StageEnvelope& env, const Rect& area) const int W = std::max(1, area.width); const double sep = static_cast(kGateNodeSepPx); - const double pps = gatePxPerSecond(area); + const double slot = gateStageSlotPx(area); const double xMax = static_cast(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 diff --git a/src/core/instrument/ui/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h index 474784e..2cc1f0d 100644 --- a/src/core/instrument/ui/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -9,6 +9,7 @@ #include #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 diff --git a/src/core/instrument/ui/param_slider.cpp b/src/core/instrument/ui/param_slider.cpp index 0c50889..feb1ce5 100644 --- a/src/core/instrument/ui/param_slider.cpp +++ b/src/core/instrument/ui/param_slider.cpp @@ -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(dyPixels) * (fineDrag(mods) ? kFineDragScale : 1.0); // Screen y grows downward: an upward drag (negative dy) increases the value. - return clamp01(start - static_cast(dyPixels) / dragRangePixels); + return clamp01(start - dy / dragRangePixels); } int controlAtPoint(const std::vector& rows, int x, int y) { diff --git a/src/core/instrument/ui/param_slider.h b/src/core/instrument/ui/param_slider.h index df9e14d..9652989 100644 --- a/src/core/instrument/ui/param_slider.h +++ b/src/core/instrument/ui/param_slider.h @@ -15,6 +15,7 @@ #include #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 diff --git a/src/core/instrument/ui/param_taper.cpp b/src/core/instrument/ui/param_taper.cpp new file mode 100644 index 0000000..647d589 --- /dev/null +++ b/src/core/instrument/ui/param_taper.cpp @@ -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 + +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 diff --git a/src/core/instrument/ui/param_taper.h b/src/core/instrument/ui/param_taper.h new file mode 100644 index 0000000..248e3fe --- /dev/null +++ b/src/core/instrument/ui/param_taper.h @@ -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 diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 555529b..f9d6ed3 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -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_` 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 diff --git a/src/shell/instrument/editor_input_curve.cpp b/src/shell/instrument/editor_input_curve.cpp index 02908fa..31ee47c 100644 --- a/src/shell/instrument/editor_input_curve.cpp +++ b/src/shell/instrument/editor_input_curve.cpp @@ -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(edit.index))) commitAndReload(); return; diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index ae487c6..cbdc930 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -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(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(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(); diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 2de7461..01d2673 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -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(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 diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h index 814353c..d6caa15 100644 --- a/src/shell/instrument/editor_internal.h +++ b/src/shell/instrument/editor_internal.h @@ -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& #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). diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 635068e..df050eb 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -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 diff --git a/tests/test_deck_values.cpp b/tests/test_deck_values.cpp index 1a0963f..fc85595 100644 --- a/tests/test_deck_values.cpp +++ b/tests/test_deck_values.cpp @@ -5,6 +5,9 @@ #include "../src/core/instrument/ui/deck_values.h" +#include "../src/core/instrument/engine/master_gain.h" + +#include #include #include #include @@ -22,13 +25,31 @@ static std::string msLabel(double seconds) { return std::string(buf); } -// Every domain the binding maps: a stage time over the seconds ceiling, a level, a fraction, +// The stage-time ceiling has TWO names — the overlay's schematic domain and the knob's — and they +// must be the same number or a maxed knob stops landing on the canvas edge. Asserted, not assumed. +static void testTheTwoCeilingNamesAreOneNumber() { + CHECK(kEnvTimeMaxSeconds == kGateStageMaxSeconds); + CHECK(kEnvTimeMaxSeconds == kStageTimeMaxSeconds); + CHECK(kEnvTimeMaxSeconds == 10.0); +} + +// Every domain the binding maps: a stage time through the shared taper, a level, a fraction, // a normalized filter position, a bipolar depth, and a curve exponent over its log travel. static void testNormRoundTripsThroughEveryValueDomain() { PlaySeconds p; setDeckParam(DeckParam::kAttack, p, 0.25, 0); - CHECK(p.adsr.attackSeconds == 0.25 * kEnvTimeMaxSeconds); - CHECK(deckParamNorm(DeckParam::kAttack, p) == 0.25); + CHECK(p.adsr.attackSeconds == timeSecondsFromNorm(0.25)); + // The VALUE round trip is what has to be exact (param_taper.h); the needle returning to the + // very same norm double is explicitly NOT required of a log map. The residual is bounded by + // the taper's output quantum read back through the map — under 1e-7 of the travel across the + // whole domain, which is four orders below one drag pixel. + CHECK(std::fabs(deckParamNorm(DeckParam::kAttack, p) - 0.25) < 1e-7); + // The raised ceiling costs the low end nothing: a several-second stage is reachable by hand, + // AND everything under 100 ms still gets more than 40 % of the knob's travel to itself. + setDeckParam(DeckParam::kDecay, p, 0.95, 0); + CHECK(p.adsr.decaySeconds > 5.0 && p.adsr.decaySeconds < kEnvTimeMaxSeconds); + setDeckParam(DeckParam::kDecay, p, 0.42, 0); + CHECK(p.adsr.decaySeconds < 0.100); setDeckParam(DeckParam::kSustain, p, 0.4, 0); CHECK(p.adsr.sustainLevel == 0.4); @@ -122,9 +143,8 @@ static void testInnerResetLandsOnTheExactLinearNeutral() { } // A reset lands on the field's own stored default, EXACTLY — the defaults are read off a fresh -// PlaySeconds and arrive through the norm round trip, so the two stage times whose defaults are -// neither 0 nor 1 are the cases that actually exercise that exactness (see resetDeckParam's -// note on what the seconds ceiling has to be for it to hold). +// PlaySeconds and COPIED rather than round-tripped, which is what makes the two stage times whose +// defaults are neither 0 nor 1 land bit for bit at a non-power-of-two ceiling. static void testResetLandsOnTheStoredDefaultOfEachControl() { const PlaySeconds defaults; PlaySeconds p; @@ -152,6 +172,138 @@ static void testResetLandsOnTheStoredDefaultOfEachControl() { CHECK(p.adsr.releaseSeconds == defaults.adsr.releaseSeconds); } +// EVERY knob resets to its own stored default, not just the six dual-ring pairs above. Swept +// over the whole control-id space so a control added later cannot quietly miss the reset table: +// perturb, reset, and require the control to read exactly what a fresh PlaySeconds reads. +static void testEveryKnobIdResetsToItsDefault() { + const PlaySeconds defaults; + for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { + const DeckParam id = static_cast(i); + if (deckParamUnit(id) == UnitCategory::None) continue; // no reset gesture + if (id == DeckParam::kMasterGain || id == DeckParam::kKeyTrack) continue; // not in PlaySeconds + PlaySeconds p; + setDeckParam(id, p, 0.37, 0); + setDeckParam(id, p, 0.83, 0); // two writes: one of the two is off every default + CHECK(deckParamNorm(id, p) != deckParamNorm(id, defaults)); + resetDeckParam(id, p); + CHECK(deckParamNorm(id, p) == deckParamNorm(id, defaults)); + } +} + +// THE exact-preimage criterion, per unit category, against a default-constructed PlaySeconds and +// against master gain's unity. A host's reset-to-default arrives as toPlain(defaultNorm) with no +// bypass available, so this is the assertion the reset bypass CANNOT stand in for. +static void testEveryDefaultHasAnExactNormalizedPreimage() { + const PlaySeconds d; + const struct { DeckParam id; double stored; } msKnobs[] = { + {DeckParam::kAttack, d.adsr.attackSeconds}, + {DeckParam::kHold, d.adsr.holdSeconds}, + {DeckParam::kDecay, d.adsr.decaySeconds}, + {DeckParam::kRelease, d.adsr.releaseSeconds}, + {DeckParam::kTrigAttack, d.trigAhd.attackSeconds}, + {DeckParam::kTrigDecay, d.trigAhd.decaySeconds}, + {DeckParam::kPitchEnvAttack, d.pitchEnv.shape.attackSeconds}, + {DeckParam::kPitchEnvDecay, d.pitchEnv.shape.decaySeconds}, + {DeckParam::kFilterEnvAttack, d.filter.env.attackSeconds}, + {DeckParam::kFilterEnvHold, d.filter.env.holdSeconds}, + {DeckParam::kFilterEnvDecay, d.filter.env.decaySeconds}, + {DeckParam::kFilterEnvRelease, d.filter.env.releaseSeconds}, + {DeckParam::kFilterTrigAttack, d.filter.trigEnv.attackSeconds}, + {DeckParam::kFilterTrigDecay, d.filter.trigEnv.decaySeconds}, + }; + for (const auto& k : msKnobs) { + CHECK(timeSecondsFromNorm(deckParamNorm(k.id, d)) == k.stored); + } + // The two whose defaults are neither 0 nor the ceiling are the ones that can actually fail. + CHECK(d.adsr.attackSeconds == 0.003 && d.adsr.releaseSeconds == 0.060); + + CHECK(depthSemitonesFromNorm(deckParamNorm(DeckParam::kPitchEnvDepth, d), + kPitchDepthMaxSemis) == d.pitchEnv.peakSemitones); + CHECK(deckParamNorm(DeckParam::kSustain, d) == d.adsr.sustainLevel); + CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction); + CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction); + CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount); + CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) == + d.adsr.attackCurve); + // Master gain's unity: the case where a hair off is an audible gain error rather than a + // cosmetic one. Its taper is engine/master_gain's — consumed here, not defined here. + CHECK(instrument::engine::masterGainLinearFromNorm(instrument::engine::masterGainNormFromLinear(1.0)) == 1.0); +} + +// Shift's snap unit is a property of the control's UNIT and lands on a whole unit of what the +// control DISPLAYS — which is why three controls sharing the Percent category take three +// different norm steps. +static void testShiftSnapsToAWholeUnitOfTheDisplayedValue() { + // Milliseconds: the snapped norm reads back as an exact whole millisecond. + const double ms = timeSecondsFromNorm(snapDeckParamNorm(DeckParam::kAttack, + timeNormFromSeconds(0.03472))); + CHECK(ms == 0.035); + // Semitones. + CHECK(depthSemitonesFromNorm( + snapDeckParamNorm(DeckParam::kPitchEnvDepth, + depthNormFromSemitones(6.6, kPitchDepthMaxSemis)), + kPitchDepthMaxSemis) == 7.0); + // Percent, 0..100 %: the norm IS the fraction. + CHECK(snapDeckParamNorm(DeckParam::kSustain, 0.4162) == 0.42); + // Percent, 0..200 %: a whole DISPLAYED percent is half a norm percent. + CHECK(snapDeckParamNorm(DeckParam::kFilterKeyTrack, 0.4162) == 0.4150); + // Percent, +/-100 %: likewise, measured on the bipolar value. + CHECK(snapDeckParamNorm(DeckParam::kFilterVel, deckNormFromBipolar(-0.4162)) == + deckNormFromBipolar(-0.42)); + // Exponent: whole numbers, which puts the linear neutral one snap from centre. Compared as + // the norm the snap RETURNS — the exponent's own log travel is not an exact round trip. + CHECK(snapDeckParamNorm(DeckParam::kAttackCurve, util::knobNormFromCurve(2.6)) == + util::knobNormFromCurve(3.0)); + CHECK(snapDeckParamNorm(DeckParam::kAttackCurve, util::knobNormFromCurve(1.4)) == + util::knobNormFromCurve(util::kCurveNeutral)); + // Decibels, likewise compared as the returned norm. + CHECK(snapDeckParamNorm(DeckParam::kMasterGain, + instrument::engine::masterGainNormFromDb(-6.4)) == + instrument::engine::masterGainNormFromDb(-6.0)); + // Already-integer and discrete controls are untouched. + CHECK(snapDeckParamNorm(DeckParam::kVoiceCount, 0.4162) == 0.4162); + CHECK(snapDeckParamNorm(DeckParam::kPlayMode, 0.4162) == 0.4162); + CHECK(deckParamUnit(DeckParam::kVoiceCount) == UnitCategory::None); + CHECK(deckParamUnit(DeckParam::kAmpVelCurve) == UnitCategory::None); +} + +// The taper and the raised ceiling are persistence-neutral BY CONSTRUCTION: the binding only +// READS the stored seconds, so a value dialled under the old 2 s ceiling reloads bit-identical +// and simply sits somewhere else on the knob. Nothing on the load path rewrites it. +static void testAValueStoredUnderTheOldCeilingIsReadNotRewritten() { + PlaySeconds p; + p.adsr.decaySeconds = 1.75; // reachable by hand at the retired 2 s ceiling + p.adsr.releaseSeconds = 2.0; + const double normDecay = deckParamNorm(DeckParam::kDecay, p); + CHECK(p.adsr.decaySeconds == 1.75); // reading the norm mutated nothing + CHECK(p.adsr.releaseSeconds == 2.0); + CHECK(normDecay > 0.0 && normDecay < 1.0); // still on the knob, just at a new angle + CHECK(deckParamNorm(DeckParam::kRelease, p) > normDecay); + // And it survives the norm the knob would hand back, so a no-op touch of the control does + // not quantize a legacy value away. + setDeckParam(DeckParam::kDecay, p, normDecay, 0); + CHECK(p.adsr.decaySeconds == 1.75); +} + +// The filter's four tone controls are wire-frozen in the payload: their stored value IS their +// normalized position, and nothing in the taper pass may re-map it. Their snap is display-side +// only, which is what this separates. +static void testTheFilterFourKeepTheirIdentityTaper() { + PlaySeconds p; + const double positions[] = {0.0, 0.125, 0.5, 0.73, 1.0}; + for (double n : positions) { + setDeckParam(DeckParam::kFilterCutoff, p, n, 0); + setDeckParam(DeckParam::kFilterQ, p, n, 0); + setDeckParam(DeckParam::kFilterMorph, p, n, 0); + setDeckParam(DeckParam::kFilterDrive, p, n, 0); + CHECK(p.filter.settings.cutoffNorm == static_cast(n)); + CHECK(p.filter.settings.resonanceNorm == static_cast(n)); + CHECK(p.filter.settings.morphNorm == static_cast(n)); + CHECK(p.filter.settings.driveNorm == static_cast(n)); + CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == static_cast(static_cast(n))); + } +} + // One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a // decimal rather than reading as a bare zero, and a multi-second one stays in ms rather than // switching units mid-deck. @@ -162,7 +314,7 @@ static void testTimeConstantsAlwaysReadInMilliseconds() { CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading CHECK(msLabel(0.25) == "250 ms"); CHECK(msLabel(1.5) == "1500 ms"); // multi-second, still ms - CHECK(msLabel(kEnvTimeMaxSeconds) == "2000 ms"); + CHECK(msLabel(kEnvTimeMaxSeconds) == "10000 ms"); // The 10 ms hinge belongs to the integer form, not the decimal one. CHECK(msLabel(0.01) == "10 ms"); CHECK(msLabel(0.0099) == "9.9 ms"); @@ -175,10 +327,16 @@ static void testTimeConstantsAlwaysReadInMilliseconds() { } int main() { + testTheTwoCeilingNamesAreOneNumber(); testNormRoundTripsThroughEveryValueDomain(); testResetTouchesOnlyItsOwnRingOnADualRingKnob(); testInnerResetLandsOnTheExactLinearNeutral(); testResetLandsOnTheStoredDefaultOfEachControl(); + testEveryKnobIdResetsToItsDefault(); + testEveryDefaultHasAnExactNormalizedPreimage(); + testShiftSnapsToAWholeUnitOfTheDisplayedValue(); + testAValueStoredUnderTheOldCeilingIsReadNotRewritten(); + testTheFilterFourKeepTheirIdentityTaper(); testTimeConstantsAlwaysReadInMilliseconds(); if (g_fail) { std::printf("%d FAILURE(S)\n", g_fail); diff --git a/tests/test_envelope_edit.cpp b/tests/test_envelope_edit.cpp index add5ff9..6effc07 100644 --- a/tests/test_envelope_edit.cpp +++ b/tests/test_envelope_edit.cpp @@ -5,11 +5,12 @@ // // Covers: nodeAtPoint (every drawn handle grabbable, the anchored ReleaseEnd and the Origin // never grabbed, other-kind nodes rejected, misses outside the radius, a dead coincident AHD -// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag -// (AHDSR stage times at the schematic scale, the sustain level on Y, the release dragged from -// its START with the inverted sign, the caller's clamp domain, AHD stage times at the 1:1 -// scale, the hold FRACTION); curve-knot drags (the exponent domain, its endpoints, and the -// round trip through the shared law that keeps knot and dial on one value); degenerate no-ops. +// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag (AHDSR stage nodes +// tracking the cursor across the TAPERED schematic and being its exact inverse, the sustain level +// on Y, the release dragged from its START with the inverted sign, the caller's clamp domain, AHD +// stage times at the 1:1 scale, the hold FRACTION); curve-knot drags (the exponent domain, its +// endpoints, and the round trip through the shared law that keeps knot and dial on one value); +// the interaction law (Ctrl's rate on every axis, Shift's per-category snap); degenerate no-ops. #include "../src/core/instrument/ui/envelope_edit.h" @@ -28,12 +29,14 @@ static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; } static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100 static constexpr double kTotal = 4.0; +// The shell's own domain (editor_controls' envClampBounds), so a drag here is clamped exactly +// where a knob is. static EnvClampBounds bounds() { EnvClampBounds b; - b.maxAttackSeconds = 2.0; - b.maxHoldSeconds = 2.0; - b.maxDecaySeconds = 2.0; - b.maxReleaseSeconds = 2.0; + b.maxAttackSeconds = kGateStageMaxSeconds; + b.maxHoldSeconds = kGateStageMaxSeconds; + b.maxDecaySeconds = kGateStageMaxSeconds; + b.maxReleaseSeconds = kGateStageMaxSeconds; return b; } @@ -160,23 +163,64 @@ static void testMissOutsideTheRadius() { // --- AHDSR drags --------------------------------------------------------------- -static void testAhdsrStageTimesTrackTheSchematicScale() { +// The x position of node `n` as the FORWARD map draws it — the only thing a tapered-axis drag can +// be measured against, since there is no longer a fixed seconds-per-pixel rate to restate. +static int drawnX(const StageEnvelope& e, EnvNode n) { + EnvVertex v; + return findNode(buildEnvelopePolyline(e, overlayOf(wideArea()), kTotal), n, v) ? v.x : -1; +} + +// The schematic axis IS the knob's taper, so what a stage node tracks is the CURSOR — at both +// ends of the range, which a fixed-rate inverse could not manage once the axis stopped being +// linear in seconds. Swept across four decades of stage time for exactly that reason. +static void testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange() { const Rect a = wideArea(); + const double startTimes[] = {0.0, 0.003, 0.25, 2.0}; + for (double t : startTimes) { + StageEnvelope e = ahdsrEnv(); + e.attackSeconds = t; + const StageEnvelope moved = + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 40, 0); + CHECK(std::abs((drawnX(moved, EnvNode::AttackEnd) - drawnX(e, EnvNode::AttackEnd)) - 40) + <= 1); + CHECK(moved.attackSeconds > t); + CHECK(moved.holdSeconds == e.holdSeconds); // only the dragged param moves + } + // Hold and decay ride the same axis, in both directions. const StageEnvelope e = ahdsrEnv(); - const double secPerPx = 1.0 / gatePxPerSecond(a); - - const StageEnvelope attack = - resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 50, 0); - CHECK(std::fabs(attack.attackSeconds - (e.attackSeconds + 50 * secPerPx)) < 1e-9); - CHECK(attack.holdSeconds == e.holdSeconds); // only the dragged param moves - const StageEnvelope hold = resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -20, 0); - CHECK(std::fabs(hold.holdSeconds - (e.holdSeconds - 20 * secPerPx)) < 1e-9); - + CHECK(std::abs((drawnX(hold, EnvNode::HoldEnd) - drawnX(e, EnvNode::HoldEnd)) + 20) <= 1); + CHECK(hold.holdSeconds < e.holdSeconds); const StageEnvelope decay = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 30, 0); - CHECK(std::fabs(decay.decaySeconds - (e.decaySeconds + 30 * secPerPx)) < 1e-9); + CHECK(std::abs((drawnX(decay, EnvNode::DecayEnd) - drawnX(e, EnvNode::DecayEnd)) - 30) <= 1); + CHECK(decay.decaySeconds > e.decaySeconds); +} + +// The one-model rule, at the tapered axis: a node dragged to a pixel and the knob's value at that +// pixel are ONE number, so the inverse has to be EXACT and not merely close. A zero-delta drag +// reproduces the grab value bit for bit, and a drag out and straight back lands where it started. +static void testDrawAndDragAreExactInverses() { + const Rect a = wideArea(); + // Four decades of stage time, stopping short of the clamp: a drag that saturates at the + // domain end deliberately does NOT come back (testStageTimesClampToTheKnobDomain owns that). + const double startTimes[] = {0.0, 0.003, 0.060, 1.0}; + for (double t : startTimes) { + StageEnvelope e = ahdsrEnv(); + e.attackSeconds = t; + CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 0, 0) + .attackSeconds == t); + const StageEnvelope out = + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 30, 0); + const StageEnvelope back = + resolveNodeDrag(out, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), -30, 0); + // The DRAWN node returns to the exact pixel it left, which is the property the one-model + // rule actually needs; the underlying seconds return to within the taper's own quantum + // read back through the map, which is proportional to the value. + CHECK(drawnX(back, EnvNode::AttackEnd) == drawnX(e, EnvNode::AttackEnd)); + CHECK(std::fabs(back.attackSeconds - t) < 1e-6 * (t + 0.01)); + } } // The release is dragged from its TOP node and its end is anchored to the canvas edge, so @@ -185,13 +229,15 @@ static void testAhdsrStageTimesTrackTheSchematicScale() { static void testReleaseDragsFromItsStartWithInvertedSign() { const Rect a = wideArea(); const StageEnvelope e = ahdsrEnv(); - const double secPerPx = 1.0 / gatePxPerSecond(a); const StageEnvelope longer = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), -40, 0); - CHECK(std::fabs(longer.releaseSeconds - (e.releaseSeconds + 40 * secPerPx)) < 1e-9); + CHECK(longer.releaseSeconds > e.releaseSeconds); const StageEnvelope shorter = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), 40, 0); CHECK(shorter.releaseSeconds < e.releaseSeconds); + // The node still tracks the cursor, inverted sign notwithstanding. + CHECK(std::abs((drawnX(longer, EnvNode::ReleaseStart) - + drawnX(e, EnvNode::ReleaseStart)) + 40) <= 1); } static void testSustainLevelOnTheDecayNodesYAxis() { @@ -327,6 +373,73 @@ static void testKnotOnANearLevelSegmentIsANoOp() { CHECK(out.decayCurve == 2.5); } +// --- the interaction law on the overlay ---------------------------------------- + +// Ctrl scales the PIXEL delta, so it composes with every axis — the tapered schematic, the 1:1 +// wall clock, the level and the exponent — instead of each getting its own rule. +static void testCtrlScalesEveryAxisOfANodeDrag() { + const Rect a = wideArea(); + const StageEnvelope e = ahdsrEnv(); + const DragModifiers fine{false, true}; + const int coarse = 10; + const int equivalent = static_cast(coarse / kFineDragScale); // 200 fine px == 10 coarse + CHECK(std::fabs( + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), equivalent, + 0, fine).attackSeconds - + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), coarse, 0) + .attackSeconds) < 1e-9); + CHECK(std::fabs( + resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, + equivalent, fine).sustainLevel - + resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, coarse) + .sustainLevel) < 1e-9); + // A zero delta is identical under either rate — the state the shell's re-anchor establishes + // at every modifier transition, and why the value cannot jump across one. + CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 0, 0, fine) + .attackSeconds == e.attackSeconds); +} + +// Shift reaches the overlay because node, knot and knob are surfaces onto ONE model: a snap +// available on the knob and not on the node would be exactly the divergence that rule forbids. +// Each axis is asserted against the snap of ITS OWN category applied to the free drag's result — +// a node that routed a level through the millisecond snap, or snapped before the axis map rather +// than after it, fails here. The snaps themselves are param_taper's own tests. +static void testShiftSnapsEachAxisToItsOwnWholeUnit() { + const Rect a = wideArea(); + const StageEnvelope e = ahdsrEnv(); + const DragModifiers shift{true, false}; + + const StageEnvelope freeMs = + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 37, 0); + const StageEnvelope snapMs = + resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 37, 0, shift); + CHECK(snapMs.attackSeconds == snapSecondsToWholeMs(freeMs.attackSeconds)); + CHECK(snapMs.attackSeconds != freeMs.attackSeconds); // the drag really did move to the grid + CHECK(std::fabs(snapMs.attackSeconds - freeMs.attackSeconds) <= 0.0005 + 1e-12); + + const StageEnvelope freeLevel = + resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -13); + const StageEnvelope snapLevel = + resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -13, shift); + CHECK(snapLevel.sustainLevel == snapFractionToWholePercent(freeLevel.sustainLevel)); + CHECK(std::fabs(snapLevel.sustainLevel - freeLevel.sustainLevel) <= 0.005 + 1e-12); + + const StageEnvelope freeKnot = + resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 9); + const StageEnvelope snapKnot = + resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 9, shift); + CHECK(snapKnot.attackCurve == snapExponentToWhole(freeKnot.attackCurve)); + CHECK(snapKnot.attackCurve != freeKnot.attackCurve); + + // An AHD's Hold node edits a FRACTION, so its whole unit is a percent, not a millisecond. + const StageEnvelope freeFrac = + resolveNodeDrag(ahdEnv(), EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 37, 0); + const StageEnvelope snapFrac = resolveNodeDrag(ahdEnv(), EnvNode::HoldEnd, overlayOf(a), + kTotal, bounds(), 37, 0, shift); + CHECK(snapFrac.holdFraction == snapFractionToWholePercent(freeFrac.holdFraction)); + CHECK(snapFrac.holdFraction != freeFrac.holdFraction); +} + // --- degenerate ---------------------------------------------------------------- static void testDegenerateInputsAreNoOps() { @@ -347,7 +460,8 @@ int main() { testFunctionalCoincidentDecayEndStaysGrabbable(); testMissOutsideTheRadius(); - testAhdsrStageTimesTrackTheSchematicScale(); + testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange(); + testDrawAndDragAreExactInverses(); testReleaseDragsFromItsStartWithInvertedSign(); testSustainLevelOnTheDecayNodesYAxis(); testStageTimesClampToTheKnobDomain(); @@ -355,6 +469,9 @@ int main() { testAhdStageTimesTrackTheWallClockScale(); testAhdHoldNodeEditsTheFraction(); + testCtrlScalesEveryAxisOfANodeDrag(); + testShiftSnapsEachAxisToItsOwnWholeUnit(); + testKnotDragMovesTheExponentWithinItsDomain(); testKnotAndModelCannotDiverge(); testKnotOnALevelSegmentIsANoOp(); diff --git a/tests/test_envelope_overlay.cpp b/tests/test_envelope_overlay.cpp index b16b323..6c527f2 100644 --- a/tests/test_envelope_overlay.cpp +++ b/tests/test_envelope_overlay.cpp @@ -4,15 +4,18 @@ // RIGHT-ANCHORED release, and the sustain-less AHD laid 1:1 over the waveform's time axis. // // Covers: timeToX / levelToY (linear maps, edge clamps, past-end clamped to right-1, no 32-bit -// overflow on huge times, degenerate area/duration); gatePxPerSecond; the AHDSR polyline (node -// order, levels, release anchored at the right edge, the sustain plateau reaching the edge at -// zero release, per-segment separation at the tier-0 defaults, overrun compression, every -// vertex in-bounds); splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the -// AHD polyline (1:1 with the time axis, origin offset); curve knots (present only on sloped -// non-zero segments, height following the exponent); the degenerate flat baseline. +// overflow on huge times, degenerate area/duration); gateStageSlotPx; the AHDSR polyline (node +// order, levels, the TAPERED stage placement and its legibility at both ends of the range, +// release anchored at the right edge, the sustain plateau reaching the edge at zero release, +// per-segment separation at the tier-0 defaults, overrun compression, every vertex in-bounds); +// splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the AHD polyline (1:1 with +// the time axis, origin offset); curve knots (present only on sloped non-zero segments, height +// following the exponent); the degenerate flat baseline. #include "../src/core/instrument/ui/envelope_overlay.h" +#include "../src/core/instrument/ui/sample_bands.h" // the editor floor the legibility test uses + #include #include #include @@ -94,20 +97,19 @@ static void testDegenerateAreaAndDuration() { CHECK(timeToX(Rect{}, 2.0, 1.0) == 0); CHECK(timeToX(wideArea(), 0.0, 1.0) == wideArea().x); CHECK(levelToY(Rect{}, 0.5) == 0); - CHECK(gatePxPerSecond(Rect{}) == 0.0); + CHECK(gateStageSlotPx(Rect{}) == 0.0); } -// The literal PARAM-DOMAIN scale, independent of any sample duration: usable px = canvas width -// minus the last column minus 4 node-separation bases, spread over 4 x kGateStageMaxSeconds. -// This is what makes a dragged handle track the cursor 1:1 (envelope_edit's own inverse reads -// this same function) — a scale regression here is exactly what a relational-only check misses. -static void testGatePxPerSecond() { - // 967 / 8 px/s, pinned as a literal — restating the formula with the same named constants - // would let a change to kGateNodeSepPx or kGateStageMaxSeconds move both sides and pass - // silently. - CHECK(gatePxPerSecond(wideArea()) == 120.875); - CHECK(gatePxPerSecond(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0 - CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0 +// The literal slot width, independent of any sample duration: usable px = canvas width minus the +// last column minus 4 node-separation bases, split four ways. A stage then occupies its own +// TAPERED fraction of that slot, which is what makes a dragged handle track the cursor at both +// ends of the range — a scale regression here is exactly what a relational-only check misses. +static void testGateStageSlotPx() { + // 967 / 4 px, pinned as a literal — restating the formula with the same named constants + // would let a change to kGateNodeSepPx move both sides and pass silently. + CHECK(gateStageSlotPx(wideArea()) == 241.75); + CHECK(gateStageSlotPx(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0 + CHECK(gateStageSlotPx(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0 } // --- the AHDSR schematic ------------------------------------------------------ @@ -135,24 +137,52 @@ static void testAhdsrNodeOrderAndLevels() { CHECK(v.x == a.right() - 1); // ANCHORED, whatever the release is } -// The literal per-node x placement, hand-derived from the documented formula (pps = 120.875 -// px/s per testGatePxPerSecond; each timed stage is prefixed by the kGateNodeSepPx=8 base): -// attack .2s -> raw 8+24.175=32.175 -> px 32; hold .1s -> raw 32.175+8+12.0875=52.2625 -> px 52; -// decay .3s -> raw 52.2625+8+36.2625=96.525 -> px 97; plateau -> raw 999-8-48.35=942.65 -> px -// 943; release end pinned at the last column, 999. A literal regression pin — no relational or -// bounds-only check catches a formula-shape change the way an exact pixel count does. +// The literal per-node x placement, hand-derived from the documented formula (slot = 241.75 px +// per testGateStageSlotPx; each timed stage is prefixed by the kGateNodeSepPx=8 base and occupies +// slot x timeNormFromSeconds(t) of its own slot; L = ln(1 + 10/0.003) = 8.112028): +// attack .25s -> norm ln(84.3333)/L = 0.546677 -> 8 + 132.159 = 140.159 -> px 140 +// hold .05s -> norm ln(17.6667)/L = 0.354007 -> 140.159 + 8 + 85.581 = 233.740 -> px 234 +// decay .5s -> norm ln(167.667)/L = 0.631385 -> 233.740 + 8 + 152.637 = 394.377 -> px 394 +// plateau 1s -> norm ln(334.333)/L = 0.716493 -> 999 - 8 - 173.211 = 817.789 -> px 818 +// release end pinned at the last column, 999. +// A literal regression pin — no relational or bounds-only check catches a formula-shape change +// the way an exact pixel count does. static void testAhdsrSchematicPlacement() { const Rect a = wideArea(); const std::vector poly = - buildEnvelopePolyline(ahdsr(0.2, 0.1, 0.3, 0.5, 0.4), overlayOf(a), 4.0); + buildEnvelopePolyline(ahdsr(0.25, 0.05, 0.5, 0.5, 1.0), overlayOf(a), 4.0); EnvVertex v; - CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 32); - CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 52); - CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 97); - CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 943); + CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 140); + CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 234); + CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 394); + CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 818); CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 999); } +// The legibility the tapered axis exists for, at BOTH ends of the raised range. Linear-in-seconds +// put the 3 ms default attack 0.07 px from the origin at a 10 s ceiling — indistinguishable from +// zero and impossible to grab. Asserted at the editor's own floor width, not a comfortable one. +static void testTaperedAxisKeepsBothEndsOfTheRangeLegible() { + const Rect floorArea = Rect::ltrb(0, 0, kEditorMinWidth - 2 * kPad, 100); + const std::vector poly = + buildEnvelopePolyline(ahdsr(0.003, 0.0, 0.0, 1.0, 0.060), overlayOf(floorArea), 4.0); + EnvVertex origin, attack; + CHECK(findNode(poly, EnvNode::Origin, origin)); + CHECK(findNode(poly, EnvNode::AttackEnd, attack)); + // Well clear of the grab radius, so the default attack is a real handle rather than a node + // sitting on the origin. + CHECK(attack.x - origin.x >= 20); + + // And a maxed stage still lands its end node at its slot's edge: the taper's norm-1 end and + // the schematic's canvas edge are the same place, which is the anchor the policy rests on. + const std::vector maxed = buildEnvelopePolyline( + ahdsr(kGateStageMaxSeconds, 0.0, 0.0, 1.0, 0.0), overlayOf(floorArea), 4.0); + EnvVertex maxAttack; + CHECK(findNode(maxed, EnvNode::AttackEnd, maxAttack)); + const double slot = gateStageSlotPx(floorArea); + CHECK(maxAttack.x == floorArea.x + static_cast(kGateNodeSepPx + slot + 0.5)); +} + // The AHDSR schematic is scaled by the PARAM domain, NOT the capture length: the same params // produce the SAME polyline whether totalSeconds is 0.3 or 10 (gatePolyline doesn't even take // totalSeconds — only the sustain-less AHD's x-axis is wall-clock/PCM-aligned). @@ -409,10 +439,11 @@ int main() { testTimeToXClampsBothEnds(); testLevelToY(); testDegenerateAreaAndDuration(); - testGatePxPerSecond(); + testGateStageSlotPx(); testAhdsrNodeOrderAndLevels(); testAhdsrSchematicPlacement(); + testTaperedAxisKeepsBothEndsOfTheRangeLegible(); testGateLayoutIndependentOfSampleDuration(); testZeroReleasePutsTheSustainPlateauAtTheRightEdge(); testReleaseGrowsLeftwardFromTheAnchor(); diff --git a/tests/test_param_slider.cpp b/tests/test_param_slider.cpp index 48dfaed..0a39f19 100644 --- a/tests/test_param_slider.cpp +++ b/tests/test_param_slider.cpp @@ -245,22 +245,35 @@ static void testKnobNeedlePointOnCircle() { static void testKnobDragUpIncreases() { // Up (negative dy) increases, down decreases, scaled by the drag range. - CHECK(approx(knobDragValue(0.5, -32, 128), 0.75)); - CHECK(approx(knobDragValue(0.5, +32, 128), 0.25)); + CHECK(approx(knobDragValue(0.5, -32, {}, 128), 0.75)); + CHECK(approx(knobDragValue(0.5, +32, {}, 128), 0.25)); // A full-range upward drag from 0 lands exactly at 1. - CHECK(approx(knobDragValue(0.0, -128, 128), 1.0)); + CHECK(approx(knobDragValue(0.0, -128, {}, 128), 1.0)); // Default sensitivity applies when the range is omitted. CHECK(approx(knobDragValue(0.0, -kKnobDragRangePixels), 1.0)); } static void testKnobDragClamps() { - CHECK(approx(knobDragValue(0.9, -64, 128), 1.0)); // over-drag up clamps at 1 - CHECK(approx(knobDragValue(0.1, +64, 128), 0.0)); // over-drag down clamps at 0 + CHECK(approx(knobDragValue(0.9, -64, {}, 128), 1.0)); // over-drag up clamps at 1 + CHECK(approx(knobDragValue(0.1, +64, {}, 128), 0.0)); // over-drag down clamps at 0 // The start value itself is clamped before the delta applies. - CHECK(approx(knobDragValue(1.5, 0, 128), 1.0)); - CHECK(approx(knobDragValue(-0.5, 0, 128), 0.0)); + CHECK(approx(knobDragValue(1.5, 0, {}, 128), 1.0)); + CHECK(approx(knobDragValue(-0.5, 0, {}, 128), 0.0)); // A degenerate drag range yields the clamped start value. - CHECK(approx(knobDragValue(0.7, -50, 0), 0.7)); + CHECK(approx(knobDragValue(0.7, -50, {}, 0), 0.7)); +} + +// Ctrl scales the drag rate; Shift+Ctrl is Shift, so the rate goes back to coarse. The snap +// itself is not this module's — only the rate is. +static void testCtrlScalesTheDragRateAndShiftOverridesIt() { + const DragModifiers fine{false, true}; + const DragModifiers both{true, true}; + CHECK(approx(knobDragValue(0.5, -32, fine, 128), 0.5 + 0.25 * kFineDragScale)); + CHECK(approx(knobDragValue(0.5, -32, both, 128), 0.75)); + CHECK(approx(knobDragValue(0.5, -32, DragModifiers{true, false}, 128), 0.75)); + // Continuity across a transition is the CALLER's re-anchor, not this function's: at a + // zero delta both rates agree, which is exactly the state a re-anchor establishes. + CHECK(knobDragValue(0.42, 0, fine, 128) == knobDragValue(0.42, 0, {}, 128)); } // --- controlAtPoint routing --------------------------------------------------- @@ -321,6 +334,7 @@ int main() { testKnobNeedlePointOnCircle(); testKnobDragUpIncreases(); testKnobDragClamps(); + testCtrlScalesTheDragRateAndShiftOverridesIt(); testControlAtPointRoutes(); testControlAtPointMisses(); diff --git a/tests/test_param_taper.cpp b/tests/test_param_taper.cpp new file mode 100644 index 0000000..49dab04 --- /dev/null +++ b/tests/test_param_taper.cpp @@ -0,0 +1,260 @@ +// Standalone tests for reasampler::instrument::ui::param_taper — no VST3, no REAPER, no +// framework. The taper is the one map the knob's needle, the AHDSR schematic axis and (later) the +// host's normalization all read, so what is asserted here is what all three obey. +// +// Covers: the modifier truth table (Shift beats Ctrl); the stage-time taper (exact endpoints, +// monotone, the two landmark bands, and the EXACT-PREIMAGE guarantee swept over the whole +// quantum grid rather than sampled at the defaults); the depth taper (exact centre and ends, +// exact symmetry, the +/-7 st landmark, whole-semitone preimages); and the four whole-unit snaps. + +#include "../src/core/instrument/ui/param_taper.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::ui; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static constexpr double kDepth = 24.0; // the pitch-depth throw the deck passes in today + +// --- modifiers ----------------------------------------------------------------------------- + +// Shift+Ctrl is SHIFT: with the output quantized to whole units a finer drag produces the same +// sequence, so Ctrl is ignored there. Asserted rather than left to a comment because the +// "obvious fix" is to compound the two. +static void testShiftBeatsCtrlForTheFineDragRate() { + CHECK(!fineDrag(DragModifiers{false, false})); + CHECK(fineDrag(DragModifiers{false, true})); + CHECK(!fineDrag(DragModifiers{true, false})); + CHECK(!fineDrag(DragModifiers{true, true})); + CHECK((DragModifiers{true, false} != DragModifiers{false, false})); + CHECK((DragModifiers{true, true} == DragModifiers{true, true})); +} + +// --- the stage-time taper ------------------------------------------------------------------ + +// Zero is a REQUIRED value a pure log cannot express, and the ceiling has to be reachable by +// hand — both endpoints are exact, not merely close. +static void testStageTimeEndpointsAreExact() { + CHECK(timeSecondsFromNorm(0.0) == 0.0); + CHECK(timeSecondsFromNorm(1.0) == kStageTimeMaxSeconds); + CHECK(timeNormFromSeconds(0.0) == 0.0); + CHECK(timeNormFromSeconds(kStageTimeMaxSeconds) == 1.0); + // Out of domain clamps rather than extrapolating. + CHECK(timeSecondsFromNorm(-1.0) == 0.0); + CHECK(timeSecondsFromNorm(2.0) == kStageTimeMaxSeconds); + CHECK(timeNormFromSeconds(-1.0) == 0.0); + CHECK(timeNormFromSeconds(1e9) == 1.0); +} + +// The ceiling this phase raised it to. Pinned as a literal: this endpoint becomes a frozen host +// normalization, so a silent change to it is exactly what a test has to refuse. +static void testStageTimeCeilingIsTenSeconds() { + CHECK(kStageTimeMaxSeconds == 10.0); +} + +// The two landmarks the taper is fitted to, at the NEW ceiling. They are what make the low end +// dialable at a 10 s range, and they are also the overlay's legibility guarantee. +static void testStageTimeLandmarksLandInTheirBands() { + const double at10ms = timeNormFromSeconds(0.010); + const double at100ms = timeNormFromSeconds(0.100); + CHECK(at10ms >= 0.12 && at10ms <= 0.20); + CHECK(at100ms >= 0.42 && at100ms <= 0.52); + // And the two are ordered with real separation, not merely inside their bands. + CHECK(at100ms > at10ms + 0.2); +} + +static void testStageTimeIsMonotone() { + double prev = -1.0; + for (int i = 0; i <= 200000; ++i) { + const double v = timeSecondsFromNorm(static_cast(i) / 200000.0); + CHECK(v >= prev); + if (v < prev) return; // one report is enough + prev = v; + } +} + +// The FINEST drag a user can make — Ctrl's 1/20 rate over the 128 px knob travel — must still +// move the value, or the output quantum would be observable as a dead zone. +static void testEveryFinestDragStepMovesTheValue() { + const int steps = static_cast(1.0 / kFineDragScale) * 128; + for (int i = 0; i < steps; ++i) { + const double lo = timeSecondsFromNorm(static_cast(i) / steps); + const double hi = timeSecondsFromNorm(static_cast(i + 1) / steps); + CHECK(hi > lo); + if (!(hi > lo)) return; + } +} + +// THE sharpest requirement in the track. A host's reset-to-default arrives as +// toPlain(defaultNorm) with no bypass available, so the preimage has to be EXACT. Swept over the +// whole quantum grid at the resolution the defaults live at, not sampled at the two the parameter +// set happens to carry today — that is what makes the guarantee structural. +static void testEveryWholeMicrosecondRoundTripsExactly() { + for (int us = 0; us <= 200000; us += 7) { // 0 .. 200 ms, a prime stride to avoid alignment + const double seconds = static_cast(us) / 1e6; + CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds); + if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) return; + } + // And across the rest of the range, where the map is coarsest. + for (int ms = 200; ms <= 10000; ms += 13) { + const double seconds = static_cast(ms) / 1e3; + CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds); + if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) return; + } +} + +// The converse round trip is NOT required, but its residual is worth pinning: it is bounded by +// the output quantum read back through the map, which stays four orders below one drag pixel. +// Pinned so a future quantum change cannot make the needle visibly lag the hand unnoticed. +static void testNormRoundTripResidualStaysBelowOneDragPixel() { + for (int i = 0; i <= 100000; ++i) { + const double n = static_cast(i) / 100000.0; + const double back = timeNormFromSeconds(timeSecondsFromNorm(n)); + CHECK(std::fabs(back - n) < 1e-7); + if (!(std::fabs(back - n) < 1e-7)) return; + } +} + +// The two stage-time defaults the parameter set actually carries, named so a reader can see the +// values the sweep above covers generically. +static void testTheStageTimeDefaultsRoundTripExactly() { + CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.003)) == 0.003); + CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.060)) == 0.060); + CHECK(timeSecondsFromNorm(timeNormFromSeconds(0.0)) == 0.0); +} + +// --- the depth taper ----------------------------------------------------------------------- + +static void testDepthCentreAndEndsAreExact() { + CHECK(depthNormFromSemitones(0.0, kDepth) == 0.5); + CHECK(depthSemitonesFromNorm(0.5, kDepth) == 0.0); + CHECK(depthNormFromSemitones(kDepth, kDepth) == 1.0); + CHECK(depthNormFromSemitones(-kDepth, kDepth) == 0.0); + CHECK(depthSemitonesFromNorm(1.0, kDepth) == kDepth); + CHECK(depthSemitonesFromNorm(0.0, kDepth) == -kDepth); + // Beyond the throw clamps rather than extrapolating. + CHECK(depthNormFromSemitones(100.0, kDepth) == 1.0); + CHECK(depthSemitonesFromNorm(5.0, kDepth) == kDepth); +} + +// Symmetric BITWISE, not approximately: a bipolar knob whose two halves disagreed by an ulp +// would read a different depth up than down at the same distance from centre. +static void testDepthIsExactlySymmetric() { + for (int i = 0; i <= 1000; ++i) { + const double n = static_cast(i) / 1000.0; + CHECK(depthSemitonesFromNorm(n, kDepth) == -depthSemitonesFromNorm(1.0 - n, kDepth)); + if (depthSemitonesFromNorm(n, kDepth) != -depthSemitonesFromNorm(1.0 - n, kDepth)) return; + } +} + +// Centre expansion: the musically useful +/-7 st gets more than half of each half-travel. +static void testDepthLandmarkLandsInItsBand() { + const double halfTravel = (depthNormFromSemitones(7.0, kDepth) - 0.5) * 2.0; + CHECK(halfTravel >= 0.50 && halfTravel <= 0.58); + // The negative half is the same distance out. Compared with a tolerance, not bitwise: 0.5+h + // and 0.5-h round differently, and the mirror that has to be EXACT is the one in the plain + // direction (testDepthIsExactlySymmetric) — a sub-ulp difference in a needle angle is not. + CHECK(std::fabs((0.5 - depthNormFromSemitones(-7.0, kDepth)) * 2.0 - halfTravel) < 1e-15); +} + +static void testDepthIsMonotone() { + double prev = -1e9; + for (int i = 0; i <= 200000; ++i) { + const double v = depthSemitonesFromNorm(static_cast(i) / 200000.0, kDepth); + CHECK(v >= prev); + if (v < prev) return; + prev = v; + } +} + +// Same exact-preimage guarantee as the time taper: every value on the depth quantum grid comes +// back bitwise. Whole semitones are the case a Shift-snap produces, so they are swept explicitly. +static void testEveryWholeSemitoneRoundTripsExactly() { + for (int st = -24; st <= 24; ++st) { + const double d = static_cast(st); + CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d); + } + for (int milli = -24000; milli <= 24000; milli += 37) { + const double d = static_cast(milli) / 1000.0; + CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d); + if (depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) != d) return; + } +} + +// A degenerate throw is a caller bug, not a crash: the map collapses to the centre. +static void testDegenerateThrowCollapsesToCentre() { + CHECK(depthNormFromSemitones(3.0, 0.0) == 0.5); + CHECK(depthSemitonesFromNorm(0.9, 0.0) == 0.0); +} + +// --- the whole-unit snaps ------------------------------------------------------------------- + +static void testMillisecondSnap() { + CHECK(snapSecondsToWholeMs(0.0124) == 0.012); + CHECK(snapSecondsToWholeMs(0.0126) == 0.013); + CHECK(snapSecondsToWholeMs(0.0004) == 0.0); + CHECK(snapSecondsToWholeMs(-1.0) == 0.0); + CHECK(snapSecondsToWholeMs(9.9996) == 10.0); + // The snapped value is itself on the taper's grid, so a snap followed by a round trip holds. + CHECK(timeSecondsFromNorm(timeNormFromSeconds(snapSecondsToWholeMs(0.0347))) == 0.035); +} + +static void testPercentSnap() { + CHECK(snapFractionToWholePercent(0.514) == 0.51); + CHECK(snapFractionToWholePercent(0.516) == 0.52); + CHECK(snapFractionToWholePercent(-0.514) == -0.51); + CHECK(snapFractionToWholePercent(1.0) == 1.0); + CHECK(snapFractionToWholePercent(0.0) == 0.0); +} + +static void testSemitoneSnap() { + CHECK(snapSemitonesToWhole(6.6) == 7.0); + CHECK(snapSemitonesToWhole(-6.6) == -7.0); + CHECK(snapSemitonesToWhole(0.4) == 0.0); + CHECK(depthSemitonesFromNorm(depthNormFromSemitones(snapSemitonesToWhole(6.6), kDepth), + kDepth) == 7.0); +} + +// The exponent snap reaches 1.0, the linear neutral — one snap from the dial's centre — and +// clamps into curve_law's own domain rather than rounding to a zero that is not an exponent. +static void testExponentSnap() { + CHECK(snapExponentToWhole(1.4) == 1.0); + CHECK(snapExponentToWhole(2.6) == 3.0); + CHECK(snapExponentToWhole(0.3) == util::kCurveMin); + CHECK(snapExponentToWhole(0.6) == 1.0); + CHECK(snapExponentToWhole(1e9) == util::kCurveMax); +} + +int main() { + testShiftBeatsCtrlForTheFineDragRate(); + + testStageTimeEndpointsAreExact(); + testStageTimeCeilingIsTenSeconds(); + testStageTimeLandmarksLandInTheirBands(); + testStageTimeIsMonotone(); + testEveryFinestDragStepMovesTheValue(); + testEveryWholeMicrosecondRoundTripsExactly(); + testNormRoundTripResidualStaysBelowOneDragPixel(); + testTheStageTimeDefaultsRoundTripExactly(); + + testDepthCentreAndEndsAreExact(); + testDepthIsExactlySymmetric(); + testDepthLandmarkLandsInItsBand(); + testDepthIsMonotone(); + testEveryWholeSemitoneRoundTripsExactly(); + testDegenerateThrowCollapsesToCentre(); + + testMillisecondSnap(); + testPercentSnap(); + testSemitoneSnap(); + testExponentSnap(); + + if (g_fail == 0) std::printf("param_taper: all tests passed\n"); + else std::printf("param_taper: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}