Merge Γ-W1-T1: one taper, one modifier law, the 10 s stage ceiling
This commit is contained in:
@@ -312,15 +312,20 @@ anything for a trigger shape.
|
|||||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
- **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.
|
- `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.
|
- `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.
|
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
||||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. Carries a SECOND hit-test, `hitTestKnobFace`, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (`knob_deck.h`; the ceiling itself now lives in `sample_bands.h` as a window fact); every group's categorical row is `deck_groups`' `deckRowFor`. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, where a `rowToggle` widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
|
||||||
- `deck_values` — the deck's control-id ↔ parameter-set BINDING and its display units, split
|
- `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`
|
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` /
|
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),
|
`setDeckParam` (the normalized ↔ stored-seconds/fraction/position binding and its clamps, over
|
||||||
`resetDeckParam` (the double-click reset — the defaults are READ off a default-constructed
|
`param_taper`'s maps), `resetDeckParam` (the double-click reset — the defaults are READ off a
|
||||||
`PlaySeconds`, so there is no second table of defaults to drift), and `formatEnvTimeMs`, the
|
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
|
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
|
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
|
stored representation changes. Links the header-only `play_seconds`, deliberately not
|
||||||
@@ -329,14 +334,14 @@ anything for a trigger shape.
|
|||||||
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
parameter set does not carry (key-track, voice count, master gain, preview velocity) and the
|
||||||
labels for them.
|
labels for them.
|
||||||
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
|
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
|
||||||
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
|
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis. Spline points are excluded from `param_taper`'s Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
|
||||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||||
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
|
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
|
||||||
|
|
||||||
## Gotchas
|
## 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.
|
- **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.
|
- **`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.
|
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||||
|
|||||||
@@ -34,11 +34,17 @@ reasampler_pure_library(browser_scroll
|
|||||||
LINK PUBLIC capture_browser sample_chrome)
|
LINK PUBLIC capture_browser sample_chrome)
|
||||||
reasampler_test(browser_scroll LINK browser_scroll)
|
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_test(param_slider LINK param_slider)
|
||||||
|
|
||||||
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
|
reasampler_pure_library(envelope_overlay
|
||||||
reasampler_test(envelope_overlay LINK 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_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
|
||||||
reasampler_test(envelope_edit LINK envelope_edit)
|
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.
|
# the filter's MorphLaw — an enum, so no filter symbol is linked.
|
||||||
reasampler_pure_library(deck_values
|
reasampler_pure_library(deck_values
|
||||||
SOURCES deck_values.cpp
|
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)
|
reasampler_test(deck_values LINK deck_values)
|
||||||
|
|
||||||
# The bake Hold knob's value domain. Links the ladder alone — it computes no geometry, so it
|
# The bake Hold knob's value domain. Links the ladder alone — it computes no geometry, so it
|
||||||
@@ -88,3 +94,13 @@ reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_g
|
|||||||
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
|
# 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.
|
# proving that takes a curve of each domain mapped through the one curveBox.
|
||||||
reasampler_test(curve_popup LINK curve_popup velocity_curve)
|
reasampler_test(curve_popup LINK curve_popup velocity_curve)
|
||||||
|
|
||||||
|
# The ONE norm<->value taper and modifier vocabulary every variable control shares. Declared
|
||||||
|
# last, but it sits at the BOTTOM of this directory's dependency order: param_slider,
|
||||||
|
# envelope_overlay and deck_values all read it — which is exactly why it could not stay inside
|
||||||
|
# deck_values, which sits above envelope_overlay.
|
||||||
|
reasampler_pure_library(param_taper SOURCES param_taper.cpp LINK PUBLIC curve_law)
|
||||||
|
# envelope_overlay and sample_bands are linked for the test only: the finest-drag-step assertion
|
||||||
|
# is judged against the envelope node drag at the editor's own floor width (the sharper of the
|
||||||
|
# taper's two consumers), read from the allocator/overlay rather than copied as a number.
|
||||||
|
reasampler_test(param_taper LINK param_taper envelope_overlay sample_bands)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#include "core/instrument/ui/deck_values.h"
|
#include "core/instrument/ui/deck_values.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
#include "core/instrument/engine/filter/filter_morph.h" // MorphLaw (the law toggle's value)
|
#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/clamp01.h"
|
||||||
#include "core/util/curve_law.h" // the ONE curve-exponent domain
|
#include "core/util/curve_law.h" // the ONE curve-exponent domain
|
||||||
|
|
||||||
@@ -14,13 +16,6 @@ namespace reasampler::instrument::ui {
|
|||||||
using engine::filter::MorphLaw;
|
using engine::filter::MorphLaw;
|
||||||
using util::clamp01;
|
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) {
|
double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case DeckParam::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
case DeckParam::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
|
||||||
@@ -28,31 +23,32 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
|||||||
case DeckParam::kPitchEnvMode: return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
|
case DeckParam::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::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::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
|
||||||
case DeckParam::kAttack: return secToNorm(play.adsr.attackSeconds);
|
case DeckParam::kAttack: return timeNormFromSeconds(play.adsr.attackSeconds);
|
||||||
case DeckParam::kHold: return secToNorm(play.adsr.holdSeconds);
|
case DeckParam::kHold: return timeNormFromSeconds(play.adsr.holdSeconds);
|
||||||
case DeckParam::kDecay: return secToNorm(play.adsr.decaySeconds);
|
case DeckParam::kDecay: return timeNormFromSeconds(play.adsr.decaySeconds);
|
||||||
case DeckParam::kSustain: return clamp01(play.adsr.sustainLevel);
|
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::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve);
|
||||||
case DeckParam::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
|
case DeckParam::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
|
||||||
case DeckParam::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
|
case DeckParam::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
|
||||||
case DeckParam::kTrigLength: return clamp01(play.trigger.lengthFraction);
|
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::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::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve);
|
||||||
case DeckParam::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
|
case DeckParam::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
|
||||||
case DeckParam::kPitchEnvEnable: return play.pitchEnv.enabled ? 1.0 : 0.0;
|
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::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:
|
case DeckParam::kPitchEnvAttackCurve:
|
||||||
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
|
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
|
||||||
case DeckParam::kPitchEnvDecayCurve:
|
case DeckParam::kPitchEnvDecayCurve:
|
||||||
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
|
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
|
||||||
case DeckParam::kPitchEnvDepth:
|
case DeckParam::kPitchEnvDepth:
|
||||||
// Signed depth centred at 0.5 (0.5 == 0 semitones).
|
return depthNormFromSemitones(play.pitchEnv.peakSemitones, kPitchDepthMaxSemis);
|
||||||
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
|
||||||
// Filter. The four tone controls ARE the module's normalized positions — stored and
|
// 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.
|
// shown as-is, so the knob travel is exactly filter_params' own law.
|
||||||
case DeckParam::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
|
case DeckParam::kFilterEnable: return play.filter.enabled ? 1.0 : 0.0;
|
||||||
@@ -65,20 +61,23 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play) {
|
|||||||
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
case DeckParam::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
||||||
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
case DeckParam::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
||||||
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
case DeckParam::kFilterKeyTrack: return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
||||||
case DeckParam::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
|
case DeckParam::kFilterEnvAttack: return timeNormFromSeconds(play.filter.env.attackSeconds);
|
||||||
case DeckParam::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
|
case DeckParam::kFilterEnvHold: return timeNormFromSeconds(play.filter.env.holdSeconds);
|
||||||
case DeckParam::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
|
case DeckParam::kFilterEnvDecay: return timeNormFromSeconds(play.filter.env.decaySeconds);
|
||||||
case DeckParam::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
|
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:
|
case DeckParam::kFilterEnvAttackCurve:
|
||||||
return util::knobNormFromCurve(play.filter.env.attackCurve);
|
return util::knobNormFromCurve(play.filter.env.attackCurve);
|
||||||
case DeckParam::kFilterEnvDecayCurve:
|
case DeckParam::kFilterEnvDecayCurve:
|
||||||
return util::knobNormFromCurve(play.filter.env.decayCurve);
|
return util::knobNormFromCurve(play.filter.env.decayCurve);
|
||||||
case DeckParam::kFilterEnvReleaseCurve:
|
case DeckParam::kFilterEnvReleaseCurve:
|
||||||
return util::knobNormFromCurve(play.filter.env.releaseCurve);
|
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::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:
|
case DeckParam::kFilterTrigAttackCurve:
|
||||||
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
|
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
|
||||||
case DeckParam::kFilterTrigDecayCurve:
|
case DeckParam::kFilterTrigDecayCurve:
|
||||||
@@ -110,11 +109,11 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
|||||||
case DeckParam::kPitchEngine:
|
case DeckParam::kPitchEngine:
|
||||||
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||||
break;
|
break;
|
||||||
case DeckParam::kAttack: play.adsr.attackSeconds = normToSec(value); break;
|
case DeckParam::kAttack: play.adsr.attackSeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kHold: play.adsr.holdSeconds = normToSec(value); break;
|
case DeckParam::kHold: play.adsr.holdSeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kDecay: play.adsr.decaySeconds = normToSec(value); break;
|
case DeckParam::kDecay: play.adsr.decaySeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kSustain: play.adsr.sustainLevel = clamp01(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::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
|
case DeckParam::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
|
case DeckParam::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||||
@@ -123,26 +122,26 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
|||||||
// nothing.
|
// nothing.
|
||||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||||
break;
|
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::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:
|
case DeckParam::kTrigAttackCurve:
|
||||||
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
|
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kTrigDecayCurve:
|
case DeckParam::kTrigDecayCurve:
|
||||||
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
|
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
|
case DeckParam::kPitchEnvEnable: play.pitchEnv.enabled = (segment == 1); break;
|
||||||
case DeckParam::kPitchEnvAttack:
|
case DeckParam::kPitchEnvAttack:
|
||||||
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
|
play.pitchEnv.shape.attackSeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kPitchEnvHold:
|
case DeckParam::kPitchEnvHold:
|
||||||
play.pitchEnv.shape.holdFraction = clamp01(value); break;
|
play.pitchEnv.shape.holdFraction = clamp01(value); break;
|
||||||
case DeckParam::kPitchEnvDecay:
|
case DeckParam::kPitchEnvDecay:
|
||||||
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
|
play.pitchEnv.shape.decaySeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kPitchEnvAttackCurve:
|
case DeckParam::kPitchEnvAttackCurve:
|
||||||
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
|
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kPitchEnvDecayCurve:
|
case DeckParam::kPitchEnvDecayCurve:
|
||||||
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
|
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kPitchEnvDepth:
|
case DeckParam::kPitchEnvDepth:
|
||||||
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
play.pitchEnv.peakSemitones = depthSemitonesFromNorm(value, kPitchDepthMaxSemis);
|
||||||
break;
|
break;
|
||||||
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
case DeckParam::kFilterEnable: play.filter.enabled = (segment == 1); break;
|
||||||
case DeckParam::kFilterLaw:
|
case DeckParam::kFilterLaw:
|
||||||
@@ -162,15 +161,15 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
|||||||
case DeckParam::kFilterKeyTrack:
|
case DeckParam::kFilterKeyTrack:
|
||||||
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
||||||
case DeckParam::kFilterEnvAttack:
|
case DeckParam::kFilterEnvAttack:
|
||||||
play.filter.env.attackSeconds = normToSec(value); break;
|
play.filter.env.attackSeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kFilterEnvHold:
|
case DeckParam::kFilterEnvHold:
|
||||||
play.filter.env.holdSeconds = normToSec(value); break;
|
play.filter.env.holdSeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kFilterEnvDecay:
|
case DeckParam::kFilterEnvDecay:
|
||||||
play.filter.env.decaySeconds = normToSec(value); break;
|
play.filter.env.decaySeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kFilterEnvSustain:
|
case DeckParam::kFilterEnvSustain:
|
||||||
play.filter.env.sustainLevel = clamp01(value); break;
|
play.filter.env.sustainLevel = clamp01(value); break;
|
||||||
case DeckParam::kFilterEnvRelease:
|
case DeckParam::kFilterEnvRelease:
|
||||||
play.filter.env.releaseSeconds = normToSec(value); break;
|
play.filter.env.releaseSeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kFilterEnvAttackCurve:
|
case DeckParam::kFilterEnvAttackCurve:
|
||||||
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
|
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kFilterEnvDecayCurve:
|
case DeckParam::kFilterEnvDecayCurve:
|
||||||
@@ -178,11 +177,11 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
|||||||
case DeckParam::kFilterEnvReleaseCurve:
|
case DeckParam::kFilterEnvReleaseCurve:
|
||||||
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
|
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kFilterTrigAttack:
|
case DeckParam::kFilterTrigAttack:
|
||||||
play.filter.trigEnv.attackSeconds = normToSec(value); break;
|
play.filter.trigEnv.attackSeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kFilterTrigHold:
|
case DeckParam::kFilterTrigHold:
|
||||||
play.filter.trigEnv.holdFraction = clamp01(value); break;
|
play.filter.trigEnv.holdFraction = clamp01(value); break;
|
||||||
case DeckParam::kFilterTrigDecay:
|
case DeckParam::kFilterTrigDecay:
|
||||||
play.filter.trigEnv.decaySeconds = normToSec(value); break;
|
play.filter.trigEnv.decaySeconds = timeSecondsFromNorm(value); break;
|
||||||
case DeckParam::kFilterTrigAttackCurve:
|
case DeckParam::kFilterTrigAttackCurve:
|
||||||
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
|
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case DeckParam::kFilterTrigDecayCurve:
|
case DeckParam::kFilterTrigDecayCurve:
|
||||||
@@ -197,9 +196,160 @@ void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment) {
|
|||||||
enforceGateUnavailableWhileDrawn(play);
|
enforceGateUnavailableWhileDrawn(play);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deckParamNorm and setDeckParam carry each id's MAP — which taper, which clamp; these two carry
|
||||||
|
// only its LOCATION, which is the whole mechanism of the taper-free reset (see deck_values.h for
|
||||||
|
// why they are exposed beyond that one caller). A toggle, radio or curve cell has no reset gesture
|
||||||
|
// and resolves to null.
|
||||||
|
double* deckDoubleField(DeckParam id, PlaySeconds& p) {
|
||||||
|
switch (id) {
|
||||||
|
case DeckParam::kAttack: return &p.adsr.attackSeconds;
|
||||||
|
case DeckParam::kHold: return &p.adsr.holdSeconds;
|
||||||
|
case DeckParam::kDecay: return &p.adsr.decaySeconds;
|
||||||
|
case DeckParam::kSustain: return &p.adsr.sustainLevel;
|
||||||
|
case DeckParam::kRelease: return &p.adsr.releaseSeconds;
|
||||||
|
case DeckParam::kAttackCurve: return &p.adsr.attackCurve;
|
||||||
|
case DeckParam::kDecayCurve: return &p.adsr.decayCurve;
|
||||||
|
case DeckParam::kReleaseCurve: return &p.adsr.releaseCurve;
|
||||||
|
case DeckParam::kTrigLength: return &p.trigger.lengthFraction;
|
||||||
|
case DeckParam::kTrigAttack: return &p.trigAhd.attackSeconds;
|
||||||
|
case DeckParam::kTrigHold: return &p.trigAhd.holdFraction;
|
||||||
|
case DeckParam::kTrigDecay: return &p.trigAhd.decaySeconds;
|
||||||
|
case DeckParam::kTrigAttackCurve: return &p.trigAhd.attackCurve;
|
||||||
|
case DeckParam::kTrigDecayCurve: return &p.trigAhd.decayCurve;
|
||||||
|
case DeckParam::kPitchEnvAttack: return &p.pitchEnv.shape.attackSeconds;
|
||||||
|
case DeckParam::kPitchEnvHold: return &p.pitchEnv.shape.holdFraction;
|
||||||
|
case DeckParam::kPitchEnvDecay: return &p.pitchEnv.shape.decaySeconds;
|
||||||
|
case DeckParam::kPitchEnvAttackCurve: return &p.pitchEnv.shape.attackCurve;
|
||||||
|
case DeckParam::kPitchEnvDecayCurve: return &p.pitchEnv.shape.decayCurve;
|
||||||
|
case DeckParam::kPitchEnvDepth: return &p.pitchEnv.peakSemitones;
|
||||||
|
case DeckParam::kFilterModAmt: return &p.filter.modAmount;
|
||||||
|
case DeckParam::kFilterVel: return &p.filter.velAmount;
|
||||||
|
case DeckParam::kFilterKeyTrack: return &p.filter.keyTrack;
|
||||||
|
case DeckParam::kFilterEnvAttack: return &p.filter.env.attackSeconds;
|
||||||
|
case DeckParam::kFilterEnvHold: return &p.filter.env.holdSeconds;
|
||||||
|
case DeckParam::kFilterEnvDecay: return &p.filter.env.decaySeconds;
|
||||||
|
case DeckParam::kFilterEnvSustain: return &p.filter.env.sustainLevel;
|
||||||
|
case DeckParam::kFilterEnvRelease: return &p.filter.env.releaseSeconds;
|
||||||
|
case DeckParam::kFilterEnvAttackCurve: return &p.filter.env.attackCurve;
|
||||||
|
case DeckParam::kFilterEnvDecayCurve: return &p.filter.env.decayCurve;
|
||||||
|
case DeckParam::kFilterEnvReleaseCurve: return &p.filter.env.releaseCurve;
|
||||||
|
case DeckParam::kFilterTrigAttack: return &p.filter.trigEnv.attackSeconds;
|
||||||
|
case DeckParam::kFilterTrigHold: return &p.filter.trigEnv.holdFraction;
|
||||||
|
case DeckParam::kFilterTrigDecay: return &p.filter.trigEnv.decaySeconds;
|
||||||
|
case DeckParam::kFilterTrigAttackCurve: return &p.filter.trigEnv.attackCurve;
|
||||||
|
case DeckParam::kFilterTrigDecayCurve: return &p.filter.trigEnv.decayCurve;
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter's four tone controls store their NORMALIZED position, as floats, and their law is
|
||||||
|
// wire-frozen — hence a second resolver rather than a widened first one.
|
||||||
|
float* deckFloatField(DeckParam id, PlaySeconds& p) {
|
||||||
|
switch (id) {
|
||||||
|
case DeckParam::kFilterMorph: return &p.filter.settings.morphNorm;
|
||||||
|
case DeckParam::kFilterCutoff: return &p.filter.settings.cutoffNorm;
|
||||||
|
case DeckParam::kFilterQ: return &p.filter.settings.resonanceNorm;
|
||||||
|
case DeckParam::kFilterDrive: return &p.filter.settings.driveNorm;
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void resetDeckParam(DeckParam id, PlaySeconds& play) {
|
void resetDeckParam(DeckParam id, PlaySeconds& play) {
|
||||||
const PlaySeconds defaults;
|
PlaySeconds defaults;
|
||||||
setDeckParam(id, play, deckParamNorm(id, defaults), 0);
|
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) {
|
void formatEnvTimeMs(double seconds, char* buf, std::size_t len) {
|
||||||
|
|||||||
@@ -11,14 +11,15 @@
|
|||||||
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
|
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the deck's edit target)
|
||||||
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
#include "core/instrument/ui/deck_groups.h" // DeckParam
|
||||||
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
|
#include "core/instrument/ui/envelope_overlay.h" // kGateStageMaxSeconds
|
||||||
|
#include "core/instrument/ui/param_taper.h" // UnitCategory + the shared tapers
|
||||||
|
|
||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
using map::PlaySeconds;
|
using map::PlaySeconds;
|
||||||
|
|
||||||
// Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly what the
|
// 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
|
// parameter set stores. An ALIAS of the overlay's schematic domain, which is itself an alias of
|
||||||
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
|
// the taper's; param_taper.h owns why the number has one home.
|
||||||
inline constexpr double kEnvTimeMaxSeconds = kGateStageMaxSeconds;
|
inline constexpr double kEnvTimeMaxSeconds = kGateStageMaxSeconds;
|
||||||
|
|
||||||
// Pitch depth throw: +/-kVelocityPitchRangeSemitones, centred. The one throw the pitch
|
// 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.
|
// Key-track knob ceiling (0..200%), shared by the pitch and filter key-track controls.
|
||||||
inline constexpr double kKeyTrackMax = 2.0;
|
inline constexpr double kKeyTrackMax = 2.0;
|
||||||
|
|
||||||
// The normalized [0,1] a control shows: seconds over the ceiling, levels and fractions as-is,
|
// The normalized [0,1] a control shows: stage times through the shared time taper, levels and
|
||||||
// signed depths centred at 0.5, curve exponents over their logarithmic travel. Controls backed
|
// fractions as-is, signed depths through the centre-expanded depth taper, curve exponents over
|
||||||
// by per-instance state rather than the parameter set (voice count, master gain, the pitch
|
// their logarithmic travel. Controls backed by per-instance state rather than the parameter set
|
||||||
// key-track scalar, preview velocity) are not here — the shell reads those from the processor.
|
// (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);
|
double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
||||||
|
|
||||||
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
|
// Applies a committed interaction: a knob's normalized `value`, or a toggle's `segment` (0/1).
|
||||||
@@ -39,13 +41,33 @@ double deckParamNorm(DeckParam id, const PlaySeconds& play);
|
|||||||
void setDeckParam(DeckParam id, PlaySeconds& play, double value, int segment);
|
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
|
// 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
|
// second table of defaults to drift from the real one, and the value is COPIED rather than
|
||||||
// landing EXACTLY on a stage time (0.003 s attack, 0.060 s release) depends on
|
// round-tripped through norm -> value. That bypass is MANDATORY: a reset must land on the stored
|
||||||
// kEnvTimeMaxSeconds being a power of two — x/2^n*2^n is lossless, an arbitrary ceiling is not.
|
// default bit for bit, and no round trip through a log taper over a non-power-of-two ceiling can
|
||||||
// Move that ceiling off a power of two and a reset lands a mantissa bit off its own default.
|
// promise that for every control. Never "simplify" it back into a round trip.
|
||||||
// For knob-valued controls — a toggle has no reset gesture.
|
// For knob-valued controls — a toggle has no reset gesture.
|
||||||
void resetDeckParam(DeckParam id, PlaySeconds& play);
|
void resetDeckParam(DeckParam id, PlaySeconds& play);
|
||||||
|
|
||||||
|
// The ADDRESS of the one stored field `id` owns — the mechanism resetDeckParam bypasses the taper
|
||||||
|
// with. Exposed beyond that one caller so a test can verify a reset (or any other mutation)
|
||||||
|
// against the actual stored field rather than its normalized read-back, which deckParamNorm does
|
||||||
|
// not guarantee is injective. Null for a control with no reset gesture (a toggle, radio, or
|
||||||
|
// curve-popup cell) or one whose value lives outside PlaySeconds (master gain, key-track).
|
||||||
|
double* deckDoubleField(DeckParam id, PlaySeconds& p);
|
||||||
|
// The filter's four tone controls store their normalized position as float — see deckFloatField's
|
||||||
|
// definition for why that is a second resolver rather than a widened first one.
|
||||||
|
float* deckFloatField(DeckParam id, PlaySeconds& p);
|
||||||
|
|
||||||
|
// THE snap-unit table: which whole unit Shift snaps each control to. Includes the deck's
|
||||||
|
// processor-side ids (voice count, master gain), which have no entry in the two functions above
|
||||||
|
// because their VALUE lives outside the parameter set — the unit does not.
|
||||||
|
UnitCategory deckParamUnit(DeckParam id);
|
||||||
|
|
||||||
|
// Applies that snap to a control's normalized value. Snapping happens in the DISPLAYED unit, so
|
||||||
|
// this is where each control's full scale enters: 0..100 %, 0..200 % and +/-100 % all snap to a
|
||||||
|
// whole displayed percent and therefore take different norm steps.
|
||||||
|
double snapDeckParamNorm(DeckParam id, double norm);
|
||||||
|
|
||||||
// A time constant as MILLISECONDS, e.g. "12 ms". Never switches to seconds: the editor reads in
|
// 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
|
// 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.
|
// attack is not rounded to a bare "0 ms". Writes at most `len` bytes including the terminator.
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
using util::clamp01;
|
using util::clamp01;
|
||||||
using util::curveFromMidLevel;
|
using util::curveFromLevelAt;
|
||||||
using util::curveMidLevel;
|
using util::curveLevelAt;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -23,11 +23,25 @@ double secondsPerPixel(const Rect& area, double totalSeconds) {
|
|||||||
return totalSeconds / static_cast<double>(w);
|
return totalSeconds / static_cast<double>(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
|
// The exact inverse of gatePolyline's tapered stage placement: a stage's drawn offset inside its
|
||||||
// dragged handle tracks the cursor 1:1.
|
// slot is slot * timeNormFromSeconds(t), so a pixel delta moves the NORM by dx/slot — never the
|
||||||
double gateSecondsPerPixel(const Rect& area) {
|
// seconds by a fixed rate. Reading the same taper the draw does is what makes a dragged handle
|
||||||
const double pps = gatePxPerSecond(area);
|
// track the cursor at both ends of the range instead of only near the ceiling.
|
||||||
return pps > 0.0 ? 1.0 / pps : 0.0;
|
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]).
|
// Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
|
||||||
@@ -91,10 +105,35 @@ SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
|
// The pixel bounds of the segment a curve knot rides, by node — read off the SAME polyline the
|
||||||
// curve_law's inverse (curve_law.h owns why the knot and the inner dial share this one law).
|
// draw built (never re-derived), so the drag's phi can never disagree with knotVtx's.
|
||||||
|
struct SegmentPixels {
|
||||||
|
int x0 = 0;
|
||||||
|
int x1 = 0;
|
||||||
|
bool ok = false;
|
||||||
|
};
|
||||||
|
SegmentPixels segmentPixels(const std::vector<EnvVertex>& poly, EnvNode knot) {
|
||||||
|
EnvNode startNode, endNode;
|
||||||
|
switch (knot) {
|
||||||
|
case EnvNode::AttackCurve: startNode = EnvNode::Origin; endNode = EnvNode::AttackEnd; break;
|
||||||
|
case EnvNode::DecayCurve: startNode = EnvNode::HoldEnd; endNode = EnvNode::DecayEnd; break;
|
||||||
|
case EnvNode::ReleaseCurve: startNode = EnvNode::ReleaseStart; endNode = EnvNode::ReleaseEnd; break;
|
||||||
|
default: return {};
|
||||||
|
}
|
||||||
|
SegmentPixels s;
|
||||||
|
bool haveStart = false, haveEnd = false;
|
||||||
|
for (const EnvVertex& v : poly) {
|
||||||
|
if (v.node == startNode) { s.x0 = v.x; haveStart = true; }
|
||||||
|
else if (v.node == endNode) { s.x1 = v.x; haveEnd = true; }
|
||||||
|
}
|
||||||
|
s.ok = haveStart && haveEnd;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A knot drag: the grab-time level at `phi` (the phi the knot's own drawn x implies — see
|
||||||
|
// knotPhi) shifted by the pixel delta, read back through curve_law's inverse at that same phi.
|
||||||
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
|
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
|
||||||
const Rect& area, int dyPixels) {
|
double phi, const Rect& area, double dyPixels) {
|
||||||
const SegmentLevels seg = segmentLevels(grabEnv, knot);
|
const SegmentLevels seg = segmentLevels(grabEnv, knot);
|
||||||
if (!seg.ok) return grabExponent;
|
if (!seg.ok) return grabExponent;
|
||||||
const double span = seg.end - seg.start;
|
const double span = seg.end - seg.start;
|
||||||
@@ -103,9 +142,9 @@ double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grab
|
|||||||
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
|
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
|
||||||
// level travel — a segment thinner than that is visually a no-op drag anyway.
|
// level travel — a segment thinner than that is visually a no-op drag anyway.
|
||||||
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
|
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
|
||||||
const double grabLevel = seg.start + span * curveMidLevel(grabExponent);
|
const double grabLevel = seg.start + span * curveLevelAt(phi, grabExponent);
|
||||||
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
|
const double newLevel = grabLevel - dyPixels * levelPerPixel(area);
|
||||||
return curveFromMidLevel((newLevel - seg.start) / span);
|
return curveFromLevelAt(phi, (newLevel - seg.start) / span);
|
||||||
}
|
}
|
||||||
|
|
||||||
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
|
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
|
||||||
@@ -150,15 +189,29 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
|
|||||||
|
|
||||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||||
double totalSeconds, const EnvClampBounds& bounds,
|
double totalSeconds, const EnvClampBounds& bounds,
|
||||||
int dxPixels, int dyPixels) {
|
int dxPixels, int dyPixels, const DragModifiers& mods) {
|
||||||
StageEnvelope out = grabEnv;
|
StageEnvelope out = grabEnv;
|
||||||
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
|
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
|
||||||
|
|
||||||
const Rect& rect = area.rect;
|
const Rect& rect = area.rect;
|
||||||
const double secPerPx = secondsPerPixel(rect, totalSeconds);
|
const double secPerPx = secondsPerPixel(rect, totalSeconds);
|
||||||
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
|
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
|
||||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
// Fine drag scales the PIXEL delta, so it composes with every axis below (the tapered
|
||||||
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
|
// schematic, the 1:1 wall clock, the level and the exponent) without a second rule.
|
||||||
|
const double scale = fineDrag(mods) ? kFineDragScale : 1.0;
|
||||||
|
const double dx = static_cast<double>(dxPixels) * scale;
|
||||||
|
const double dy = static_cast<double>(dyPixels) * scale;
|
||||||
|
const double dSec = dx * secPerPx;
|
||||||
|
|
||||||
|
// A curve knot's phi is read off the same polyline knotVtx drew, so the drag inverts the
|
||||||
|
// exact phi the knot is sitting at rather than assuming the segment midpoint.
|
||||||
|
double curvePhi = 0.5;
|
||||||
|
if (node == EnvNode::AttackCurve || node == EnvNode::DecayCurve ||
|
||||||
|
node == EnvNode::ReleaseCurve) {
|
||||||
|
const std::vector<EnvVertex> poly = buildEnvelopePolyline(grabEnv, area, totalSeconds);
|
||||||
|
const SegmentPixels sp = segmentPixels(poly, node);
|
||||||
|
if (sp.ok) curvePhi = knotPhi(sp.x0, sp.x1);
|
||||||
|
}
|
||||||
|
|
||||||
if (grabEnv.kind == EnvKind::Ahdsr) {
|
if (grabEnv.kind == EnvKind::Ahdsr) {
|
||||||
switch (node) {
|
switch (node) {
|
||||||
@@ -166,38 +219,43 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
|||||||
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
|
// 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.
|
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
|
||||||
case EnvNode::AttackEnd:
|
case EnvNode::AttackEnd:
|
||||||
out.attackSeconds =
|
out.attackSeconds = std::clamp(
|
||||||
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
snappedSeconds(gateStageFromPixels(grabEnv.attackSeconds, rect, dx), mods), 0.0,
|
||||||
|
bounds.maxAttackSeconds);
|
||||||
break;
|
break;
|
||||||
case EnvNode::HoldEnd:
|
case EnvNode::HoldEnd:
|
||||||
out.holdSeconds =
|
out.holdSeconds = std::clamp(
|
||||||
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
|
snappedSeconds(gateStageFromPixels(grabEnv.holdSeconds, rect, dx), mods), 0.0,
|
||||||
|
bounds.maxHoldSeconds);
|
||||||
break;
|
break;
|
||||||
case EnvNode::DecayEnd: {
|
case EnvNode::DecayEnd: {
|
||||||
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
||||||
out.decaySeconds =
|
out.decaySeconds = std::clamp(
|
||||||
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
snappedSeconds(gateStageFromPixels(grabEnv.decaySeconds, rect, dx), mods), 0.0,
|
||||||
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
|
bounds.maxDecaySeconds);
|
||||||
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
const double dLevel = -dy * levelPerPixel(rect);
|
||||||
|
out.sustainLevel =
|
||||||
|
std::clamp(snappedFraction(grabEnv.sustainLevel + dLevel, mods), 0.0, 1.0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EnvNode::ReleaseStart:
|
case EnvNode::ReleaseStart:
|
||||||
// The release runs from this node to the anchored right edge, so dragging LEFT
|
// 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.
|
// (negative dx) lengthens it — the delta enters with the opposite sign.
|
||||||
out.releaseSeconds =
|
out.releaseSeconds = std::clamp(
|
||||||
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
|
snappedSeconds(gateStageFromPixels(grabEnv.releaseSeconds, rect, -dx), mods), 0.0,
|
||||||
|
bounds.maxReleaseSeconds);
|
||||||
break;
|
break;
|
||||||
case EnvNode::AttackCurve:
|
case EnvNode::AttackCurve:
|
||||||
out.attackCurve =
|
out.attackCurve = snappedExponent(
|
||||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
|
||||||
break;
|
break;
|
||||||
case EnvNode::DecayCurve:
|
case EnvNode::DecayCurve:
|
||||||
out.decayCurve =
|
out.decayCurve = snappedExponent(
|
||||||
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
|
||||||
break;
|
break;
|
||||||
case EnvNode::ReleaseCurve:
|
case EnvNode::ReleaseCurve:
|
||||||
out.releaseCurve =
|
out.releaseCurve = snappedExponent(
|
||||||
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
|
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, curvePhi, rect, dy), mods);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -209,8 +267,8 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
|||||||
const AhdSplit s = splitAhdSeconds(grabEnv);
|
const AhdSplit s = splitAhdSeconds(grabEnv);
|
||||||
switch (node) {
|
switch (node) {
|
||||||
case EnvNode::AttackEnd:
|
case EnvNode::AttackEnd:
|
||||||
out.attackSeconds =
|
out.attackSeconds = std::clamp(snappedSeconds(grabEnv.attackSeconds + dSec, mods), 0.0,
|
||||||
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
|
bounds.maxAttackSeconds);
|
||||||
break;
|
break;
|
||||||
case EnvNode::HoldEnd: {
|
case EnvNode::HoldEnd: {
|
||||||
// Hold is a fraction of what attack and decay left, so the node's pixel motion
|
// Hold is a fraction of what attack and decay left, so the node's pixel motion
|
||||||
@@ -218,7 +276,7 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
|||||||
// nothing the drag could express.
|
// nothing the drag could express.
|
||||||
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
|
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
|
||||||
if (rem <= 0.0) break;
|
if (rem <= 0.0) break;
|
||||||
out.holdFraction = clamp01((s.hold + dSec) / rem);
|
out.holdFraction = clamp01(snappedFraction((s.hold + dSec) / rem, mods));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EnvNode::DecayEnd: {
|
case EnvNode::DecayEnd: {
|
||||||
@@ -231,17 +289,18 @@ StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const
|
|||||||
// unchanged rather than divided by zero.
|
// unchanged rather than divided by zero.
|
||||||
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
|
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
|
||||||
if (denom > 1e-9) {
|
if (denom > 1e-9) {
|
||||||
out.decaySeconds =
|
out.decaySeconds = std::clamp(snappedSeconds(grabEnv.decaySeconds + dSec / denom, mods),
|
||||||
std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds);
|
0.0, bounds.maxDecaySeconds);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case EnvNode::AttackCurve:
|
case EnvNode::AttackCurve:
|
||||||
out.attackCurve =
|
out.attackCurve = snappedExponent(
|
||||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, curvePhi, rect, dy), mods);
|
||||||
break;
|
break;
|
||||||
case EnvNode::DecayCurve:
|
case EnvNode::DecayCurve:
|
||||||
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
out.decayCurve = snappedExponent(
|
||||||
|
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, curvePhi, rect, dy), mods);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -51,15 +51,18 @@ NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double to
|
|||||||
// Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time
|
// 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);
|
// (the shell snapshots it on button-down so the delta is absolute, not accumulated);
|
||||||
// `dxPixels`/`dyPixels` is the pixel delta since grab.
|
// `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
|
// * X delta -> the node's time param, through the same map the forward draw used — the tapered
|
||||||
// [0, per-param max].
|
// 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
|
// * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve
|
||||||
// exponent. Ignored for time-only nodes.
|
// 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
|
// * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns
|
||||||
// `grabEnv` unchanged.
|
// `grabEnv` unchanged.
|
||||||
// Only the dragged node's param(s) change. Pure.
|
// Only the dragged node's param(s) change. Pure.
|
||||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||||
double totalSeconds, const EnvClampBounds& bounds,
|
double totalSeconds, const EnvClampBounds& bounds,
|
||||||
int dxPixels, int dyPixels);
|
int dxPixels, int dyPixels, const DragModifiers& mods = {});
|
||||||
|
|
||||||
} // namespace reasampler::instrument::ui
|
} // namespace reasampler::instrument::ui
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
using util::clamp01;
|
using util::clamp01;
|
||||||
using util::curveMap;
|
using util::curveLevelAt;
|
||||||
using util::curveMidLevel;
|
|
||||||
|
|
||||||
int timeToX(const Rect& area, double totalSeconds, double t) {
|
int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||||
const int w = std::max(0, area.width);
|
const int w = std::max(0, area.width);
|
||||||
@@ -23,7 +22,7 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
|
|||||||
return area.x + static_cast<int>(px + 0.5);
|
return area.x + static_cast<int>(px + 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
double gatePxPerSecond(const Rect& area) {
|
double gateStageSlotPx(const Rect& area) {
|
||||||
const int w = std::max(0, area.width);
|
const int w = std::max(0, area.width);
|
||||||
if (w <= 0) return 0.0;
|
if (w <= 0) return 0.0;
|
||||||
// The four timed stages share the canvas minus their four separation bases and the last
|
// The four timed stages share the canvas minus their four separation bases and the last
|
||||||
@@ -31,7 +30,7 @@ double gatePxPerSecond(const Rect& area) {
|
|||||||
// puts the plateau's end one separation short of the right edge rather than a fixed
|
// puts the plateau's end one separation short of the right edge rather than a fixed
|
||||||
// fraction of the way across.
|
// fraction of the way across.
|
||||||
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
|
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
|
||||||
return usable / (4.0 * kGateStageMaxSeconds);
|
return usable / 4.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int levelToY(const Rect& area, double level) {
|
int levelToY(const Rect& area, double level) {
|
||||||
@@ -46,6 +45,12 @@ int levelToY(const Rect& area, double level) {
|
|||||||
return area.y + static_cast<int>(dy);
|
return area.y + static_cast<int>(dy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double knotPhi(int x0, int x1) {
|
||||||
|
if (x1 == x0) return 0.5;
|
||||||
|
const int mid = (x0 + x1) / 2;
|
||||||
|
return static_cast<double>(mid - x0) / static_cast<double>(x1 - x0);
|
||||||
|
}
|
||||||
|
|
||||||
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
|
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
|
||||||
AhdSplit out;
|
AhdSplit out;
|
||||||
const double span = std::max(0.0, env.spanSeconds);
|
const double span = std::max(0.0, env.spanSeconds);
|
||||||
@@ -90,14 +95,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
|
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
|
||||||
// pixel midpoint, its level read through curve_law.h's own law (the knot/dial pairing's home).
|
// pixel midpoint. Its level is read at the phi that midpoint's TRUNCATED x actually implies
|
||||||
|
// (knotPhi), not always phi = 0.5 — an odd-pixel span would otherwise draw the knot a half
|
||||||
|
// pixel off the curve its own vertices trace. curve_law.h owns the knot/dial pairing.
|
||||||
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
|
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
|
||||||
double endLevel, double exponent) {
|
double endLevel, double exponent) {
|
||||||
const double u = curveMidLevel(exponent);
|
|
||||||
const double level = startLevel + (endLevel - startLevel) * u;
|
|
||||||
EnvVertex v;
|
EnvVertex v;
|
||||||
v.node = node;
|
v.node = node;
|
||||||
v.x = (x0 + x1) / 2;
|
v.x = (x0 + x1) / 2;
|
||||||
|
const double u = curveLevelAt(knotPhi(x0, x1), exponent);
|
||||||
|
const double level = startLevel + (endLevel - startLevel) * u;
|
||||||
v.y = levelToY(area, level);
|
v.y = levelToY(area, level);
|
||||||
v.level = level;
|
v.level = level;
|
||||||
v.knot = true;
|
v.knot = true;
|
||||||
@@ -114,17 +121,17 @@ std::vector<EnvVertex> gatePolyline(const StageEnvelope& env, const Rect& area)
|
|||||||
|
|
||||||
const int W = std::max(1, area.width);
|
const int W = std::max(1, area.width);
|
||||||
const double sep = static_cast<double>(kGateNodeSepPx);
|
const double sep = static_cast<double>(kGateNodeSepPx);
|
||||||
const double pps = gatePxPerSecond(area);
|
const double slot = gateStageSlotPx(area);
|
||||||
const double xMax = static_cast<double>(W - 1);
|
const double xMax = static_cast<double>(W - 1);
|
||||||
|
|
||||||
// The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart —
|
// 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
|
// 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
|
// 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.
|
// leaves the plateau running to within one separation of the edge.
|
||||||
double xAttack = sep + a * pps;
|
double xAttack = sep + slot * timeNormFromSeconds(a);
|
||||||
double xHold = xAttack + sep + h * pps;
|
double xHold = xAttack + sep + slot * timeNormFromSeconds(h);
|
||||||
double xDecay = xHold + sep + d * pps;
|
double xDecay = xHold + sep + slot * timeNormFromSeconds(d);
|
||||||
double xPlateau = xMax - sep - r * pps;
|
double xPlateau = xMax - sep - slot * timeNormFromSeconds(r);
|
||||||
const double xRelease = xMax;
|
const double xRelease = xMax;
|
||||||
|
|
||||||
// Keep every node separated when the four stages together would overrun the canvas: the
|
// Keep every node separated when the four stages together would overrun the canvas: the
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
|
#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
|
#include "core/util/curve_law.h" // the ONE per-segment curve law
|
||||||
|
|
||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
@@ -80,14 +81,18 @@ struct EnvVertex {
|
|||||||
inline constexpr int kGateNodeSepPx = 8;
|
inline constexpr int kGateNodeSepPx = 8;
|
||||||
|
|
||||||
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
|
// 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
|
// span at most this. An ALIAS of the taper's own domain end, not a second constant: a maxed knob
|
||||||
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
|
// lands exactly at the canvas edge (at which point the sustain plateau has shrunk to nothing)
|
||||||
inline constexpr double kGateStageMaxSeconds = 2.0;
|
// only while the two agree.
|
||||||
|
inline constexpr double kGateStageMaxSeconds = kStageTimeMaxSeconds;
|
||||||
|
|
||||||
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
|
// Width of ONE of the AHDSR schematic's four equal stage slots, independent of the sample's
|
||||||
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
|
// actual duration. A stage of `t` seconds fills slot * timeNormFromSeconds(t) pixels of it — the
|
||||||
// the cursor 1:1.
|
// axis IS the knob's taper, so a node's position within its slot is that knob's needle position
|
||||||
double gatePxPerSecond(const Rect& area);
|
// 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`
|
// 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
|
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the
|
||||||
@@ -110,6 +115,13 @@ int timeToX(const Rect& area, double totalSeconds, double t);
|
|||||||
// clamped. Shared with envelope_edit's node hit-test.
|
// clamped. Shared with envelope_edit's node hit-test.
|
||||||
int levelToY(const Rect& area, double level);
|
int levelToY(const Rect& area, double level);
|
||||||
|
|
||||||
|
// The normalized phi a curve knot's TRUNCATED integer x actually lands at within its bounding
|
||||||
|
// segment [x0, x1] — exactly 0.5 only when the span is even. Shared with envelope_edit's knot
|
||||||
|
// drag so the draw and its inverse read the same phi off the same formula rather than two
|
||||||
|
// copies that could drift apart. x0 == x1 (no interior) returns 0.5; callers never place a knot
|
||||||
|
// there.
|
||||||
|
double knotPhi(int x0, int x1);
|
||||||
|
|
||||||
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
|
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
|
||||||
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
|
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
|
||||||
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
|
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
|
||||||
|
|||||||
@@ -135,11 +135,13 @@ KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double v
|
|||||||
knob.centerY - knob.radius * std::cos(rad)};
|
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);
|
const double start = clamp01(startValue);
|
||||||
if (dragRangePixels <= 0) return start;
|
if (dragRangePixels <= 0) return start;
|
||||||
|
const double dy = static_cast<double>(dyPixels) * (fineDrag(mods) ? kFineDragScale : 1.0);
|
||||||
// Screen y grows downward: an upward drag (negative dy) increases the value.
|
// Screen y grows downward: an upward drag (negative dy) increases the value.
|
||||||
return clamp01(start - static_cast<double>(dyPixels) / dragRangePixels);
|
return clamp01(start - dy / dragRangePixels);
|
||||||
}
|
}
|
||||||
|
|
||||||
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
|
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
#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 {
|
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,
|
// 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
|
// `dyPixels` the pointer's y displacement (down = positive). Up increases, down
|
||||||
// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range.
|
// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range. Ctrl in
|
||||||
double knobDragValue(double startValue, int dyPixels,
|
// `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);
|
int dragRangePixels = kKnobDragRangePixels);
|
||||||
|
|
||||||
// Control a point lands on, given laid-out `rows`. Returns the control id whose
|
// Control a point lands on, given laid-out `rows`. Returns the control id whose
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// param_taper.cpp — see param_taper.h. Pure value math; no host types.
|
||||||
|
|
||||||
|
#include "core/instrument/ui/param_taper.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// The output quanta (header: EXACT PREIMAGE). Powers of TEN on purpose: std::round(v*S)/S is the
|
||||||
|
// correctly-rounded double of k/S, which is the same double a decimal literal of k/S parses to —
|
||||||
|
// so a default written as 0.003 or 0.060 lands on the grid exactly. A power-of-two quantum would
|
||||||
|
// not have that property against decimal literals.
|
||||||
|
constexpr double kSecondsPerQuantum = 1e9; // 1 ns
|
||||||
|
constexpr double kSemitonesPerQuantum = 1e6; // 1 micro-semitone
|
||||||
|
|
||||||
|
// std::nearbyint reads the CURRENT FP rounding mode (MXCSR) — not exclusively ours on a DAW's UI
|
||||||
|
// thread. Under round-toward-zero it can drop a grid value by a whole quantum, which is exactly
|
||||||
|
// what the quantization scheme exists to prevent. std::round (half-away-from-zero) is the same
|
||||||
|
// regardless of that mode, which is what makes the EXACT PREIMAGE guarantee (header) structural.
|
||||||
|
double resolveTo(double value, double perUnit) { return std::round(value * perUnit) / perUnit; }
|
||||||
|
|
||||||
|
// The shifted-log offsets. Both are FITTED AGAINST THE CEILING above them, which is why the
|
||||||
|
// ceiling could not be raised in a later track: doing the two apart means fitting twice.
|
||||||
|
constexpr double kTimeOffsetSeconds = 0.003; // -> 10 ms at 0.181, 100 ms at 0.436
|
||||||
|
constexpr double kDepthOffsetSemitones = 3.0; // -> +/-7 st at 0.548 of each half-travel
|
||||||
|
|
||||||
|
double timeSpan() { return std::log1p(kStageTimeMaxSeconds / kTimeOffsetSeconds); }
|
||||||
|
|
||||||
|
double depthSpan(double maxSemitones) {
|
||||||
|
return std::log1p(maxSemitones / kDepthOffsetSemitones);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
double timeNormFromSeconds(double seconds) {
|
||||||
|
if (!(seconds > 0.0)) return 0.0; // also catches NaN
|
||||||
|
if (seconds >= kStageTimeMaxSeconds) return 1.0;
|
||||||
|
return std::log1p(seconds / kTimeOffsetSeconds) / timeSpan();
|
||||||
|
}
|
||||||
|
|
||||||
|
double timeSecondsFromNorm(double norm) {
|
||||||
|
if (!(norm > 0.0)) return 0.0;
|
||||||
|
if (norm >= 1.0) return kStageTimeMaxSeconds;
|
||||||
|
return resolveTo(kTimeOffsetSeconds * std::expm1(norm * timeSpan()), kSecondsPerQuantum);
|
||||||
|
}
|
||||||
|
|
||||||
|
double depthNormFromSemitones(double semitones, double maxSemitones) {
|
||||||
|
if (!(maxSemitones > 0.0)) return 0.5;
|
||||||
|
if (semitones == 0.0) return 0.5; // the centre is EXACT, so a knob parked there persists
|
||||||
|
const double mag = std::fabs(semitones); // no depth at all
|
||||||
|
if (!(mag < maxSemitones)) return semitones > 0.0 ? 1.0 : 0.0;
|
||||||
|
const double u = std::log1p(mag / kDepthOffsetSemitones) / depthSpan(maxSemitones);
|
||||||
|
return semitones > 0.0 ? 0.5 + 0.5 * u : 0.5 - 0.5 * u;
|
||||||
|
}
|
||||||
|
|
||||||
|
double depthSemitonesFromNorm(double norm, double maxSemitones) {
|
||||||
|
if (!(maxSemitones > 0.0)) return 0.0;
|
||||||
|
if (!(norm > 0.0)) return -maxSemitones; // also catches NaN
|
||||||
|
if (norm >= 1.0) return maxSemitones;
|
||||||
|
if (norm == 0.5) return 0.0;
|
||||||
|
const double u = std::fabs(norm - 0.5) * 2.0;
|
||||||
|
const double mag = resolveTo(kDepthOffsetSemitones * std::expm1(u * depthSpan(maxSemitones)),
|
||||||
|
kSemitonesPerQuantum);
|
||||||
|
return norm > 0.5 ? mag : -mag;
|
||||||
|
}
|
||||||
|
|
||||||
|
double snapSecondsToWholeMs(double seconds) {
|
||||||
|
if (!(seconds > 0.0)) return 0.0;
|
||||||
|
return std::round(seconds * 1000.0) / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double snapFractionToWholePercent(double fraction) {
|
||||||
|
return std::round(fraction * 100.0) / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double snapSemitonesToWhole(double semitones) { return std::round(semitones); }
|
||||||
|
|
||||||
|
// Rounding lands on 1..10; anything under half a unit clamps to the domain floor rather than to
|
||||||
|
// zero, which is not an exponent. 1.0, the linear neutral, is therefore one snap from centre.
|
||||||
|
double snapExponentToWhole(double exponent) {
|
||||||
|
return util::clampCurve(std::round(util::clampCurve(exponent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// param_taper.h — THE norm <-> value tapers every variable control shares, plus the modifier
|
||||||
|
// vocabulary its drag surfaces read. Extracted from deck_values because three consumers in two
|
||||||
|
// dependency layers read it — the knob's needle, the AHDSR overlay's schematic axis and its drag
|
||||||
|
// inverse, and the VST3 host's normalization — and three functions that agree today is a defect.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/util/curve_law.h" // the exponent domain the whole-number snap clamps into
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
// --- the interaction law's modifiers -------------------------------------------------------
|
||||||
|
|
||||||
|
// Ctrl divides the drag rate by 20. Shift+Ctrl is SHIFT, Ctrl ignored: with the output quantized
|
||||||
|
// to whole units a finer drag yields the same sequence of values, so that is an identity rather
|
||||||
|
// than a compromise — do not "fix" it into a compounded scale.
|
||||||
|
inline constexpr double kFineDragScale = 0.05;
|
||||||
|
|
||||||
|
struct DragModifiers {
|
||||||
|
bool shift = false; // snap to whole units of the control's displayed unit
|
||||||
|
bool ctrl = false; // fine drag
|
||||||
|
|
||||||
|
bool operator==(const DragModifiers& o) const { return shift == o.shift && ctrl == o.ctrl; }
|
||||||
|
bool operator!=(const DragModifiers& o) const { return !(*this == o); }
|
||||||
|
};
|
||||||
|
|
||||||
|
inline bool fineDrag(const DragModifiers& m) { return m.ctrl && !m.shift; }
|
||||||
|
|
||||||
|
// Which whole unit Shift snaps a control to. Derived from the control's UNIT rather than from a
|
||||||
|
// per-widget list, so a control added later inherits the law by naming its category.
|
||||||
|
enum class UnitCategory {
|
||||||
|
None, // discrete, already-integer, or non-scalar controls — Shift changes nothing
|
||||||
|
Milliseconds,
|
||||||
|
Semitones,
|
||||||
|
Percent,
|
||||||
|
Exponent,
|
||||||
|
Decibels,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- the two tapers ------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// EXACT PREIMAGE, and why it is structural rather than lucky. A host's reset-to-default arrives
|
||||||
|
// as toPlain(defaultNorm) with no editor-side bypass available, so every default must satisfy
|
||||||
|
// toPlain(toNormalized(d)) == d BITWISE. No transcendental map delivers that at an arbitrary
|
||||||
|
// interior point — the image of toPlain is sparser there than the doubles around it — so both
|
||||||
|
// maps below resolve their output onto a fixed decimal quantum, via std::round rather than
|
||||||
|
// std::nearbyint: round is half-away-from-zero regardless of the caller's FP rounding mode, so
|
||||||
|
// the quantization is mode-independent, not just decimal-exact. That turns the guarantee into
|
||||||
|
// "every value on the quantum grid round-trips exactly" instead of a libm/MXCSR coincidence that
|
||||||
|
// a compiler upgrade or a host's UI thread could take away. Both quanta sit roughly 3.7-4 orders
|
||||||
|
// below the finest reachable drag step (time ~3.98, depth ~3.71), so nothing observable is
|
||||||
|
// quantized. The converse, toNormalized(toPlain(n)) == n at arbitrary n, is NOT required and must
|
||||||
|
// not be demanded: no log map satisfies it in double, and requiring it would rule out the shape
|
||||||
|
// the range needs.
|
||||||
|
|
||||||
|
// The stage-time domain's upper end — the value at norm 1, and the one home of that number:
|
||||||
|
// envelope_overlay's kGateStageMaxSeconds and deck_values' kEnvTimeMaxSeconds are both aliases
|
||||||
|
// of it, so the schematic's canvas edge and the knob's ceiling cannot drift apart. Once the
|
||||||
|
// instrument reports VST3 parameters this endpoint is a frozen host normalization — moving it
|
||||||
|
// re-interprets every automation point already recorded in projects we do not own.
|
||||||
|
inline constexpr double kStageTimeMaxSeconds = 10.0;
|
||||||
|
|
||||||
|
// Shifted-log: exactly 0 s at norm 0, exactly kStageTimeMaxSeconds at norm 1, monotone
|
||||||
|
// throughout, low end expanded so 10 ms sits at ~0.18 of the travel and 100 ms at ~0.44. A pure
|
||||||
|
// log cannot include zero and zero is a required value, which is what the offset buys.
|
||||||
|
double timeNormFromSeconds(double seconds);
|
||||||
|
double timeSecondsFromNorm(double norm);
|
||||||
|
|
||||||
|
// Signed depth, symmetric about norm 0.5: exactly 0 semitones at centre, exactly +/-maxSemitones
|
||||||
|
// at the ends, monotone, with the musically useful middle expanded so +/-7 st reaches ~0.55 of
|
||||||
|
// each half-travel. The throw is a PARAMETER — the +/-24 st depth constant has its own home in
|
||||||
|
// the engine's value layer and is not restated here.
|
||||||
|
double depthNormFromSemitones(double semitones, double maxSemitones);
|
||||||
|
double depthSemitonesFromNorm(double norm, double maxSemitones);
|
||||||
|
|
||||||
|
// --- Shift's whole-unit snaps, in the VALUE domain -------------------------------------------
|
||||||
|
//
|
||||||
|
// Stated over values rather than norms because "whole unit" means whole unit of what the control
|
||||||
|
// DISPLAYS: two controls sharing a category can have different full scales, so the norm step is
|
||||||
|
// the caller's business and the unit is this module's.
|
||||||
|
|
||||||
|
double snapSecondsToWholeMs(double seconds);
|
||||||
|
double snapFractionToWholePercent(double fraction); // 1.0 == 100 %
|
||||||
|
double snapSemitonesToWhole(double semitones);
|
||||||
|
double snapExponentToWhole(double exponent); // clamped into curve_law's own domain
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
+20
-14
@@ -1,8 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// curve_law — the ONE per-segment envelope curve law: the exponent domain, the map from a
|
// curve_law — the ONE per-segment envelope curve law: the exponent domain, the map from a
|
||||||
// stage's normalized position to its normalized level, and the mid-segment inverse the
|
// stage's normalized position to its normalized level, and that map's inverse (mid-segment is
|
||||||
// overlay knot drags through. Header-only and dependency-free so the engine evaluator, the
|
// the special case). Header-only and dependency-free so the engine evaluator, the overlay's
|
||||||
// overlay's forward map, and its inverse all read the same law rather than three copies.
|
// forward map, and its inverse all read the same law rather than three copies.
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
@@ -58,19 +58,25 @@ inline double knobNormFromCurve(double exponent) {
|
|||||||
return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
|
return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
|
// The normalized level at an arbitrary segment position phi in (0,1), and its inverse. A
|
||||||
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
|
// knot's DRAWN x truncates to an integer, which lands it off phi = 0.5 whenever its segment's
|
||||||
// one value: both resolve through this law, not through each other.
|
// pixel span is odd; reading the knot's y through the phi its own x actually implies (rather
|
||||||
inline double curveMidLevel(double exponent) { return curveMap(0.5, clampCurve(exponent)); }
|
// than assuming 0.5) is what keeps the knot on the trace its own vertices draw.
|
||||||
|
inline double curveLevelAt(double phi, double exponent) { return curveMap(phi, clampCurve(exponent)); }
|
||||||
|
|
||||||
// Mid-level -> exponent: u = 0.5^p, so p = ln(u)/ln(0.5). Out-of-domain u clamps to the
|
// Level -> exponent at phi: u = phi^p, so p = ln(u)/ln(phi). Out-of-domain u clamps to the
|
||||||
// exponent endpoints rather than producing a non-finite exponent.
|
// exponent endpoints rather than producing a non-finite exponent.
|
||||||
inline double curveFromMidLevel(double midLevel) {
|
inline double curveFromLevelAt(double phi, double level) {
|
||||||
const double lo = curveMidLevel(kCurveMax); // smallest reachable mid-level
|
const double lo = curveLevelAt(phi, kCurveMax); // smallest reachable level at this phi
|
||||||
const double hi = curveMidLevel(kCurveMin); // largest
|
const double hi = curveLevelAt(phi, kCurveMin); // largest
|
||||||
if (!(midLevel > lo)) return kCurveMax; // also catches NaN
|
if (!(level > lo)) return kCurveMax; // also catches NaN
|
||||||
if (midLevel >= hi) return kCurveMin;
|
if (level >= hi) return kCurveMin;
|
||||||
return clampCurve(std::log(midLevel) / std::log(0.5));
|
return clampCurve(std::log(level) / std::log(phi));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The segment-MIDPOINT (phi = 0.5) case — the knot's placement whenever its pixel span is
|
||||||
|
// even. Kept under its own name for the existing callers/tests that assume that case.
|
||||||
|
inline double curveMidLevel(double exponent) { return curveLevelAt(0.5, exponent); }
|
||||||
|
inline double curveFromMidLevel(double midLevel) { return curveFromLevelAt(0.5, midLevel); }
|
||||||
|
|
||||||
} // namespace reasampler::util
|
} // namespace reasampler::util
|
||||||
|
|||||||
@@ -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`,
|
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_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
||||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
`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
|
`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.
|
`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.
|
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
||||||
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
|
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
|
||||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
- `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.)*
|
- `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
|
## Gotchas
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
|
|||||||
|
|
||||||
// Alt-click delete is retired (the spec's right-click supersedes it — one grammar, no
|
// 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.
|
// migration on either side): every gesture here routes through the shared resolver.
|
||||||
const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
|
const SplineGesture gesture =
|
||||||
const SplineEdit edit = resolveSplineEdit(
|
dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft;
|
||||||
editedCurve(), box, ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft, x, y);
|
const SplineEdit edit = resolveSplineEdit(editedCurve(), box, gesture, x, y);
|
||||||
if (edit.kind == SplineEditKind::kToggleHard) {
|
if (edit.kind == SplineEditKind::kToggleHard) {
|
||||||
if (editedCurve().toggleHard(static_cast<std::size_t>(edit.index))) commitAndReload();
|
if (editedCurve().toggleHard(static_cast<std::size_t>(edit.index))) commitAndReload();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#ifdef _WIN32
|
#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/knob_deck.h" // hitTestDeck / layoutDeck
|
||||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
||||||
#include "shell/instrument/editor_internal.h"
|
#include "shell/instrument/editor_internal.h"
|
||||||
@@ -97,6 +98,7 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
|||||||
dragParamId_ = inner ? static_cast<int>(curve) : hit.id;
|
dragParamId_ = inner ? static_cast<int>(curve) : hit.id;
|
||||||
dragInnerCellId_ = inner ? hit.id : -1;
|
dragInnerCellId_ = inner ? hit.id : -1;
|
||||||
dragKnobStartValue_ = deckControlNorm(dragParamId_);
|
dragKnobStartValue_ = deckControlNorm(dragParamId_);
|
||||||
|
dragMods_ = dragModifiers();
|
||||||
// Processor-side knobs (voice count / master gain) are transient live writes with no
|
// Processor-side knobs (voice count / master gain) are transient live writes with no
|
||||||
// parameter-set mutation, so they need no rollback snapshot.
|
// parameter-set mutation, so they need no rollback snapshot.
|
||||||
dragStartParams_ = params_;
|
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
|
// 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.
|
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
|
||||||
(void)x;
|
(void)x;
|
||||||
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
|
const DragModifiers mods = dragModifiers();
|
||||||
|
if (mods != dragMods_) {
|
||||||
|
// Re-anchor (see dragMods_). Reading the anchor back off the control also means a Shift
|
||||||
|
// RELEASE re-anchors from the SNAPPED value, so the knob does not spring back.
|
||||||
|
dragKnobStartValue_ = deckControlNorm(dragParamId_);
|
||||||
|
dragStartY_ = y;
|
||||||
|
dragMods_ = mods;
|
||||||
|
}
|
||||||
|
double norm = knobDragValue(dragKnobStartValue_, y - dragStartY_, mods);
|
||||||
|
// The preview-velocity sentinel (-2) and the discrete controls have no whole unit to snap to.
|
||||||
|
if (mods.shift && dragParamId_ >= 0) {
|
||||||
|
norm = snapDeckParamNorm(static_cast<DeckParam>(dragParamId_), norm);
|
||||||
|
}
|
||||||
|
applyDeckKnob(dragParamId_, norm);
|
||||||
// A live control is delivered on every move, not only on release — that is the whole
|
// 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.
|
// point: the note already sounding tracks the hand on the knob.
|
||||||
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
|
if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive();
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
|||||||
|
|
||||||
const DeckEnableState gates = deckEnableState();
|
const DeckEnableState gates = deckEnableState();
|
||||||
const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates);
|
const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates);
|
||||||
const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0
|
const SplineGesture gesture =
|
||||||
? SplineGesture::kControlLeft
|
dragModifiers().ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft;
|
||||||
: SplineGesture::kLeft;
|
|
||||||
|
|
||||||
// The staged envelope's draggable node and the drawn contour's node are mutually exclusive
|
// 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),
|
// (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;
|
envNode_ = envNodeHit.node;
|
||||||
dragStartX_ = x;
|
dragStartX_ = x;
|
||||||
dragStartY_ = y;
|
dragStartY_ = y;
|
||||||
|
dragMods_ = dragModifiers();
|
||||||
dragStartEnv_ = env;
|
dragStartEnv_ = env;
|
||||||
dragSampleFrames_ = frames;
|
dragSampleFrames_ = frames;
|
||||||
dragStartParams_ = params_;
|
dragStartParams_ = params_;
|
||||||
@@ -213,9 +213,20 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
|
|||||||
const double rate = liveSampleRate();
|
const double rate = liveSampleRate();
|
||||||
if (frames <= 0 || rate <= 0.0) return;
|
if (frames <= 0 || rate <= 0.0) return;
|
||||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||||
const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay,
|
const DragModifiers mods = dragModifiers();
|
||||||
totalSeconds, envClampBounds(), dx,
|
if (mods != dragMods_) {
|
||||||
y - dragStartY_);
|
// 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);
|
unpackEnvelope(overlayEnv_, edited, params_.play);
|
||||||
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
|
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
|
||||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel)
|
#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/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/keyboard_strip.h" // noteName (the one note-naming source)
|
||||||
|
#include "core/instrument/ui/param_taper.h" // DragModifiers (the shared interaction law)
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include "wdltypes.h"
|
#include "wdltypes.h"
|
||||||
@@ -70,6 +71,15 @@ inline std::string sampleLabel(const std::vector<instrument::map::SampleChoice>&
|
|||||||
|
|
||||||
#ifdef _WIN32
|
#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
|
// 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
|
// `KitBox` (component_geometry). This is the single boundary that bridges them so every
|
||||||
// draw routes through the shared kit (theme roles + draw_kit).
|
// draw routes through the shared kit (theme roles + draw_kit).
|
||||||
|
|||||||
@@ -519,6 +519,12 @@ private:
|
|||||||
// delta from this anchor, so a grab never jumps the value.
|
// delta from this anchor, so a grab never jumps the value.
|
||||||
double dragKnobStartValue_ = 0.0;
|
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
|
// 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
|
// 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
|
// mid-drag that closed the popup without cancelling the drag used to leave editedCurve()'s
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <initializer_list>
|
||||||
|
|
||||||
using namespace reasampler::util;
|
using namespace reasampler::util;
|
||||||
|
|
||||||
@@ -107,6 +108,39 @@ static void testMidLevelInverseSaturates() {
|
|||||||
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
|
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// curveLevelAt/curveFromLevelAt is the general form a knot's own (possibly off-centre) phi
|
||||||
|
// needs — curveMidLevel/curveFromMidLevel is the phi = 0.5 case, not a second law.
|
||||||
|
static void testMidLevelIsThePhiHalfSpecialCase() {
|
||||||
|
for (double e : {kCurveMin, 0.3, kCurveNeutral, 2.0, kCurveMax}) {
|
||||||
|
CHECK(curveLevelAt(0.5, e) == curveMidLevel(e));
|
||||||
|
}
|
||||||
|
for (double u : {0.0, 0.2, 0.5, 0.8, 1.0}) {
|
||||||
|
CHECK(curveFromLevelAt(0.5, u) == curveFromMidLevel(u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The round trip must hold at an arbitrary phi, not only 0.5 — this is what a knot whose
|
||||||
|
// integer x lands off its segment's true midpoint (an odd pixel span) actually exercises.
|
||||||
|
static void testLevelAtRoundTripsAtArbitraryPhi() {
|
||||||
|
for (double phi : {0.1, 0.3, 0.42, 0.5, 0.63, 0.9}) {
|
||||||
|
for (int i = 0; i <= 50; ++i) {
|
||||||
|
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 50.0);
|
||||||
|
const double level = curveLevelAt(phi, e);
|
||||||
|
CHECK(level > 0.0 && level < 1.0);
|
||||||
|
CHECK(std::fabs(curveFromLevelAt(phi, level) - e) < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saturation holds at an arbitrary phi too, not only the mid-level special case.
|
||||||
|
static void testLevelAtInverseSaturatesAtArbitraryPhi() {
|
||||||
|
for (double phi : {0.2, 0.5, 0.8}) {
|
||||||
|
CHECK(curveFromLevelAt(phi, 0.0) == kCurveMax);
|
||||||
|
CHECK(curveFromLevelAt(phi, 1.0) == kCurveMin);
|
||||||
|
CHECK(curveFromLevelAt(phi, std::nan("")) == kCurveMax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- The inner dial's travel ---------------------------------------------------
|
// --- The inner dial's travel ---------------------------------------------------
|
||||||
|
|
||||||
// The knob drag delivers `start - dy/kKnobDragRangePixels`. param_slider owns that constant and
|
// The knob drag delivers `start - dy/kKnobDragRangePixels`. param_slider owns that constant and
|
||||||
@@ -184,6 +218,9 @@ int main() {
|
|||||||
testClampCurveHoldsTheDomain();
|
testClampCurveHoldsTheDomain();
|
||||||
testMidLevelRoundTripsAgainstTheExponent();
|
testMidLevelRoundTripsAgainstTheExponent();
|
||||||
testMidLevelInverseSaturates();
|
testMidLevelInverseSaturates();
|
||||||
|
testMidLevelIsThePhiHalfSpecialCase();
|
||||||
|
testLevelAtRoundTripsAtArbitraryPhi();
|
||||||
|
testLevelAtInverseSaturatesAtArbitraryPhi();
|
||||||
testKnobLawIsExactAtTheNeutralCentre();
|
testKnobLawIsExactAtTheNeutralCentre();
|
||||||
testADialSweptThroughNeutralLandsOnTheIdentity();
|
testADialSweptThroughNeutralLandsOnTheIdentity();
|
||||||
testKnobLawRoundTripsOutsideTheDetent();
|
testKnobLawRoundTripsOutsideTheDetent();
|
||||||
|
|||||||
+178
-7
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
#include "../src/core/instrument/ui/deck_values.h"
|
#include "../src/core/instrument/ui/deck_values.h"
|
||||||
|
|
||||||
|
#include "../src/core/instrument/engine/master_gain.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -22,13 +25,31 @@ static std::string msLabel(double seconds) {
|
|||||||
return std::string(buf);
|
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.
|
// a normalized filter position, a bipolar depth, and a curve exponent over its log travel.
|
||||||
static void testNormRoundTripsThroughEveryValueDomain() {
|
static void testNormRoundTripsThroughEveryValueDomain() {
|
||||||
PlaySeconds p;
|
PlaySeconds p;
|
||||||
setDeckParam(DeckParam::kAttack, p, 0.25, 0);
|
setDeckParam(DeckParam::kAttack, p, 0.25, 0);
|
||||||
CHECK(p.adsr.attackSeconds == 0.25 * kEnvTimeMaxSeconds);
|
CHECK(p.adsr.attackSeconds == timeSecondsFromNorm(0.25));
|
||||||
CHECK(deckParamNorm(DeckParam::kAttack, p) == 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);
|
setDeckParam(DeckParam::kSustain, p, 0.4, 0);
|
||||||
CHECK(p.adsr.sustainLevel == 0.4);
|
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
|
// 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
|
// PlaySeconds and COPIED rather than round-tripped, which is what makes the two stage times whose
|
||||||
// neither 0 nor 1 are the cases that actually exercise that exactness (see resetDeckParam's
|
// defaults are neither 0 nor 1 land bit for bit at a non-power-of-two ceiling.
|
||||||
// note on what the seconds ceiling has to be for it to hold).
|
|
||||||
static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
||||||
const PlaySeconds defaults;
|
const PlaySeconds defaults;
|
||||||
PlaySeconds p;
|
PlaySeconds p;
|
||||||
@@ -152,6 +172,151 @@ static void testResetLandsOnTheStoredDefaultOfEachControl() {
|
|||||||
CHECK(p.adsr.releaseSeconds == defaults.adsr.releaseSeconds);
|
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.
|
||||||
|
// Compared against the STORED FIELD directly (deckDoubleField/deckFloatField), not the
|
||||||
|
// normalized read-back: deckParamNorm is not guaranteed injective, so a norm match is weaker
|
||||||
|
// than the criterion — verification against a default-constructed PlaySeconds.
|
||||||
|
static void testEveryKnobIdResetsToItsDefault() {
|
||||||
|
PlaySeconds defaults;
|
||||||
|
for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
|
||||||
|
const DeckParam id = static_cast<DeckParam>(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
|
||||||
|
if (double* pd = deckDoubleField(id, p)) {
|
||||||
|
CHECK(*pd != *deckDoubleField(id, defaults));
|
||||||
|
resetDeckParam(id, p);
|
||||||
|
CHECK(*pd == *deckDoubleField(id, defaults));
|
||||||
|
} else if (float* pf = deckFloatField(id, p)) {
|
||||||
|
CHECK(*pf != *deckFloatField(id, defaults));
|
||||||
|
resetDeckParam(id, p);
|
||||||
|
CHECK(*pf == *deckFloatField(id, defaults));
|
||||||
|
} else {
|
||||||
|
CHECK(false); // every non-None, non-excluded id must own a reset field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 a no-op touch survives the norm the knob would hand back — for THIS value, which is
|
||||||
|
// exactly on the taper's output quantum grid (1.75 s parses to a grid-aligned double). A
|
||||||
|
// legacy value off the grid (e.g. 1.2345678912345) WOULD be re-quantized on first touch;
|
||||||
|
// that is correct, intended behaviour, not a gap this test is claiming to cover.
|
||||||
|
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<float>(n));
|
||||||
|
CHECK(p.filter.settings.resonanceNorm == static_cast<float>(n));
|
||||||
|
CHECK(p.filter.settings.morphNorm == static_cast<float>(n));
|
||||||
|
CHECK(p.filter.settings.driveNorm == static_cast<float>(n));
|
||||||
|
CHECK(deckParamNorm(DeckParam::kFilterCutoff, p) == static_cast<double>(static_cast<float>(n)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One unit, everywhere, across the formatter's whole range: a sub-millisecond value keeps a
|
// 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
|
// decimal rather than reading as a bare zero, and a multi-second one stays in ms rather than
|
||||||
// switching units mid-deck.
|
// switching units mid-deck.
|
||||||
@@ -162,7 +327,7 @@ static void testTimeConstantsAlwaysReadInMilliseconds() {
|
|||||||
CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading
|
CHECK(msLabel(0.012) == "12 ms"); // the use case's own reading
|
||||||
CHECK(msLabel(0.25) == "250 ms");
|
CHECK(msLabel(0.25) == "250 ms");
|
||||||
CHECK(msLabel(1.5) == "1500 ms"); // multi-second, still 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.
|
// The 10 ms hinge belongs to the integer form, not the decimal one.
|
||||||
CHECK(msLabel(0.01) == "10 ms");
|
CHECK(msLabel(0.01) == "10 ms");
|
||||||
CHECK(msLabel(0.0099) == "9.9 ms");
|
CHECK(msLabel(0.0099) == "9.9 ms");
|
||||||
@@ -175,10 +340,16 @@ static void testTimeConstantsAlwaysReadInMilliseconds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
|
testTheTwoCeilingNamesAreOneNumber();
|
||||||
testNormRoundTripsThroughEveryValueDomain();
|
testNormRoundTripsThroughEveryValueDomain();
|
||||||
testResetTouchesOnlyItsOwnRingOnADualRingKnob();
|
testResetTouchesOnlyItsOwnRingOnADualRingKnob();
|
||||||
testInnerResetLandsOnTheExactLinearNeutral();
|
testInnerResetLandsOnTheExactLinearNeutral();
|
||||||
testResetLandsOnTheStoredDefaultOfEachControl();
|
testResetLandsOnTheStoredDefaultOfEachControl();
|
||||||
|
testEveryKnobIdResetsToItsDefault();
|
||||||
|
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||||
|
testShiftSnapsToAWholeUnitOfTheDisplayedValue();
|
||||||
|
testAValueStoredUnderTheOldCeilingIsReadNotRewritten();
|
||||||
|
testTheFilterFourKeepTheirIdentityTaper();
|
||||||
testTimeConstantsAlwaysReadInMilliseconds();
|
testTimeConstantsAlwaysReadInMilliseconds();
|
||||||
if (g_fail) {
|
if (g_fail) {
|
||||||
std::printf("%d FAILURE(S)\n", g_fail);
|
std::printf("%d FAILURE(S)\n", g_fail);
|
||||||
|
|||||||
+175
-23
@@ -5,11 +5,12 @@
|
|||||||
//
|
//
|
||||||
// Covers: nodeAtPoint (every drawn handle grabbable, the anchored ReleaseEnd and the Origin
|
// 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
|
// never grabbed, other-kind nodes rejected, misses outside the radius, a dead coincident AHD
|
||||||
// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag
|
// DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag (AHDSR stage nodes
|
||||||
// (AHDSR stage times at the schematic scale, the sustain level on Y, the release dragged from
|
// tracking the cursor across the TAPERED schematic and being its exact inverse, the sustain level
|
||||||
// its START with the inverted sign, the caller's clamp domain, AHD stage times at the 1:1
|
// on Y, the release dragged from its START with the inverted sign, the caller's clamp domain, AHD
|
||||||
// scale, the hold FRACTION); curve-knot drags (the exponent domain, its endpoints, and the
|
// stage times at the 1:1 scale, the hold FRACTION); curve-knot drags (the exponent domain, its
|
||||||
// round trip through the shared law that keeps knot and dial on one value); degenerate no-ops.
|
// 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"
|
#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 Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100
|
||||||
static constexpr double kTotal = 4.0;
|
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() {
|
static EnvClampBounds bounds() {
|
||||||
EnvClampBounds b;
|
EnvClampBounds b;
|
||||||
b.maxAttackSeconds = 2.0;
|
b.maxAttackSeconds = kGateStageMaxSeconds;
|
||||||
b.maxHoldSeconds = 2.0;
|
b.maxHoldSeconds = kGateStageMaxSeconds;
|
||||||
b.maxDecaySeconds = 2.0;
|
b.maxDecaySeconds = kGateStageMaxSeconds;
|
||||||
b.maxReleaseSeconds = 2.0;
|
b.maxReleaseSeconds = kGateStageMaxSeconds;
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,23 +163,64 @@ static void testMissOutsideTheRadius() {
|
|||||||
|
|
||||||
// --- AHDSR drags ---------------------------------------------------------------
|
// --- 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 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 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 =
|
const StageEnvelope hold =
|
||||||
resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -20, 0);
|
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 =
|
const StageEnvelope decay =
|
||||||
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 30, 0);
|
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
|
// 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() {
|
static void testReleaseDragsFromItsStartWithInvertedSign() {
|
||||||
const Rect a = wideArea();
|
const Rect a = wideArea();
|
||||||
const StageEnvelope e = ahdsrEnv();
|
const StageEnvelope e = ahdsrEnv();
|
||||||
const double secPerPx = 1.0 / gatePxPerSecond(a);
|
|
||||||
const StageEnvelope longer =
|
const StageEnvelope longer =
|
||||||
resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), -40, 0);
|
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 =
|
const StageEnvelope shorter =
|
||||||
resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), 40, 0);
|
resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), 40, 0);
|
||||||
CHECK(shorter.releaseSeconds < e.releaseSeconds);
|
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() {
|
static void testSustainLevelOnTheDecayNodesYAxis() {
|
||||||
@@ -327,6 +373,107 @@ static void testKnotOnANearLevelSegmentIsANoOp() {
|
|||||||
CHECK(out.decayCurve == 2.5);
|
CHECK(out.decayCurve == 2.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The knot drag must read the SAME phi the draw used even off the segment midpoint (an odd
|
||||||
|
// pixel span), not the fixed phi = 0.5 wideArea()'s AttackCurve span happens to land on above.
|
||||||
|
// Checked two ways: a zero-delta grab reproduces the stored exponent, and a real one-pixel drag
|
||||||
|
// moves the knot's own drawn y by the same one pixel every other node axis tracks 1:1.
|
||||||
|
static void testKnotDragTracksTheDrawOnAnOddPixelSpan() {
|
||||||
|
bool found = false;
|
||||||
|
for (int width = 24; width <= 260 && !found; ++width) {
|
||||||
|
const Rect a = Rect::ltrb(0, 0, width, 100);
|
||||||
|
StageEnvelope e = ahdsrEnv();
|
||||||
|
e.attackCurve = 3.0;
|
||||||
|
EnvVertex origin, attackEnd, knot;
|
||||||
|
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), kTotal);
|
||||||
|
if (!findNode(poly, EnvNode::Origin, origin)) continue;
|
||||||
|
if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue;
|
||||||
|
if (!findNode(poly, EnvNode::AttackCurve, knot)) continue;
|
||||||
|
const int span = attackEnd.x - origin.x;
|
||||||
|
if (span <= 0 || span % 2 == 0) continue;
|
||||||
|
found = true;
|
||||||
|
|
||||||
|
const StageEnvelope same =
|
||||||
|
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 0);
|
||||||
|
CHECK(std::fabs(same.attackCurve - e.attackCurve) < 1e-9);
|
||||||
|
|
||||||
|
const StageEnvelope dragged =
|
||||||
|
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 1);
|
||||||
|
EnvVertex knotAfter;
|
||||||
|
CHECK(findNode(buildEnvelopePolyline(dragged, overlayOf(a), kTotal), EnvNode::AttackCurve,
|
||||||
|
knotAfter));
|
||||||
|
CHECK(knotAfter.x == knot.x); // a curve drag never moves the knot's x
|
||||||
|
CHECK(std::abs(knotAfter.y - (knot.y + 1)) <= 1);
|
||||||
|
}
|
||||||
|
CHECK(found); // the sweep must actually land on an odd span
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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<int>(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 ----------------------------------------------------------------
|
// --- degenerate ----------------------------------------------------------------
|
||||||
|
|
||||||
static void testDegenerateInputsAreNoOps() {
|
static void testDegenerateInputsAreNoOps() {
|
||||||
@@ -347,7 +494,8 @@ int main() {
|
|||||||
testFunctionalCoincidentDecayEndStaysGrabbable();
|
testFunctionalCoincidentDecayEndStaysGrabbable();
|
||||||
testMissOutsideTheRadius();
|
testMissOutsideTheRadius();
|
||||||
|
|
||||||
testAhdsrStageTimesTrackTheSchematicScale();
|
testAhdsrStageNodesTrackTheCursorAcrossTheWholeRange();
|
||||||
|
testDrawAndDragAreExactInverses();
|
||||||
testReleaseDragsFromItsStartWithInvertedSign();
|
testReleaseDragsFromItsStartWithInvertedSign();
|
||||||
testSustainLevelOnTheDecayNodesYAxis();
|
testSustainLevelOnTheDecayNodesYAxis();
|
||||||
testStageTimesClampToTheKnobDomain();
|
testStageTimesClampToTheKnobDomain();
|
||||||
@@ -355,10 +503,14 @@ int main() {
|
|||||||
testAhdStageTimesTrackTheWallClockScale();
|
testAhdStageTimesTrackTheWallClockScale();
|
||||||
testAhdHoldNodeEditsTheFraction();
|
testAhdHoldNodeEditsTheFraction();
|
||||||
|
|
||||||
|
testCtrlScalesEveryAxisOfANodeDrag();
|
||||||
|
testShiftSnapsEachAxisToItsOwnWholeUnit();
|
||||||
|
|
||||||
testKnotDragMovesTheExponentWithinItsDomain();
|
testKnotDragMovesTheExponentWithinItsDomain();
|
||||||
testKnotAndModelCannotDiverge();
|
testKnotAndModelCannotDiverge();
|
||||||
testKnotOnALevelSegmentIsANoOp();
|
testKnotOnALevelSegmentIsANoOp();
|
||||||
testKnotOnANearLevelSegmentIsANoOp();
|
testKnotOnANearLevelSegmentIsANoOp();
|
||||||
|
testKnotDragTracksTheDrawOnAnOddPixelSpan();
|
||||||
|
|
||||||
testDegenerateInputsAreNoOps();
|
testDegenerateInputsAreNoOps();
|
||||||
|
|
||||||
|
|||||||
+154
-30
@@ -4,15 +4,20 @@
|
|||||||
// RIGHT-ANCHORED release, and the sustain-less AHD laid 1:1 over the waveform's time axis.
|
// 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
|
// 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
|
// overflow on huge times, degenerate area/duration); gateStageSlotPx; the AHDSR polyline (node
|
||||||
// order, levels, release anchored at the right edge, the sustain plateau reaching the edge at
|
// order, levels, the TAPERED stage placement and its legibility at both ends of the range,
|
||||||
// zero release, per-segment separation at the tier-0 defaults, overrun compression, every
|
// release anchored at the right edge, the sustain plateau reaching the edge at zero release,
|
||||||
// vertex in-bounds); splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the
|
// per-segment separation at the tier-0 defaults, overrun compression, every vertex in-bounds);
|
||||||
// AHD polyline (1:1 with the time axis, origin offset); curve knots (present only on sloped
|
// splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the AHD polyline (1:1 with
|
||||||
// non-zero segments, height following the exponent); the degenerate flat baseline.
|
// the time axis, origin offset); curve knots (present only on sloped non-zero segments, height
|
||||||
|
// following the exponent, and — swept across ODD and EVEN pixel spans, not one fixture's width —
|
||||||
|
// sitting on the curve its own vertices imply rather than always the segment's exact midpoint);
|
||||||
|
// the degenerate flat baseline.
|
||||||
|
|
||||||
#include "../src/core/instrument/ui/envelope_overlay.h"
|
#include "../src/core/instrument/ui/envelope_overlay.h"
|
||||||
|
|
||||||
|
#include "../src/core/instrument/ui/sample_bands.h" // the editor floor the legibility test uses
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -94,20 +99,19 @@ static void testDegenerateAreaAndDuration() {
|
|||||||
CHECK(timeToX(Rect{}, 2.0, 1.0) == 0);
|
CHECK(timeToX(Rect{}, 2.0, 1.0) == 0);
|
||||||
CHECK(timeToX(wideArea(), 0.0, 1.0) == wideArea().x);
|
CHECK(timeToX(wideArea(), 0.0, 1.0) == wideArea().x);
|
||||||
CHECK(levelToY(Rect{}, 0.5) == 0);
|
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
|
// The literal slot width, independent of any sample duration: usable px = canvas width minus the
|
||||||
// minus the last column minus 4 node-separation bases, spread over 4 x kGateStageMaxSeconds.
|
// last column minus 4 node-separation bases, split four ways. A stage then occupies its own
|
||||||
// This is what makes a dragged handle track the cursor 1:1 (envelope_edit's own inverse reads
|
// TAPERED fraction of that slot, which is what makes a dragged handle track the cursor at both
|
||||||
// this same function) — a scale regression here is exactly what a relational-only check misses.
|
// ends of the range — a scale regression here is exactly what a relational-only check misses.
|
||||||
static void testGatePxPerSecond() {
|
static void testGateStageSlotPx() {
|
||||||
// 967 / 8 px/s, pinned as a literal — restating the formula with the same named constants
|
// 967 / 4 px, 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
|
// would let a change to kGateNodeSepPx move both sides and pass silently.
|
||||||
// silently.
|
CHECK(gateStageSlotPx(wideArea()) == 241.75);
|
||||||
CHECK(gatePxPerSecond(wideArea()) == 120.875);
|
CHECK(gateStageSlotPx(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0
|
||||||
CHECK(gatePxPerSecond(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
|
||||||
CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the AHDSR schematic ------------------------------------------------------
|
// --- the AHDSR schematic ------------------------------------------------------
|
||||||
@@ -135,24 +139,52 @@ static void testAhdsrNodeOrderAndLevels() {
|
|||||||
CHECK(v.x == a.right() - 1); // ANCHORED, whatever the release is
|
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
|
// The literal per-node x placement, hand-derived from the documented formula (slot = 241.75 px
|
||||||
// px/s per testGatePxPerSecond; each timed stage is prefixed by the kGateNodeSepPx=8 base):
|
// per testGateStageSlotPx; each timed stage is prefixed by the kGateNodeSepPx=8 base and occupies
|
||||||
// attack .2s -> raw 8+24.175=32.175 -> px 32; hold .1s -> raw 32.175+8+12.0875=52.2625 -> px 52;
|
// slot x timeNormFromSeconds(t) of its own slot; L = ln(1 + 10/0.003) = 8.112028):
|
||||||
// decay .3s -> raw 52.2625+8+36.2625=96.525 -> px 97; plateau -> raw 999-8-48.35=942.65 -> px
|
// attack .25s -> norm ln(84.3333)/L = 0.546677 -> 8 + 132.159 = 140.159 -> px 140
|
||||||
// 943; release end pinned at the last column, 999. A literal regression pin — no relational or
|
// hold .05s -> norm ln(17.6667)/L = 0.354007 -> 140.159 + 8 + 85.581 = 233.740 -> px 234
|
||||||
// bounds-only check catches a formula-shape change the way an exact pixel count does.
|
// 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() {
|
static void testAhdsrSchematicPlacement() {
|
||||||
const Rect a = wideArea();
|
const Rect a = wideArea();
|
||||||
const std::vector<EnvVertex> poly =
|
const std::vector<EnvVertex> 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;
|
EnvVertex v;
|
||||||
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 32);
|
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 140);
|
||||||
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 52);
|
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 234);
|
||||||
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 97);
|
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 394);
|
||||||
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 943);
|
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 818);
|
||||||
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 999);
|
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<EnvVertex> 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<EnvVertex> 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<int>(kGateNodeSepPx + slot + 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
// The AHDSR schematic is scaled by the PARAM domain, NOT the capture length: the same params
|
// 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
|
// 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).
|
// totalSeconds — only the sustain-less AHD's x-axis is wall-clock/PCM-aligned).
|
||||||
@@ -392,6 +424,95 @@ static void testKnotHeightTracksTheExponent() {
|
|||||||
CHECK(steep.y >= a.y && steep.y <= a.bottom() - 1);
|
CHECK(steep.y >= a.y && steep.y <= a.bottom() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the knot sits ON its own curve (the reported defect, stated as the gate) -------------
|
||||||
|
|
||||||
|
// The general (non-truncated-phi) reading of a knot's level, computed from the vertices
|
||||||
|
// `buildEnvelopePolyline` actually returned — x0/x1/knotX are all int pixels a caller can read
|
||||||
|
// off the polyline, so this is a check ON the output, not a restatement of knotVtx's own
|
||||||
|
// formula. x0 == x1 has no interior (no knot is ever built there).
|
||||||
|
static double expectedKnotLevel(int x0, int x1, int knotX, double startLevel, double endLevel,
|
||||||
|
double exponent) {
|
||||||
|
const double phi = (x1 != x0)
|
||||||
|
? static_cast<double>(knotX - x0) / static_cast<double>(x1 - x0)
|
||||||
|
: 0.5;
|
||||||
|
return startLevel + (endLevel - startLevel) * reasampler::util::curveMap(phi, exponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reported defect, stated as the gate: at every exponent the knot's centre lies on the
|
||||||
|
// trace, within 1 px. Swept over a range of canvas widths (down to a few pixels of stage span)
|
||||||
|
// so the check actually exercises ODD pixel spans, where the segment's true midpoint falls
|
||||||
|
// between two pixels — testKnotHeightTracksTheExponent above sits at a width whose span happens
|
||||||
|
// to be even, which is exactly the kind of fixture that missed this defect.
|
||||||
|
static void testKnotSitsOnItsOwnCurveAcrossOddAndEvenSpans() {
|
||||||
|
bool sawOdd = false, sawEven = false;
|
||||||
|
int worstAhdsr = 0, worstAhd = 0;
|
||||||
|
for (int width = 24; width <= 260; width += 3) {
|
||||||
|
const Rect a = Rect::ltrb(0, 0, width, 100);
|
||||||
|
for (double exp : {util::kCurveMin, 0.3, 1.0, 3.0, util::kCurveMax}) {
|
||||||
|
StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0);
|
||||||
|
e.attackCurve = exp;
|
||||||
|
EnvVertex origin, attackEnd, knot;
|
||||||
|
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), 4.0);
|
||||||
|
if (findNode(poly, EnvNode::Origin, origin) &&
|
||||||
|
findNode(poly, EnvNode::AttackEnd, attackEnd) &&
|
||||||
|
findNode(poly, EnvNode::AttackCurve, knot)) {
|
||||||
|
const int span = attackEnd.x - origin.x;
|
||||||
|
if (span > 0) {
|
||||||
|
if (span % 2 == 0) sawEven = true; else sawOdd = true;
|
||||||
|
const double expected =
|
||||||
|
expectedKnotLevel(origin.x, attackEnd.x, knot.x, 0.0, 1.0, exp);
|
||||||
|
const int expectedY = levelToY(a, expected);
|
||||||
|
worstAhdsr = (std::max)(worstAhdsr, std::abs(knot.y - expectedY));
|
||||||
|
CHECK(std::abs(knot.y - expectedY) <= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StageEnvelope f = ahd(0.4, 0.6, 0.5, 0.0, 3.0);
|
||||||
|
f.attackCurve = exp;
|
||||||
|
EnvVertex originAhd, attackEndAhd, knotAhd;
|
||||||
|
const std::vector<EnvVertex> polyAhd = buildEnvelopePolyline(f, overlayOf(a), 4.0);
|
||||||
|
if (findNode(polyAhd, EnvNode::Origin, originAhd) &&
|
||||||
|
findNode(polyAhd, EnvNode::AttackEnd, attackEndAhd) &&
|
||||||
|
findNode(polyAhd, EnvNode::AttackCurve, knotAhd)) {
|
||||||
|
const int span = attackEndAhd.x - originAhd.x;
|
||||||
|
if (span > 0) {
|
||||||
|
if (span % 2 == 0) sawEven = true; else sawOdd = true;
|
||||||
|
const double expected = expectedKnotLevel(originAhd.x, attackEndAhd.x,
|
||||||
|
knotAhd.x, 0.0, 1.0, exp);
|
||||||
|
const int expectedY = levelToY(a, expected);
|
||||||
|
worstAhd = (std::max)(worstAhd, std::abs(knotAhd.y - expectedY));
|
||||||
|
CHECK(std::abs(knotAhd.y - expectedY) <= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CHECK(sawOdd); // the sweep actually exercised an odd-pixel span...
|
||||||
|
CHECK(sawEven); // ...and an even one, so this isn't resting on one fixture's luck.
|
||||||
|
std::printf(" worst knot/curve separation: AHDSR %d px, AHD %d px\n", worstAhdsr, worstAhd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponent 1.0 is still a plain straight line even off the segment's exact midpoint — checked
|
||||||
|
// at a deliberately ODD span so the linear case isn't only proven at the symmetric one.
|
||||||
|
static void testNeutralExponentIsAStraightLineOffCentre() {
|
||||||
|
bool found = false;
|
||||||
|
for (int width = 24; width <= 200 && !found; ++width) {
|
||||||
|
const Rect a = Rect::ltrb(0, 0, width, 100);
|
||||||
|
StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0);
|
||||||
|
e.attackCurve = util::kCurveNeutral;
|
||||||
|
EnvVertex origin, attackEnd, knot;
|
||||||
|
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), 4.0);
|
||||||
|
if (!findNode(poly, EnvNode::Origin, origin)) continue;
|
||||||
|
if (!findNode(poly, EnvNode::AttackEnd, attackEnd)) continue;
|
||||||
|
if (!findNode(poly, EnvNode::AttackCurve, knot)) continue;
|
||||||
|
const int span = attackEnd.x - origin.x;
|
||||||
|
if (span <= 0 || span % 2 == 0) continue;
|
||||||
|
found = true;
|
||||||
|
const double phi = static_cast<double>(knot.x - origin.x) / static_cast<double>(span);
|
||||||
|
CHECK(std::fabs(knot.level - phi) < 1e-12); // linear: level == phi, exactly
|
||||||
|
}
|
||||||
|
CHECK(found); // the sweep must actually land on an odd span
|
||||||
|
}
|
||||||
|
|
||||||
// --- degenerate ---------------------------------------------------------------
|
// --- degenerate ---------------------------------------------------------------
|
||||||
|
|
||||||
static void testDegenerateSurfaceYieldsFlatBaseline() {
|
static void testDegenerateSurfaceYieldsFlatBaseline() {
|
||||||
@@ -409,10 +530,11 @@ int main() {
|
|||||||
testTimeToXClampsBothEnds();
|
testTimeToXClampsBothEnds();
|
||||||
testLevelToY();
|
testLevelToY();
|
||||||
testDegenerateAreaAndDuration();
|
testDegenerateAreaAndDuration();
|
||||||
testGatePxPerSecond();
|
testGateStageSlotPx();
|
||||||
|
|
||||||
testAhdsrNodeOrderAndLevels();
|
testAhdsrNodeOrderAndLevels();
|
||||||
testAhdsrSchematicPlacement();
|
testAhdsrSchematicPlacement();
|
||||||
|
testTaperedAxisKeepsBothEndsOfTheRangeLegible();
|
||||||
testGateLayoutIndependentOfSampleDuration();
|
testGateLayoutIndependentOfSampleDuration();
|
||||||
testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
|
testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
|
||||||
testReleaseGrowsLeftwardFromTheAnchor();
|
testReleaseGrowsLeftwardFromTheAnchor();
|
||||||
@@ -427,6 +549,8 @@ int main() {
|
|||||||
|
|
||||||
testKnotsRideOnlySlopedNonZeroSegments();
|
testKnotsRideOnlySlopedNonZeroSegments();
|
||||||
testKnotHeightTracksTheExponent();
|
testKnotHeightTracksTheExponent();
|
||||||
|
testKnotSitsOnItsOwnCurveAcrossOddAndEvenSpans();
|
||||||
|
testNeutralExponentIsAStraightLineOffCentre();
|
||||||
|
|
||||||
testDegenerateSurfaceYieldsFlatBaseline();
|
testDegenerateSurfaceYieldsFlatBaseline();
|
||||||
|
|
||||||
|
|||||||
@@ -245,22 +245,35 @@ static void testKnobNeedlePointOnCircle() {
|
|||||||
|
|
||||||
static void testKnobDragUpIncreases() {
|
static void testKnobDragUpIncreases() {
|
||||||
// Up (negative dy) increases, down decreases, scaled by the drag range.
|
// 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.75));
|
||||||
CHECK(approx(knobDragValue(0.5, +32, 128), 0.25));
|
CHECK(approx(knobDragValue(0.5, +32, {}, 128), 0.25));
|
||||||
// A full-range upward drag from 0 lands exactly at 1.
|
// 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.
|
// Default sensitivity applies when the range is omitted.
|
||||||
CHECK(approx(knobDragValue(0.0, -kKnobDragRangePixels), 1.0));
|
CHECK(approx(knobDragValue(0.0, -kKnobDragRangePixels), 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testKnobDragClamps() {
|
static void testKnobDragClamps() {
|
||||||
CHECK(approx(knobDragValue(0.9, -64, 128), 1.0)); // over-drag up clamps at 1
|
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.1, +64, {}, 128), 0.0)); // over-drag down clamps at 0
|
||||||
// The start value itself is clamped before the delta applies.
|
// The start value itself is clamped before the delta applies.
|
||||||
CHECK(approx(knobDragValue(1.5, 0, 128), 1.0));
|
CHECK(approx(knobDragValue(1.5, 0, {}, 128), 1.0));
|
||||||
CHECK(approx(knobDragValue(-0.5, 0, 128), 0.0));
|
CHECK(approx(knobDragValue(-0.5, 0, {}, 128), 0.0));
|
||||||
// A degenerate drag range yields the clamped start value.
|
// 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 ---------------------------------------------------
|
// --- controlAtPoint routing ---------------------------------------------------
|
||||||
@@ -321,6 +334,7 @@ int main() {
|
|||||||
testKnobNeedlePointOnCircle();
|
testKnobNeedlePointOnCircle();
|
||||||
testKnobDragUpIncreases();
|
testKnobDragUpIncreases();
|
||||||
testKnobDragClamps();
|
testKnobDragClamps();
|
||||||
|
testCtrlScalesTheDragRateAndShiftOverridesIt();
|
||||||
testControlAtPointRoutes();
|
testControlAtPointRoutes();
|
||||||
testControlAtPointMisses();
|
testControlAtPointMisses();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
// 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 "../src/core/instrument/ui/envelope_overlay.h" // gateStageSlotPx: finest drag surface
|
||||||
|
#include "../src/core/instrument/ui/sample_bands.h" // kEditorMinWidth/kPad: the editor floor
|
||||||
|
|
||||||
|
#include <cfenv>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
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<double>(i) / 200000.0);
|
||||||
|
CHECK(v >= prev);
|
||||||
|
if (v < prev) return; // one report is enough
|
||||||
|
prev = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FINEST drag a user can make on ANY surface this taper serves — not the knob's own 128 px
|
||||||
|
// travel, which is coarser than the AHDSR schematic's node drag at the editor floor. Derived from
|
||||||
|
// the floor constant and the overlay's own slot-width formula, so a later floor change sharpens
|
||||||
|
// (or coarsens) the step this test exercises automatically instead of leaving a copied number
|
||||||
|
// silently stale.
|
||||||
|
static void testEveryFinestDragStepMovesTheValue() {
|
||||||
|
const Rect floorArea = Rect::ltrb(0, 0, kEditorMinWidth - 2 * kPad, 100);
|
||||||
|
const double slot = gateStageSlotPx(floorArea);
|
||||||
|
const int steps = static_cast<int>(slot / kFineDragScale);
|
||||||
|
for (int i = 0; i < steps; ++i) {
|
||||||
|
const double lo = timeSecondsFromNorm(static_cast<double>(i) / steps);
|
||||||
|
const double hi = timeSecondsFromNorm(static_cast<double>(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<double>(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<double>(ms) / 1e3;
|
||||||
|
CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds);
|
||||||
|
if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MODE-INDEPENDENCE, the whole point of resolveTo's std::round over std::nearbyint. First shows
|
||||||
|
// the defect directly, generically: under round-toward-zero, the RETIRED std::nearbyint reads
|
||||||
|
// that mode and truncates a value whose fraction is well past half, while std::round (specified
|
||||||
|
// to round half-away-from-zero REGARDLESS of the current mode) does not. Then proves the
|
||||||
|
// production round trip itself — not a stand-in — survives the same hostile mode across the grid.
|
||||||
|
static void testRoundingSurvivesAHostileFpRoundingMode() {
|
||||||
|
const int saved = std::fegetround();
|
||||||
|
CHECK(std::fesetround(FE_TOWARDZERO) == 0);
|
||||||
|
|
||||||
|
CHECK(std::nearbyint(12.9) == 12.0); // the RETIRED behaviour: mode-dependent, wrong here
|
||||||
|
CHECK(std::round(12.9) == 13.0); // the fix: mode-independent, rounds to nearest
|
||||||
|
|
||||||
|
for (int us = 0; us <= 200000; us += 7) {
|
||||||
|
const double seconds = static_cast<double>(us) / 1e6;
|
||||||
|
CHECK(timeSecondsFromNorm(timeNormFromSeconds(seconds)) == seconds);
|
||||||
|
if (timeSecondsFromNorm(timeNormFromSeconds(seconds)) != seconds) break;
|
||||||
|
}
|
||||||
|
for (int milli = -24000; milli <= 24000; milli += 37) {
|
||||||
|
const double d = static_cast<double>(milli) / 1000.0;
|
||||||
|
CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d);
|
||||||
|
if (depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) != d) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fesetround(saved); // restore — every other test in this binary assumes the default
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<double>(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<double>(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<double>(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<double>(st);
|
||||||
|
CHECK(depthSemitonesFromNorm(depthNormFromSemitones(d, kDepth), kDepth) == d);
|
||||||
|
}
|
||||||
|
for (int milli = -24000; milli <= 24000; milli += 37) {
|
||||||
|
const double d = static_cast<double>(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();
|
||||||
|
testRoundingSurvivesAHostileFpRoundingMode();
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user