instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes

Trigger's fade pair folds into the AHD (and goes live); the release anchors right;
Preserve rings its synthetic tail out instead of cutting it. Payload v10.
This commit is contained in:
2026-07-31 08:37:57 -04:00
parent 87d7ceb066
commit 13e8c5c4d9
51 changed files with 3406 additions and 1812 deletions
+52 -33
View File
@@ -9,8 +9,8 @@ subdirectories:
velocity curve, and master-gain taper math.
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
`ComponentState` codec, and the small pure helpers the engine/shell share
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
frame↔fraction conversion).
(bank-generation sync, bridge-read marshalling, note-name parsing, the Trigger
play-span formula).
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
resolution, and anchored start/end offsets — the one record and resolver a
capture-signal popup and the offline bake read from, so they cannot diverge.
@@ -120,13 +120,17 @@ pitch envelope/curve (AD?) which is off by default."*
note-off, `level→0` over release. `holdFrames == 0` is exactly the pre-Gate ADSR — a
back-compat degenerate.
- **Trigger — one-shot drum-pad.** Note-on fires playback of a defined `%` of sample
length with a fade-in and fade-out ramp; note-off is ignored (the voice plays through,
no sustain loop). Frame span `[startFrame, playEnd)` where `playEnd = startFrame +
round(lengthFraction·(frames startFrame))`; amplitude ramps `0→1` over
`fadeInFrames` at the head and `1→0` over `fadeOutFrames` anchored to `playEnd`; fades
clamp so `fadeInFrames + fadeOutFrames ≤ play length`. Fade curve is equal-power
(constant-power sin/cos). **Note-off in Trigger is a no-op** — choke-on-note-off is
held/out of scope (fork S15-F1).
length; note-off is ignored (the voice plays through, no sustain loop). Frame span
`[startFrame, playEnd)` where `playEnd = startFrame +
round(lengthFraction·(frames startFrame))`. The amplitude over that span is the staged
**AHD** (below), not a fade pair. **Note-off in Trigger is a no-op** — choke-on-note-off
is held/out of scope (fork S15-F1).
> **Superseded, do not reintroduce:** Trigger's amplitude was once a fade-in/unity/
> fade-out shape with its own equal-power curve and its own `fadeInFrames`/`fadeOutFrames`
> pair, clamped so the two fades fit the span. That is retired — one staged-envelope
> design now covers what were two mechanisms. A saved instance's fades lift onto the AHD
> at the codec boundary (attack ← fade-in, decay ← fade-out, hold ← the remainder).
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
@@ -138,12 +142,12 @@ pitch envelope/curve (AD?) which is off by default."*
Contract for Gate's sustain loop under Preserve: *loop the source, shift the output*
(loop points stay source-frame facts). `WDL_Resampler` is **not** a Preserve engine (it
is a resampler that couples duration) — never wire it as the duration-preserving path.
- **Pitch envelope — AD, off by default.** A short attack-decay pitch-offset curve
(`peakSemitones` over `attackFrames`, decaying to 0 over `decayFrames`) riding on top of
whichever pitch engine; a zero attack gives a pure percussive pitch drop. **Off by
default** — a regression that applies pitch modulation when the envelope is disabled is
a bug. Under Varispeed the offset is a per-frame multiply of `ratio_`; under Preserve it
is added to the shifter's shift amount.
- **Pitch envelope — AHD, off by default.** A pitch-offset curve rising to `peakSemitones`
over attack, holding, then decaying to 0, riding on top of whichever pitch engine; a zero
attack gives a pure percussive pitch drop. **Off by default** — a regression that applies
pitch modulation when the envelope is disabled is a bug. Its hold fraction defaults to 0,
which is exactly the attack-decay shape it grew out of. Under Varispeed the offset is a
per-frame multiply of `ratio_`; under Preserve it is added to the shifter's shift amount.
- **Preserve RT discipline.** The shifter pre-warms at voice-allocation; no allocation in
`process()` in steady state. **Note (supersedes an earlier framing):** the
shifter's onset latency (~25 ms, half-window) was once described as "an
@@ -201,19 +205,33 @@ automatable parameters."* It rejects the precedent, not one instance of it.
- **Do not spec Tier 2/3** from this directory. Tier 2 is held, Tier 3 is
optional-forever; don't let their feature lists drive Tier 01's build shape.
### Envelope overlay + draggable nodes (S-VIEW, settled 2026-07-27, landed)
### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)
The amp envelope is drawn as a curve over the Sample view's hero waveform at the shared
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
one model: both read/write the same envelope fields of the one parameter set, so a drag
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
same per-param min/max the sliders enforce, so node-drag can never produce a param the
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
`envelope_overlay` and `envelope_edit` in Modules below.
The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a
transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at
most one is overlay-active, and **none is a valid resting state — the editor opens there.**
Never persisted; it selects what is drawn, not what is played.
**The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round
mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a
knot drag and the deck knobs are surfaces onto ONE model: all three read/write the same
fields of the one parameter set, so an edit on any of them re-lays the others — one source
of truth, structural (re-read-every-paint), never a listener chain. Every drag is
range-clamped to the same per-param min/max the knobs enforce, so no drag can produce a
param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps —
see `envelope_overlay` and `envelope_edit` in Modules below.
**Which shape an envelope takes is decided by the play mode, not by what it modulates:**
pitch is always AHD; amp and filter are AHDSR in Gate and AHD in Trigger. Both mode shapes
are STORED per envelope, so flipping modes cannot lose either mode's dialled values (the
migration case forces it: an old instance carries both its AHDSR values and its Trigger
fades, and one shared set could not preserve both modes' prior sound).
**And which LAYOUT an envelope takes follows from whether it has a sustain stage** — the
same rule, applied once: an AHDSR right-anchors its release (the end point is fixed at the
canvas edge and release is dragged from its top node), a sustain-less AHD maps 1:1 onto the
waveform's time axis. The two policies coexist rather than merge; the 1:1 mapping only means
anything for a trigger shape.
### Parameter ownership and persistence (D-B)
@@ -235,7 +253,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class. `AdsrEnvelope`/`PitchEnvelope` also own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the two level steps φ cannot cover.
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
@@ -247,9 +265,10 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
- `trigger_seam` — the shared Trigger play-SPAN formula: how the stored %-length becomes the source-frame span the voice plays and the overlay draws over, threading `startFrame` correctly. (Its fade frames↔fraction converters retired with the fade pair itself.)
### `ui/`
@@ -266,13 +285,13 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
- `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.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above); 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 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.
- `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 amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`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 (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
## Gotchas
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
- **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 AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
@@ -40,3 +40,7 @@ reasampler_test(sampler_filter LINK sampler_core)
# Live delivery is the third integration seam over the same engine: what a published block
# does to a voice that is already sounding, and what it must leave alone.
reasampler_test(live_delivery LINK sampler_core)
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
# both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core)
+166 -110
View File
@@ -1,6 +1,6 @@
#pragma once
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, Trigger fade
// shape, AD pitch offset). Concrete classes, every body defined in-class: these are called
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, sustain-less AHD,
// AHD pitch offset). Concrete classes, every body defined in-class: these are called
// per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop.
// NEVER give them a common base or a virtual tick() — that vtable lands on the hottest
// inner loop in the program (root CLAUDE.md, structural heuristic 3).
@@ -9,9 +9,62 @@
#include <cstdint>
#include "core/instrument/engine/play_params.h"
#include "core/util/curve_law.h"
namespace reasampler {
using util::curveMap;
// The A/H/D split of a bounded span, in frames.
struct AhdSpan {
std::int64_t attack = 0;
std::int64_t hold = 0;
std::int64_t decay = 0;
std::int64_t total = 0; // attack + hold + decay; <= span by construction
};
// THE span split, shared by every sustain-less envelope so they cannot disagree about where a
// stage boundary is. Attack takes at most the whole span and Decay at most what Attack left,
// so `remaining` is non-negative without a clamp; Hold then takes its FRACTION of that
// remainder, which is why total <= span holds for every (attack, decay, fraction) triple and
// there is no sum to clamp. The two per-stage mins reproduce the retired Trigger fade clamp
// exactly (head first, tail into what is left), so a migrated instance keeps its stage lengths.
inline AhdSpan fitAhd(std::int64_t spanFrames, const AhdParams& p) {
AhdSpan out;
const std::int64_t span = spanFrames > 0 ? spanFrames : 0;
std::int64_t a = p.attackFrames > 0 ? p.attackFrames : 0;
if (a > span) a = span;
std::int64_t d = p.decayFrames > 0 ? p.decayFrames : 0;
if (d > span - a) d = span - a;
const std::int64_t remaining = span - a - d;
double frac = p.holdFraction;
if (!(frac > 0.0)) frac = 0.0; // also catches NaN
if (frac > 1.0) frac = 1.0;
out.attack = a;
out.decay = d;
out.hold = static_cast<std::int64_t>(static_cast<double>(remaining) * frac + 0.5);
out.total = out.attack + out.hold + out.decay;
return out;
}
// The AHD's normalized level at `offset` frames into the span: 0 -> 1 over attack, flat 1
// across hold, 1 -> 0 over decay, 0 outside. Pure over the offset so both the ticking pitch
// envelope and the positional amplitude one read one shape.
inline double ahdLevelAt(double offset, const AhdSpan& s, double attackCurve,
double decayCurve) {
if (offset < 0.0 || offset >= static_cast<double>(s.total)) return 0.0;
if (s.attack > 0 && offset < static_cast<double>(s.attack)) {
return curveMap(offset / static_cast<double>(s.attack), attackCurve);
}
const double decayStart = static_cast<double>(s.total - s.decay);
if (s.decay > 0 && offset >= decayStart) {
double t = (offset - decayStart) / static_cast<double>(s.decay);
if (t > 1.0) t = 1.0;
return 1.0 - curveMap(t, decayCurve);
}
return 1.0;
}
// Absorbs a step a live parameter move would otherwise put straight into an evaluator's
// output, as an offset that decays to EXACTLY zero — so the at-rest path carries no residue
// and the smoother's own branch stays predictably false. Per-frame decay rather than a
@@ -152,8 +205,9 @@ private:
switch (stage_) {
case Stage::Attack: {
if (params.attackFrames <= 0) return 1.0;
const double l = stagePos_ / static_cast<double>(params.attackFrames);
return l > 1.0 ? 1.0 : l;
double l = stagePos_ / static_cast<double>(params.attackFrames);
if (l > 1.0) l = 1.0;
return curveMap(l, params.attackCurve);
}
case Stage::Hold:
// A zero-length hold falls straight through to Decay on the next tick, whose
@@ -162,16 +216,17 @@ private:
return (params.decayFrames <= 0) ? params.sustainLevel : 1.0;
case Stage::Decay: {
if (params.decayFrames <= 0) return params.sustainLevel;
const double t = stagePos_ / static_cast<double>(params.decayFrames);
return 1.0 + (params.sustainLevel - 1.0) * t;
double t = stagePos_ / static_cast<double>(params.decayFrames);
if (t > 1.0) t = 1.0; // never bites on the un-edited path (transitions at >=)
return 1.0 + (params.sustainLevel - 1.0) * curveMap(t, params.decayCurve);
}
case Stage::Sustain:
return params.sustainLevel;
case Stage::Release: {
if (params.releaseFrames <= 0) return 0.0;
const double t = stagePos_ / static_cast<double>(params.releaseFrames);
const double l = releaseFrom_ * (1.0 - t);
return l < 0.0 ? 0.0 : l;
double t = stagePos_ / static_cast<double>(params.releaseFrames);
if (t > 1.0) t = 1.0;
return releaseFrom_ * (1.0 - curveMap(t, params.releaseCurve));
}
default:
return 0.0;
@@ -276,145 +331,146 @@ private:
StepSmoother smooth_;
};
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
// offset into the span (not output frames): under Varispeed a transposed voice consumes
// source faster than output, so driving the fades off the read position keeps fade-in/out
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
// time-boxed by the play length and note-off-immune.
class TriggerEnvelope {
public:
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
// length finishes immediately.
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve) {
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
curve_ = curve;
finished_ = (playLength_ <= 0);
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end).
// A negative fade is treated as 0. When both fades together exceed the play length,
// shrink the fade-out first (the head fade-in is the more perceptually load-bearing
// onset ramp), then the fade-in — never letting either go negative or the sum exceed
// the span.
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
if (fi > playLength_) fi = playLength_;
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
fadeIn_ = fi;
fadeOut_ = fo;
// The sustain-less AHD amplitude shape, evaluated at a source-frame offset into the span
// rather than by ticking output frames: under Varispeed a transposed voice consumes source
// faster than output, so driving the shape off the read position keeps every stage boundary on
// the same source frames regardless of engine. Note-off-immune and time-boxed by the span.
//
// Positional means there is no phase counter to hold across a live edit, so the phi rule
// AdsrEnvelope applies has nothing to act on here; a live reshape is a level step, absorbed by
// the same bounded smoother.
class AhdEnvelope {
public:
// `spanFrames` is the bound the stages are fitted into — (playEnd - startFrame) for the
// Trigger amp and filter envelopes. A zero/negative span finishes immediately.
void configure(std::int64_t spanFrames, const AhdParams& params) {
span_ = spanFrames > 0 ? spanFrames : 0;
fit(params);
smooth_.clear();
}
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
// or past playLength. Pure over the offset so it composes with either pitch engine's
// read rate.
// Peer of AdsrEnvelope::snapLive: a voice that has rendered nothing takes the new shape
// outright, with no step to absorb.
void snapLive(const AhdParams& params) {
fit(params);
smooth_.clear();
}
// Live delivery to a sounding voice at its current `sourceOffset`. See the class note for
// why this smooths rather than holding a normalized position.
void applyLive(double sourceOffset, const AhdParams& params) {
const double before = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
fit(params);
const double after = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
if (after != before) smooth_.absorb(before - after);
}
// Amplitude at `sourceOffset` = (readPos - startFrame). Latches finished() at or past the
// fitted total, which is what frees the voice.
double amplitudeAt(double sourceOffset) {
if (finished_ || sourceOffset < 0.0 ||
sourceOffset >= static_cast<double>(playLength_)) {
// At/past the play length the one-shot is done; the voice also frees on
// readPos >= playEnd.
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
if (finished_ || sourceOffset >= static_cast<double>(fit_.total)) {
if (sourceOffset >= static_cast<double>(fit_.total)) finished_ = true;
return 0.0;
}
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over
// [playLength_-fadeOut_, playLength_). Unity between. The two ramps never overlap
// (configure clamps fadeIn_ + fadeOut_ <= length). The offset is fractional (the read
// head is fractional under repitch), so the ramps are smooth rather than stepped.
double amp = 1.0;
const double foStart = static_cast<double>(playLength_ - fadeOut_);
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
amp = (curve_ == FadeCurve::EqualPower)
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): constant power
: phase;
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_);
amp = (curve_ == FadeCurve::EqualPower)
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): constant power
: (1.0 - phase);
}
return amp;
const double out = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
return smooth_.active() ? out + smooth_.advance() : out;
}
bool finished() const { return finished_; }
const AhdSpan& stages() const { return fit_; }
private:
std::int64_t playLength_ = 0;
std::int64_t fadeIn_ = 0;
std::int64_t fadeOut_ = 0;
FadeCurve curve_ = kDefaultFadeCurve;
bool finished_ = false;
void fit(const AhdParams& p) {
fit_ = fitAhd(span_, p);
attackCurve_ = p.attackCurve;
decayCurve_ = p.decayCurve;
finished_ = (fit_.total <= 0);
}
std::int64_t span_ = 0;
AhdSpan fit_;
double attackCurve_ = util::kCurveNeutral;
double decayCurve_ = util::kCurveNeutral;
bool finished_ = true;
StepSmoother smooth_;
};
// tick() returns the current pitch offset in semitones (0 when disabled or past
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
// (Varispeed) or a shift-amount add (Preserve).
// tick() returns the current pitch offset in semitones (0 when disabled or past the AHD),
// advancing one frame. The voice converts it to a ratio multiply (Varispeed) or a shift-amount
// add (Preserve). Unlike the amplitude AHD this owns its own position counter — pitch-envelope
// time is wall-clock output frames — so the mid-stage rule applies in full.
class PitchEnvelope {
public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; }
// `spanFrames` is the playable span the Hold fraction is taken against.
void configure(std::int64_t spanFrames, const PitchEnvParams& params) {
span_ = spanFrames > 0 ? spanFrames : 0;
params_ = params;
fit_ = fitAhd(span_, params.shape);
pos_ = 0.0;
}
void noteOn() { pos_ = 0.0; smooth_.clear(); }
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
// that has rendered nothing takes the new times and depth outright.
void snapLive(std::int64_t attackFrames, std::int64_t decayFrames, double peakSemitones) {
params_.attackFrames = attackFrames;
params_.decayFrames = decayFrames;
params_.peakSemitones = peakSemitones;
// that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete
// toggle travelling by reload, so the caller's copy of it is deliberately ignored.
void snapLive(const PitchEnvParams& params) {
params_.peakSemitones = params.peakSemitones;
params_.shape = params.shape;
fit_ = fitAhd(span_, params_.shape);
smooth_.clear();
}
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
// level, not a duration). `enabled` is a discrete toggle and travels by reload, so it is
// deliberately not a parameter here.
void applyLive(std::int64_t attackFrames, std::int64_t decayFrames,
double peakSemitones) {
const double before = offsetAt(params_);
const double a = params_.attackFrames > 0 ? static_cast<double>(params_.attackFrames) : 0.0;
const double d = params_.decayFrames > 0 ? static_cast<double>(params_.decayFrames) : 0.0;
const double na = attackFrames > 0 ? static_cast<double>(attackFrames) : 0.0;
const double nd = decayFrames > 0 ? static_cast<double>(decayFrames) : 0.0;
if (pos_ < a) {
pos_ = (na > 0.0) ? pos_ * (na / a) : na;
} else if (pos_ < a + d) {
pos_ = (nd > 0.0) ? na + (pos_ - a) * (nd / d) : na + nd;
} else {
pos_ = na + nd; // already past the envelope: stay past it under the new times
}
params_.attackFrames = attackFrames;
params_.decayFrames = decayFrames;
params_.peakSemitones = peakSemitones;
const double after = offsetAt(params_);
// level, not a duration).
void applyLive(const PitchEnvParams& params) {
const double before = offsetAt();
const AhdSpan next = fitAhd(span_, params.shape);
pos_ = holdPhase(fit_, next);
params_.peakSemitones = params.peakSemitones;
params_.shape = params.shape;
fit_ = next;
const double after = offsetAt();
if (after != before) smooth_.absorb(before - after);
}
double tick() {
if (!params_.enabled) return 0.0;
const double offset = offsetAt(params_);
const double offset = offsetAt();
pos_ += 1.0;
return smooth_.active() ? offset + smooth_.advance() : offset;
}
private:
// The semitone offset at the current position under `params` — the shared evaluator for
// both tick() and applyLive's before/after comparison.
double offsetAt(const PitchEnvParams& params) const {
if (!params.enabled) return 0.0;
const double a = params.attackFrames > 0 ? static_cast<double>(params.attackFrames) : 0.0;
const double d = params.decayFrames > 0 ? static_cast<double>(params.decayFrames) : 0.0;
if (pos_ < a) {
// Attack: 0 -> peak over attackFrames (rise into the peak).
return params.peakSemitones * (pos_ / a);
// The semitone offset at the current position — the shared evaluator for both tick() and
// applyLive's before/after comparison.
double offsetAt() const {
if (!params_.enabled) return 0.0;
return params_.peakSemitones *
ahdLevelAt(pos_, fit_, params_.shape.attackCurve, params_.shape.decayCurve);
}
if (pos_ < a + d) {
// Decay: peak -> 0 over decayFrames (settle to base pitch).
return params.peakSemitones * (1.0 - (pos_ - a) / d);
// The position under `next` holding the normalized position within whichever leg pos_ is
// in. A leg dialled to zero completes: the position lands on that leg's new end.
double holdPhase(const AhdSpan& old, const AhdSpan& next) const {
const double oa = static_cast<double>(old.attack);
const double oh = static_cast<double>(old.hold);
const double od = static_cast<double>(old.decay);
const double na = static_cast<double>(next.attack);
const double nh = static_cast<double>(next.hold);
const double nd = static_cast<double>(next.decay);
if (pos_ < oa) return (na > 0.0) ? pos_ * (na / oa) : na;
if (pos_ < oa + oh) return (nh > 0.0) ? na + (pos_ - oa) * (nh / oh) : na + nh;
if (pos_ < oa + oh + od) {
return (nd > 0.0) ? na + nh + (pos_ - oa - oh) * (nd / od) : na + nh + nd;
}
return 0.0; // past attack+decay: at base pitch forever.
return na + nh + nd; // already past the envelope: stay past it under the new shape
}
PitchEnvParams params_;
std::int64_t span_ = 0;
AhdSpan fit_;
double pos_ = 0.0;
StepSmoother smooth_;
};
+3 -3
View File
@@ -11,10 +11,10 @@ LiveValues foldLive(const PlayParams& params) {
v.filterModAmount = params.filter.modAmount;
v.filterKeyTrack = params.filter.keyTrack;
v.filterEnv = params.filter.env;
v.filterAhd = params.filter.trigEnv;
v.adsr = params.adsr;
v.pitchEnvAttackFrames = params.pitchEnv.attackFrames;
v.pitchEnvDecayFrames = params.pitchEnv.decayFrames;
v.pitchEnvPeakSemitones = params.pitchEnv.peakSemitones;
v.ampAhd = params.trigAhd;
v.pitchEnv = params.pitchEnv;
return v;
}
+7 -3
View File
@@ -27,15 +27,19 @@ inline constexpr double kLiveRampSeconds = 0.020;
// morphLaw rides inside filterSettings only because it is cheaper to carry the whole struct to
// the filter's prepare() than to splice it back; it changes only across a reload, which
// republishes this block, so the two can never disagree.
// Each envelope carries BOTH mode shapes: which one a voice applies is fixed at note-on by
// its play mode, so publishing both keeps the block one shape regardless of mode. The pitch
// envelope's `enabled` rides along inside its params only because the struct is carried whole;
// PitchEnvelope ignores it, since a toggle travels by reload.
struct LiveValues {
filter::FilterSettings filterSettings{};
double filterModAmount = 0.0;
double filterKeyTrack = 0.0;
AdsrParams filterEnv{};
AhdParams filterAhd{};
AdsrParams adsr{};
std::int64_t pitchEnvAttackFrames = 0;
std::int64_t pitchEnvDecayFrames = 0;
double pitchEnvPeakSemitones = 0.0;
AhdParams ampAhd{};
PitchEnvParams pitchEnv{};
};
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
+38 -21
View File
@@ -11,6 +11,7 @@
#include "core/audio/peaks.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/velocity_curve.h"
#include "core/util/curve_law.h" // the per-segment curve exponent domain + its neutral
namespace reasampler {
@@ -39,34 +40,44 @@ inline constexpr int kMaxVoiceCount = 32;
inline constexpr int kDefaultVoiceCount = 16;
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
// The three curve exponents shape the SLOPED stages only — Hold and Sustain are flat by
// definition and carry none. `curve_law.h` owns what an exponent means.
struct AdsrParams {
std::int64_t attackFrames = 0;
std::int64_t holdFrames = 0;
std::int64_t decayFrames = 0;
double sustainLevel = 1.0; // 0..1
std::int64_t releaseFrames = 0;
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
double releaseCurve = util::kCurveNeutral;
};
// Attack -> Hold -> Decay over a bounded span: the shape every SUSTAIN-LESS envelope takes
// (the Trigger amp, the Trigger filter envelope, the pitch envelope). Hold is a FRACTION of
// the span left after attack and decay, never a time of its own — that is what makes
// A + H + D <= span structural rather than clamped (see fitAhd in envelopes.h).
struct AhdParams {
std::int64_t attackFrames = 0;
std::int64_t decayFrames = 0;
double holdFraction = 1.0; // 0..1 of the span remaining after attack + decay
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
};
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
// honor the start point. Default Gate so an instrument with no params set plays as before.
// note-off-immune, no sustain loop, plays a % of sample length shaped by the AHD. Both honor
// the start point. Default Gate so an instrument with no params set plays as before.
enum class PlayMode { Gate, Trigger };
// Playback covers [startFrame, playEnd), playEnd = startFrame +
// round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the
// head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so
// fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd.
// Trigger's play SPAN: [startFrame, playEnd), playEnd = startFrame +
// round(lengthFraction*(frames - startFrame)). The voice frees when the head reaches playEnd.
// The amplitude SHAPE over that span is PlayParams::trigAhd — the fade-in/fade-out pair that
// used to live here is retired; do not reintroduce a second amplitude mechanism.
struct TriggerParams {
double lengthFraction = 1.0; // (0,1] of the post-start span to play
std::int64_t fadeInFrames = 0;
std::int64_t fadeOutFrames = 0;
};
// EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
// the build-time residual.
enum class FadeCurve { EqualPower, Linear };
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
// PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
// (an octave up keeps its length).
@@ -83,14 +94,15 @@ inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0;
// AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
// to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
// then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
// bit-identical to the un-modulated engine). At note-on the offset rises to peakSemitones over
// attack, holds there, then falls to 0 over decay; a zero attack gives a pure percussive pitch
// drop. The hold fraction defaults to 0 so an instance predating the stage plays exactly as its
// attack-decay predecessor did.
struct PitchEnvParams {
bool enabled = false;
std::int64_t attackFrames = 0;
std::int64_t decayFrames = 0;
double peakSemitones = 0.0; // signed depth at the peak
AhdParams shape{0, 0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
};
// Per-voice resonant filter, off by default (enabled=false -> the render path skips it
@@ -106,7 +118,11 @@ struct FilterParams {
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
AdsrParams env; // the same staged AHDSR the amp runs; frames
// The filter envelope takes the same shape the amp does under the active play mode:
// AHDSR in Gate, AHD in Trigger. Both are stored, so a mode flip cannot lose either
// mode's dialled values (see core/instrument/CLAUDE.md).
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
AhdParams trigEnv; // Trigger: the same staged AHD the amp runs; frames
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
// because a flat curve under a depth control would make every velocity the same offset;
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
@@ -121,8 +137,9 @@ struct FilterParams {
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
struct PlayParams {
PlayMode playMode = PlayMode::Gate;
AdsrParams adsr;
TriggerParams trigger;
AdsrParams adsr; // Gate amp
TriggerParams trigger; // Trigger play span
AhdParams trigAhd; // Trigger amp
PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv;
FilterParams filter;
+31 -19
View File
@@ -64,11 +64,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start);
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
startFrame_ = start; // the span-offset origin: readPos - startFrame
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
// % play length.
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
std::int64_t trigSpan = 0;
if (playMode_ == PlayMode::Gate) {
env_.configure(p.adsr);
env_.noteOn();
@@ -79,17 +80,18 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
double frac = p.trigger.lengthFraction;
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
if (frac > 1.0) frac = 1.0;
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
std::int64_t playLen = static_cast<std::int64_t>(
static_cast<double>(span) * frac + 0.5); // round
static_cast<double>(postStart) * frac + 0.5); // round
if (playLen < 0) playLen = 0;
if (playLen > span) playLen = span;
if (playLen > postStart) playLen = postStart;
playEnd_ = start + playLen;
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
kDefaultFadeCurve);
trigSpan = playLen;
ampAhd_.configure(playLen, p.trigAhd);
}
pitchEnv_.configure(p.pitchEnv);
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
// stages lay 1:1 over the waveform from the start point.
pitchEnv_.configure(postStart, p.pitchEnv);
pitchEnv_.noteOn();
// A restart lands every live glide back on the new note's own values, at a step derived
@@ -119,8 +121,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
rDrive_.set(static_cast<double>(p.filter.settings.driveNorm));
if (playMode_ == PlayMode::Gate) {
filterEnv_.configure(p.filter.env);
filterEnv_.noteOn();
} else {
filterAhd_.configure(trigSpan, p.filter.trigEnv);
}
filter_.reset();
updateFilterCutoffBase(note);
// The note's ONE full solve — Q, morph and drive are constants for its lifetime unless
@@ -196,25 +202,31 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
}
void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
// Gate's amplitude envelope is the AHDSR; Trigger's fade shape is anchored to a play span
// resolved at note-on and travels by reload instead (deck_groups.h names why).
// Each envelope applies only the shape its play mode selected at note-on; the block
// carries both so the mode never changes what is published.
//
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
const bool gate = (playMode_ == PlayMode::Gate);
if (snap) {
if (playMode_ == PlayMode::Gate) env_.snapLive(live.adsr);
pitchEnv_.snapLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
live.pitchEnvPeakSemitones);
if (gate) env_.snapLive(live.adsr);
else ampAhd_.snapLive(live.ampAhd);
pitchEnv_.snapLive(live.pitchEnv);
} else {
if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr);
pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
live.pitchEnvPeakSemitones);
if (gate) env_.applyLive(live.adsr);
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
pitchEnv_.applyLive(live.pitchEnv);
}
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
if (snap) filterEnv_.snapLive(live.filterEnv);
else filterEnv_.applyLive(live.filterEnv);
if (snap) {
if (gate) filterEnv_.snapLive(live.filterEnv);
else filterAhd_.snapLive(live.filterAhd);
} else {
if (gate) filterEnv_.applyLive(live.filterEnv);
else filterAhd_.applyLive(sourceOffset(), live.filterAhd);
}
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack;
filterSettings_.morphLaw = live.filterSettings.morphLaw;
+49 -16
View File
@@ -60,7 +60,7 @@ inline double filterNormPerOctave() {
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
// amplitude was instantly ~1 (a zero-attack Trigger or Gate) got zero
// compensation and kept the full click — the difference-seed has no such hole. Off by
// default so the bare core stays byte-identical to the pre-fix engine; the processor
// shell opts in.
@@ -162,25 +162,26 @@ private:
}
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
// advanceFrame frees the voice.
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
// the voice.
double tickAmplitude() {
double amp;
if (playMode_ == PlayMode::Gate) {
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
} else {
// Anchored to the source offset so fades land on the same source frames under
// either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
// advanceFrame; finished() here is the belt to that suspenders.
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true;
amp = ampAhd_.amplitudeAt(sourceOffset());
if (ampAhd_.finished()) amplitudeDone_ = true;
}
return amp;
}
// Frames into the Trigger play span at the current read head — the domain both
// sustain-less envelopes are evaluated over.
double sourceOffset() const { return readPos_ - static_cast<double>(startFrame_); }
// Advances the filter envelope and re-solves the corner from the modulated cutoff. The
// solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
// rather than staircasing. State preservation across the solve is voice_filter's own
@@ -195,8 +196,13 @@ private:
// through both so a moved base always re-solves.
void tickFilterCutoff() {
if (filterModAmount_ == 0.0 && filterSolved_) return;
double cut = static_cast<double>(filterBaseCutoff_) +
filterModAmount_ * filterEnv_.tick();
// The filter envelope takes the amp's shape under the active mode — AHDSR in Gate,
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
// branch is perfectly predicted.
const double envOut = (playMode_ == PlayMode::Gate)
? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset());
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
if (cut < 0.0) cut = 0.0;
if (cut > 1.0) cut = 1.0;
const float cutNorm = static_cast<float>(cut);
@@ -284,6 +290,22 @@ private:
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
}
// Rings the voice's last rendered output out instead of hard-cutting it when the read head
// reaches the end of its span, on the PRESERVE path only. Varispeed's final sample is real
// source content at its natural end and its stop is left byte-identical; Preserve's is
// recycled synthetic tail (freezeTail stops the writer a full window before the read head
// arrives), whose level bears no relation to the source's own ending — cutting it at
// whatever amplitude the splice machinery happens to be at is the end-of-sample click.
// Reuses the takeover blend so the boundary frame reproduces the last level exactly.
void seedTerminalDeclick() {
if (pitchEngine_ != PitchEngine::Preserve) return;
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
declickWeight_ = 1.0;
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
}
// Shared read/advance for both render paths: computes the interpolated per-channel
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
@@ -327,6 +349,7 @@ private:
// byte-identical to the plain idle-out.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
if (declickPending_) seedDeclick();
if (!declickActive_) seedTerminalDeclick();
if (declickActive_) {
// Bounded blend at silence: outCurrent == 0, so the blend is
// w*(ref 0) == w*ref. The weight decays by kDeclickDecay each frame,
@@ -350,6 +373,15 @@ private:
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
const double amp = tickAmplitude();
// Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE
// the play span (a zero decay, which the shape deliberately keeps expressible) cuts the
// same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which
// still holds the PREVIOUS frame — this one is already silent. Gate is left out on
// purpose: its amplitude reaches zero through a release, so there is no cut to ring out.
if (amplitudeDone_ && amp == 0.0 && !declickActive_ &&
playMode_ == PlayMode::Trigger) {
seedTerminalDeclick();
}
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
@@ -518,13 +550,13 @@ private:
double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr;
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
// Gate uses env_ (AHDSR); Trigger uses ampAhd_ — only one active per voice (selected by
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
// readPos_ >= playEnd_).
PlayMode playMode_ = PlayMode::Gate;
AdsrEnvelope env_;
TriggerEnvelope trigEnv_;
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
AhdEnvelope ampAhd_;
std::int64_t startFrame_ = 0; // clamped initial read frame; the span-offset origin
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
@@ -534,7 +566,8 @@ private:
// solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
// filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
instrument::engine::filter::VoiceFilter filter_;
AdsrEnvelope filterEnv_;
AdsrEnvelope filterEnv_; // Gate
AhdEnvelope filterAhd_; // Trigger
bool filterOn_ = false;
double filterRate_ = 0.0;
double filterCutoffNorm_ = 1.0;
+3 -1
View File
@@ -17,8 +17,10 @@ reasampler_test(bank_sync LINK bank_sync)
# the voice engine: velocity_curve (the curve field) and master_gain (the wire gain cap) only.
# play_params.h also pulls in filter/'s headers (FilterSettings, MorphLaw) for the v9 filter
# tail -- plain value types, so no filter symbol is linked and this stays true.
# Two TUs on the format's OWN seam: the envelope's version ladder and the payload's, which
# the format already keeps on independent version axes (see component_state_io.h).
reasampler_pure_library(component_state_io
SOURCES component_state_io.cpp
SOURCES component_state_io.cpp params_payload.cpp
LINK PUBLIC velocity_curve master_gain)
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
+5 -266
View File
@@ -1,16 +1,16 @@
// component_state_io — the ComponentState envelope + params-payload binary codec. See
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v9).
// Every wire format is FROZEN — byte-identical across revisions.
// component_state_io — the ComponentState ENVELOPE codec. See component_state_io.h for both
// format ladders (envelope v1..v11, params payload v1..v10); the payload half lives in
// params_payload, which grows on its own version axis. Every wire format is FROZEN —
// byte-identical across revisions.
#include "core/instrument/map/component_state_io.h"
#include <algorithm> // std::min (bounded curve-point reserve)
#include <cassert> // assert (v3-lift projectRate guard)
#include <cmath> // std::isfinite (v8 master-gain validation)
#include <cstring> // std::memcpy (serializeSelection)
#include <utility> // std::move
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
#include "core/instrument/map/params_payload.h" // the payload half of this codec
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
namespace reasampler::instrument::map {
@@ -26,267 +26,6 @@ namespace {
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
// payload that carried at least one zone: the first zone's capture, which supersedes the
// envelope's selection id (see the adoption rule in the header).
struct PayloadRead {
InstrumentParams params;
std::string adoptedSampleId;
};
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
// the two shapes cannot drift byte-for-byte.
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
out.push_back(p.rootOverride ? 1 : 0);
if (p.rootOverride) {
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
}
out.push_back(p.loopOverride ? 1 : 0);
if (p.loopOverride) {
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
putLE(out, asU64(p.loopOverride->start));
putLE(out, asU64(p.loopOverride->end));
}
out.push_back(p.startPoint ? 1 : 0);
if (p.startPoint) putLE(out, asU64(*p.startPoint));
}
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
// The amp curve (v7) and the filter's own curve (v9) share this shape.
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.amp));
}
}
// Append the params payload: marker + version + the single parameter record. Always emits
// the CURRENT payload version; the marker precedes the record so any reader detects the
// shape independent of the envelope version (see component_state_io.h).
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
putLE(out, kParamsFormatMarker);
putLE(out, kParamsPayloadVersion);
putOverrides(out, p);
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
const PlaySeconds& pp = p.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
putLE(out, doubleToBits(pp.adsr.attackSeconds));
putLE(out, doubleToBits(pp.adsr.decaySeconds));
putLE(out, doubleToBits(pp.adsr.sustainLevel));
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
// Key-tracking scalar (1.0 = 100% ET).
putLE(out, doubleToBits(p.keyTrack));
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
// velocity + amp as doubles (endpoints included, so N >= 2).
putCurve(out, p.velocityCurve);
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
// whole payload stays one numeric shape.
const FilterSeconds& f = pp.filter;
out.push_back(f.enabled ? 1 : 0);
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
putLE(out, doubleToBits(f.modAmount));
putLE(out, doubleToBits(f.velAmount));
putLE(out, doubleToBits(f.keyTrack));
putLE(out, doubleToBits(f.env.attackSeconds));
putLE(out, doubleToBits(f.env.holdSeconds));
putLE(out, doubleToBits(f.env.decaySeconds));
putLE(out, doubleToBits(f.env.sustainLevel));
putLE(out, doubleToBits(f.env.releaseSeconds));
putCurve(out, f.velocityCurve);
}
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
// v8 single-record reader so the two can never disagree about field order.
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
p.play.trigger.fadeInFrames = r.i64();
p.play.trigger.fadeOutFrames = r.i64();
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
p.play.pitchEnv.enabled = (r.u8() != 0);
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
// defensively; a truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
// can't trigger a giant allocation before the bounded reads fail.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, amp});
}
if (r.ok) {
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
}
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec.
void readFilterTail(ByteReader& r, InstrumentParams& p) {
FilterSeconds& f = p.play.filter;
f.enabled = (r.u8() != 0);
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
double modAmount = bitsToDouble(r.u64());
double velAmount = bitsToDouble(r.u64());
double keyTrack = bitsToDouble(r.u64());
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
f.env.attackSeconds = bitsToDouble(r.u64());
f.env.holdSeconds = bitsToDouble(r.u64());
f.env.decaySeconds = bitsToDouble(r.u64());
f.env.sustainLevel = bitsToDouble(r.u64());
f.env.releaseSeconds = bitsToDouble(r.u64());
readCurveTail(r, f.velocityCurve);
}
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
// no file and no bank entry.
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
// blobs carry seconds directly and need no rate.
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
PayloadRead out;
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
const std::uint32_t count = r.u32();
bool adopted = false;
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
InstrumentParams p;
std::string sampleId;
const std::uint32_t idLen = r.u32();
sampleId = r.str(idLen);
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
r.i32(); // highNote
const std::uint8_t hasOverride = r.u8();
if (hasOverride) p.rootOverride = r.i32();
if (extended) {
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
p.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) p.startPoint = r.i64();
}
if (legacyV3Play) {
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
p.play.trigger.fadeInFrames = r.i64();
p.play.trigger.fadeOutFrames = r.i64();
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
p.play.pitchEnv.enabled = (r.u8() != 0);
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
} else if (secondsPlay) {
readSecondsPlayTail(r, p);
}
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (curveTail) readCurveTail(r, p.velocityCurve);
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// leave the seconds product defaults on p.play.
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
if (!adopted) {
out.params = std::move(p);
out.adoptedSampleId = std::move(sampleId);
adopted = true;
}
}
return out;
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
// v1 (a plain small zone count).
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
std::uint32_t pv = 0; // 0 = v1, no marker
if (r.peekU32() == kParamsFormatMarker) {
r.u32(); // consume the marker
pv = r.u32(); // payload version
}
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
PayloadRead out;
InstrumentParams& p = out.params;
const std::uint8_t hasRoot = r.u8();
if (hasRoot) p.rootOverride = r.i32();
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
p.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) p.startPoint = r.i64();
readSecondsPlayTail(r, p);
p.keyTrack = bitsToDouble(r.u64());
readCurveTail(r, p.velocityCurve);
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
return out;
}
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
// supersedes the envelope's selection id) lives here, once.
void applyPayload(ComponentState& out, PayloadRead read) {
+25 -9
View File
@@ -8,7 +8,8 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v8) must be preserved exactly.
// payload v1..v10) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint>
#include <string>
@@ -70,13 +71,25 @@ namespace reasampler::instrument::map {
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
// velocity curve (count + points) as in v7.
//
// v9 (CURRENT WRITE FORMAT) is v8 PLUS the per-voice filter tail, appended after the velocity
// curve: 1 byte enabled; 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles,
// widened from the module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte
// LE modAmount, velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release
// SECONDS; then the filter's OWN velocity curve (count + points, same shape as v7's). A v8
// blob is a strict prefix, so it lifts to the off/neutral filter default and plays
// bit-identically.
// v9 is v8 PLUS the per-voice filter tail, appended after the velocity curve: 1 byte enabled;
// 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles, widened from the
// module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte LE modAmount,
// velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release SECONDS; then
// the filter's OWN velocity curve (count + points, same shape as v7's). A v8 blob is a strict
// prefix, so it lifts to the off/neutral filter default and plays bit-identically.
//
// v10 (CURRENT WRITE FORMAT) is v9 PLUS the staged-curve tail, appended after the filter's
// velocity curve, all 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve
// exponents; the Trigger amp AHD (attack SECONDS, decay SECONDS, hold FRACTION, attack curve,
// decay curve); the pitch envelope's hold FRACTION + attack/decay curve exponents; the filter
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
// the amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
// seconds at the project rate the reader is handed. v10 writes ZERO into both — the values
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
//
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
@@ -85,7 +98,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 9; // v8 + the per-voice filter tail
inline constexpr std::uint32_t kParamsPayloadVersion = 10; // v9 + the staged-curve tail
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -98,6 +111,9 @@ inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;
// self-describing, mirroring the envelope's version constants.
inline constexpr std::uint32_t kParamsFilterVersion = 9;
// v9 + the staged-curve tail (curve exponents, the Trigger AHDs, the pitch Hold fraction).
inline constexpr std::uint32_t kParamsCurveVersion = 10;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
+354
View File
@@ -0,0 +1,354 @@
// params_payload.cpp — see params_payload.h. The format ladder it implements is documented
// in component_state_io.h; every wire format below is FROZEN.
#include "core/instrument/map/params_payload.h"
#include <algorithm> // std::min (bounded curve-point reserve)
#include <cassert> // assert (v3-lift projectRate guard)
#include <cmath> // std::isfinite (wire-value validation)
#include <utility> // std::move
#include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation)
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
namespace reasampler::instrument::map {
using reasampler::wire::ByteReader;
using reasampler::wire::bitsToDouble;
using reasampler::wire::doubleToBits;
using reasampler::wire::putLE;
namespace {
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
// the two shapes cannot drift byte-for-byte.
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
out.push_back(p.rootOverride ? 1 : 0);
if (p.rootOverride) {
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
}
out.push_back(p.loopOverride ? 1 : 0);
if (p.loopOverride) {
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
putLE(out, asU64(p.loopOverride->start));
putLE(out, asU64(p.loopOverride->end));
}
out.push_back(p.startPoint ? 1 : 0);
if (p.startPoint) putLE(out, asU64(*p.startPoint));
}
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
// The amp curve (v7) and the filter's own curve (v9) share this shape.
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.amp));
}
}
// A stored AHD's five doubles, in one order shared by every AHD on the wire.
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
putLE(out, doubleToBits(a.attackSeconds));
putLE(out, doubleToBits(a.decaySeconds));
putLE(out, doubleToBits(a.holdFraction));
putLE(out, doubleToBits(a.attackCurve));
putLE(out, doubleToBits(a.decayCurve));
}
// THE lift of the retired Trigger fade pair onto the AHD that replaced it: Attack takes the
// fade-in, Decay the fade-out, Hold the whole remainder — so a zero fade-out lands Decay = 0
// and the abrupt end an old instance could express stays representable. The fades were SOURCE
// frames and the AHD stores wall-clock seconds, so the conversion goes through the same
// project rate the v3 lift already uses. A v10-or-newer blob overwrites this from its own tail.
void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, double projectRate,
AhdSeconds& out) {
const double rate = projectRate > 0.0 ? projectRate : 1.0;
out.attackSeconds = static_cast<double>(fadeInFrames > 0 ? fadeInFrames : 0) / rate;
out.decaySeconds = static_cast<double>(fadeOutFrames > 0 ? fadeOutFrames : 0) / rate;
out.holdFraction = 1.0;
}
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
// v8 single-record reader so the two can never disagree about field order.
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate) {
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
const std::int64_t fadeIn = r.i64();
const std::int64_t fadeOut = r.i64();
liftTriggerFades(fadeIn, fadeOut, projectRate, p.play.trigAhd);
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
p.play.pitchEnv.enabled = (r.u8() != 0);
p.play.pitchEnv.shape.attackSeconds = bitsToDouble(r.u64());
p.play.pitchEnv.shape.decaySeconds = bitsToDouble(r.u64());
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
// defensively; a truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
// can't trigger a giant allocation before the bounded reads fail.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, amp});
}
if (r.ok) {
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
}
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec.
void readFilterTail(ByteReader& r, InstrumentParams& p) {
FilterSeconds& f = p.play.filter;
f.enabled = (r.u8() != 0);
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
double modAmount = bitsToDouble(r.u64());
double velAmount = bitsToDouble(r.u64());
double keyTrack = bitsToDouble(r.u64());
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
f.env.attackSeconds = bitsToDouble(r.u64());
f.env.holdSeconds = bitsToDouble(r.u64());
f.env.decaySeconds = bitsToDouble(r.u64());
f.env.sustainLevel = bitsToDouble(r.u64());
f.env.releaseSeconds = bitsToDouble(r.u64());
readCurveTail(r, f.velocityCurve);
}
// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
// rather than to an endpoint: neutral is the one exponent that cannot change how a stage
// sounds, so a damaged blob loses the shaping instead of inventing one.
double readCurveExponent(ByteReader& r) {
const double v = bitsToDouble(r.u64());
return std::isfinite(v) ? reasampler::util::clampCurve(v) : reasampler::util::kCurveNeutral;
}
void readAhd(ByteReader& r, AhdSeconds& a) {
a.attackSeconds = bitsToDouble(r.u64());
a.decaySeconds = bitsToDouble(r.u64());
const double frac = bitsToDouble(r.u64());
a.holdFraction = std::isfinite(frac) ? frac : 0.0;
a.attackCurve = readCurveExponent(r);
a.decayCurve = readCurveExponent(r);
}
// Read the v10 staged-curve tail into `p`. A blob that stops short leaves the neutral
// exponents and the fade-lifted Trigger AHD, which is what makes a v9 blob play as before.
void readCurveStageTail(ByteReader& r, InstrumentParams& p) {
PlaySeconds& pp = p.play;
pp.adsr.attackCurve = readCurveExponent(r);
pp.adsr.decayCurve = readCurveExponent(r);
pp.adsr.releaseCurve = readCurveExponent(r);
readAhd(r, pp.trigAhd);
const double pitchHold = bitsToDouble(r.u64());
pp.pitchEnv.shape.holdFraction = std::isfinite(pitchHold) ? pitchHold : 0.0;
pp.pitchEnv.shape.attackCurve = readCurveExponent(r);
pp.pitchEnv.shape.decayCurve = readCurveExponent(r);
pp.filter.env.attackCurve = readCurveExponent(r);
pp.filter.env.decayCurve = readCurveExponent(r);
pp.filter.env.releaseCurve = readCurveExponent(r);
readAhd(r, pp.filter.trigEnv);
}
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
// no file and no bank entry.
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
// blobs carry seconds directly and need no rate.
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
PayloadRead out;
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
const std::uint32_t count = r.u32();
bool adopted = false;
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
InstrumentParams p;
std::string sampleId;
const std::uint32_t idLen = r.u32();
sampleId = r.str(idLen);
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
r.i32(); // highNote
const std::uint8_t hasOverride = r.u8();
if (hasOverride) p.rootOverride = r.i32();
if (extended) {
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
p.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) p.startPoint = r.i64();
}
if (legacyV3Play) {
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
const std::int64_t fadeIn = r.i64();
const std::int64_t fadeOut = r.i64();
liftTriggerFades(fadeIn, fadeOut, liftRate, p.play.trigAhd);
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
p.play.pitchEnv.enabled = (r.u8() != 0);
p.play.pitchEnv.shape.attackSeconds = static_cast<double>(r.i64()) / liftRate;
p.play.pitchEnv.shape.decaySeconds = static_cast<double>(r.i64()) / liftRate;
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
} else if (secondsPlay) {
readSecondsPlayTail(r, p, projectRate);
}
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (curveTail) readCurveTail(r, p.velocityCurve);
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// leave the seconds product defaults on p.play.
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
if (!adopted) {
out.params = std::move(p);
out.adoptedSampleId = std::move(sampleId);
adopted = true;
}
}
return out;
}
} // namespace
// Append the params payload: marker + version + the single parameter record. Always emits
// the CURRENT payload version; the marker precedes the record so any reader detects the
// shape independent of the envelope version (see component_state_io.h).
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
putLE(out, kParamsFormatMarker);
putLE(out, kParamsPayloadVersion);
putOverrides(out, p);
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
const PlaySeconds& pp = p.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
// The retired fade pair's two frozen slots (see the header): the shape stays, the values
// moved into the Trigger AHD tail below.
putLE(out, asU64(std::int64_t{0}));
putLE(out, asU64(std::int64_t{0}));
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putLE(out, doubleToBits(pp.pitchEnv.shape.attackSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.pitchEnv.shape.decaySeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
putLE(out, doubleToBits(pp.adsr.attackSeconds));
putLE(out, doubleToBits(pp.adsr.decaySeconds));
putLE(out, doubleToBits(pp.adsr.sustainLevel));
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
// Key-tracking scalar (1.0 = 100% ET).
putLE(out, doubleToBits(p.keyTrack));
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
// velocity + amp as doubles (endpoints included, so N >= 2).
putCurve(out, p.velocityCurve);
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
// whole payload stays one numeric shape.
const FilterSeconds& f = pp.filter;
out.push_back(f.enabled ? 1 : 0);
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
putLE(out, doubleToBits(f.modAmount));
putLE(out, doubleToBits(f.velAmount));
putLE(out, doubleToBits(f.keyTrack));
putLE(out, doubleToBits(f.env.attackSeconds));
putLE(out, doubleToBits(f.env.holdSeconds));
putLE(out, doubleToBits(f.env.decaySeconds));
putLE(out, doubleToBits(f.env.sustainLevel));
putLE(out, doubleToBits(f.env.releaseSeconds));
putCurve(out, f.velocityCurve);
// v10: the staged-curve tail.
putLE(out, doubleToBits(pp.adsr.attackCurve));
putLE(out, doubleToBits(pp.adsr.decayCurve));
putLE(out, doubleToBits(pp.adsr.releaseCurve));
putAhd(out, pp.trigAhd);
putLE(out, doubleToBits(pp.pitchEnv.shape.holdFraction));
putLE(out, doubleToBits(pp.pitchEnv.shape.attackCurve));
putLE(out, doubleToBits(pp.pitchEnv.shape.decayCurve));
putLE(out, doubleToBits(f.env.attackCurve));
putLE(out, doubleToBits(f.env.decayCurve));
putLE(out, doubleToBits(f.env.releaseCurve));
putAhd(out, f.trigEnv);
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
// v1 (a plain small zone count).
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
std::uint32_t pv = 0; // 0 = v1, no marker
if (r.peekU32() == kParamsFormatMarker) {
r.u32(); // consume the marker
pv = r.u32(); // payload version
}
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
PayloadRead out;
InstrumentParams& p = out.params;
const std::uint8_t hasRoot = r.u8();
if (hasRoot) p.rootOverride = r.i32();
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
p.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) p.startPoint = r.i64();
readSecondsPlayTail(r, p, projectRate);
p.keyTrack = bitsToDouble(r.u64());
readCurveTail(r, p.velocityCurve);
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
return out;
}
} // namespace reasampler::instrument::map
+41
View File
@@ -0,0 +1,41 @@
#pragma once
// params_payload — the params-payload half of the ComponentState codec, split from the
// ENVELOPE half on the axis the format itself already has: the payload carries its OWN
// version and grows independently of the envelope's, so the two version ladders are two
// responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay
// serialize/deserializeComponentState; nothing outside the codec calls these.
//
// The format ladder (payload v1..v10) is documented in component_state_io.h, which stays its
// one home. EVERY wire format is FROZEN.
#include <cstdint>
#include <string>
#include <vector>
// The payload's version constants and the prose ladder stay in component_state_io.h, their
// one home — this half implements them rather than re-declaring them.
#include "core/instrument/map/component_state_io.h"
#include "core/wire/bytes.h" // ByteReader
namespace reasampler::instrument::map {
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
// payload that carried at least one zone: the first zone's capture, which supersedes the
// envelope's selection id (see the adoption rule in component_state_io.h).
struct PayloadRead {
InstrumentParams params;
std::string adoptedSampleId;
};
// Append the params payload: marker + version + the single parameter record. Always emits
// the CURRENT payload version; the marker precedes the record so any reader detects the
// shape independent of the envelope version.
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p);
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
// v1 (a plain small zone count). `projectRate` converts the LEGACY v3 wall-clock frame counts
// and the retired Trigger fade pair to the seconds domain at the read boundary.
PayloadRead readParamsPayload(reasampler::wire::ByteReader& r, double projectRate);
} // namespace reasampler::instrument::map
+20 -3
View File
@@ -212,6 +212,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
AhdParams a;
a.attackFrames = secToFrames(s.attackSeconds);
a.decayFrames = secToFrames(s.decaySeconds);
a.holdFraction = s.holdFraction;
a.attackCurve = s.attackCurve;
a.decayCurve = s.decayCurve;
return a;
};
PlayParams out;
out.playMode = stored.playMode;
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
@@ -219,12 +229,15 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
out.trigger = stored.trigger; // source-frame / fraction, unchanged
out.adsr.attackCurve = stored.adsr.attackCurve; // dimensionless
out.adsr.decayCurve = stored.adsr.decayCurve;
out.adsr.releaseCurve = stored.adsr.releaseCurve;
out.trigger = stored.trigger; // fraction, unchanged
out.trigAhd = resolveAhd(stored.trigAhd);
out.pitchEngine = stored.pitchEngine;
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
// Filter: the control positions are already rate-free and carry through untouched; only
// its envelope resolves to frames.
out.filter.enabled = stored.filter.enabled;
@@ -238,6 +251,10 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds);
out.filter.env.sustainLevel = stored.filter.env.sustainLevel;
out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds);
out.filter.env.attackCurve = stored.filter.env.attackCurve;
out.filter.env.decayCurve = stored.filter.env.decayCurve;
out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
out.filter.trigEnv = resolveAhd(stored.filter.trigEnv);
return out;
}
+24 -8
View File
@@ -143,21 +143,35 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
// (TriggerParams reused verbatim).
//
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time; the
// three curve exponents are dimensionless too (curve_law.h owns their domain).
struct AdsrSeconds {
double attackSeconds = 0.003; // tier-0 default
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060; // tier-0 default
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
double releaseCurve = util::kCurveNeutral;
};
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
struct PitchEnvSeconds {
bool enabled = false;
// The stored sustain-less AHD: wall-clock stage times in SECONDS, Hold as a FRACTION of the
// span left after them (AhdParams owns why a fraction, not a time).
struct AhdSeconds {
double attackSeconds = 0.0;
double decaySeconds = 0.0;
double holdFraction = 1.0;
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
};
// The stored AHD pitch envelope. enabled + peakSemitones are dimensionless. The hold fraction
// defaults to 0 so an instance predating the stage plays as its attack-decay predecessor did.
struct PitchEnvSeconds {
bool enabled = false;
double peakSemitones = 0.0; // signed depth at the peak
AhdSeconds shape{0.0, 0.0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
};
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field
@@ -171,7 +185,8 @@ struct FilterSeconds {
double modAmount = 0.0;
double velAmount = 0.0;
double keyTrack = 0.0;
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0};
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
AhdSeconds trigEnv; // Trigger
VelocityCurve velocityCurve = VelocityCurve::linear();
};
@@ -180,10 +195,11 @@ struct FilterSeconds {
// from the engine-facing PlayParams (frames).
struct PlaySeconds {
PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames)
AdsrSeconds adsr; // Gate amp: AHDSR (seconds)
TriggerParams trigger; // Trigger play span (%-length)
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
FilterSeconds filter; // per-voice filter, off by default
};
-10
View File
@@ -14,14 +14,4 @@ std::int64_t triggerPlayLength(double lengthFraction,
return static_cast<std::int64_t>(lengthFraction * static_cast<double>(postStart) + 0.5);
}
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) {
if (playLength <= 0) return 0.0;
return static_cast<double>(fadeFrames) / static_cast<double>(playLength);
}
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) {
if (playLength <= 0) return 0;
return static_cast<std::int64_t>(fadeFraction * static_cast<double>(playLength) + 0.5);
}
} // namespace reasampler::instrument::map
+4 -12
View File
@@ -1,8 +1,7 @@
// trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
// SOURCE FRAMES, anchored to the source-timeline read pointer) and the overlay domain
// (AmpEnvelope: FRACTIONS in [0,1] of the played span, so the drawn shape stays invariant
// across sample-rate changes). Owns the one shared pack/unpack formula so both directions
// stay consistent; reasampler_editor calls these from packEnvelope/unpackEnvelope.
// trigger_seam — the shared Trigger play-span formula: how the stored %-length becomes the
// source-frame span the voice plays and the overlay draws over. One home so the engine's
// note-on resolve and the editor's overlay pack cannot disagree about where a Trigger note
// ends.
//
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
@@ -19,11 +18,4 @@ std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
std::int64_t startFrame);
// PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
// caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
// UNPACK direction (commit path): fraction -> nearest source frame.
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
} // namespace reasampler::instrument::map
+59 -10
View File
@@ -15,6 +15,7 @@ double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1
double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; }
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
const bool trigger = (playMode == PlayMode::Trigger);
std::vector<DeckGroupDesc> out;
{
DeckGroupDesc pitch;
@@ -28,8 +29,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
DeckGroupDesc penv;
penv.id = kGroupPitchEnv;
penv.captionWidth = 58;
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
id(DeckParam::kPitchEnvHold),
id(DeckParam::kPitchEnvDecay),
id(DeckParam::kPitchEnvDepth)};
out.push_back(std::move(penv));
@@ -54,27 +57,35 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
DeckGroupDesc fenv;
fenv.id = kGroupFilterEnv;
fenv.captionWidth = 66;
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
if (trigger) {
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
id(DeckParam::kFilterTrigDecay), -1, -1};
} else {
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
id(DeckParam::kFilterEnvHold),
id(DeckParam::kFilterEnvDecay),
id(DeckParam::kFilterEnvSustain),
id(DeckParam::kFilterEnvRelease)};
}
out.push_back(std::move(fenv));
}
{
DeckGroupDesc amp;
amp.id = kGroupAmpEnv;
amp.captionWidth = 78;
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
if (playMode == PlayMode::Gate) {
if (trigger) {
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
// the row reads like the drawn envelope. One blank keeps the group's width — and
// therefore its neighbours' placement — identical across a mode flip.
amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack),
id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1};
} else {
amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold),
id(DeckParam::kDecay), id(DeckParam::kSustain),
id(DeckParam::kRelease)};
} else {
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
// the drawn envelope), plus two blanks (see knob_deck.h's blank-cell contract).
amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength),
id(DeckParam::kTrigFadeOut), -1, -1};
}
out.push_back(std::move(amp));
}
@@ -97,6 +108,24 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
return out;
}
DeckParam curveParamFor(DeckParam knob) {
switch (knob) {
case DeckParam::kAttack: return DeckParam::kAttackCurve;
case DeckParam::kDecay: return DeckParam::kDecayCurve;
case DeckParam::kRelease: return DeckParam::kReleaseCurve;
case DeckParam::kTrigAttack: return DeckParam::kTrigAttackCurve;
case DeckParam::kTrigDecay: return DeckParam::kTrigDecayCurve;
case DeckParam::kPitchEnvAttack: return DeckParam::kPitchEnvAttackCurve;
case DeckParam::kPitchEnvDecay: return DeckParam::kPitchEnvDecayCurve;
case DeckParam::kFilterEnvAttack: return DeckParam::kFilterEnvAttackCurve;
case DeckParam::kFilterEnvDecay: return DeckParam::kFilterEnvDecayCurve;
case DeckParam::kFilterEnvRelease: return DeckParam::kFilterEnvReleaseCurve;
case DeckParam::kFilterTrigAttack: return DeckParam::kFilterTrigAttackCurve;
case DeckParam::kFilterTrigDecay: return DeckParam::kFilterTrigDecayCurve;
default: return DeckParam::kCount;
}
}
bool isLiveDeckParam(DeckParam id) {
switch (id) {
case DeckParam::kAttack:
@@ -104,7 +133,11 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kDecay:
case DeckParam::kSustain:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigHold:
case DeckParam::kTrigDecay:
case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvHold:
case DeckParam::kPitchEnvDecay:
case DeckParam::kPitchEnvDepth:
case DeckParam::kFilterMorph:
@@ -118,6 +151,21 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
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 true;
// Listed rather than defaulted so a newly added control is a COMPILE error here (the
// -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level)
@@ -125,13 +173,14 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kPlayMode:
case DeckParam::kPitchEngine:
case DeckParam::kTrigLength:
case DeckParam::kTrigFadeIn:
case DeckParam::kTrigFadeOut:
case DeckParam::kPitchEnvEnable:
case DeckParam::kKeyTrack:
case DeckParam::kFilterEnable:
case DeckParam::kFilterVel:
case DeckParam::kFilterLaw:
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect:
case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger:
@@ -142,13 +191,13 @@ bool isLiveDeckParam(DeckParam id) {
return false; // unreachable for a valid enumerator; silences a warning.
}
bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode) {
bool liveCommitFor(LiveDragKind kind, int paramId) {
switch (kind) {
case LiveDragKind::kDeckKnob:
return paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount) &&
isLiveDeckParam(static_cast<DeckParam>(paramId));
case LiveDragKind::kEnvNode:
return playMode == PlayMode::Gate;
return true;
case LiveDragKind::kOther:
return false;
}
+55 -28
View File
@@ -7,7 +7,7 @@
#include <vector>
#include "core/instrument/engine/play_params.h" // PlayMode (the AMP group's Gate/Trigger face)
#include "core/instrument/engine/play_params.h" // PlayMode (the mode-dependent group faces)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc
namespace reasampler::instrument::ui {
@@ -17,18 +17,20 @@ namespace reasampler::instrument::ui {
enum class DeckParam {
kPlayMode = 0, // Gate | Trigger toggle
kPitchEngine, // Varispeed | Preserve toggle
kAttack, // AHDSR attack (Gate) / —
kHold, // AHDSR hold (Gate)
kDecay, // AHDSR decay (Gate)
kSustain, // AHDSR sustain (Gate)
kRelease, // AHDSR release (Gate)
kTrigLength, // Trigger %-length
kTrigFadeIn, // Trigger fade-in
kTrigFadeOut, // Trigger fade-out
kPitchEnvEnable, // AD pitch envelope on|off
kPitchEnvAttack, // AD pitch attack
kPitchEnvDecay, // AD pitch decay
kPitchEnvDepth, // AD pitch depth in +/- semitones
kAttack, // amp AHDSR attack (Gate)
kHold, // amp AHDSR hold (Gate)
kDecay, // amp AHDSR decay (Gate)
kSustain, // amp AHDSR sustain (Gate)
kRelease, // amp AHDSR release (Gate)
kTrigLength, // Trigger play span, % of the post-start length
kTrigAttack, // amp AHD attack (Trigger)
kTrigHold, // amp AHD hold, % of the span left after attack + decay
kTrigDecay, // amp AHD decay (Trigger)
kPitchEnvEnable, // AHD pitch envelope on|off
kPitchEnvAttack,
kPitchEnvHold, // % of the span left after attack + decay
kPitchEnvDecay,
kPitchEnvDepth, // AHD pitch depth in +/- semitones
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
// Filter. The four control positions map through filter_params' own laws; the three
// depths are bipolar and centred at zero.
@@ -41,11 +43,32 @@ enum class DeckParam {
kFilterVel, // velocity -> cutoff, +/-100%
kFilterKeyTrack, // note -> cutoff, 0..200%
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
kFilterEnvAttack,
kFilterEnvAttack, // filter AHDSR (Gate)
kFilterEnvHold,
kFilterEnvDecay,
kFilterEnvSustain,
kFilterEnvRelease,
kFilterTrigAttack, // filter AHD (Trigger)
kFilterTrigHold,
kFilterTrigDecay,
// Curve exponents. These never get a cell of their own — each is the INNER DIAL of the
// stage knob it shapes (see curveParamFor), which is why only sloped stages have one.
kAttackCurve,
kDecayCurve,
kReleaseCurve,
kTrigAttackCurve,
kTrigDecayCurve,
kPitchEnvAttackCurve,
kPitchEnvDecayCurve,
kFilterEnvAttackCurve,
kFilterEnvDecayCurve,
kFilterEnvReleaseCurve,
kFilterTrigAttackCurve,
kFilterTrigDecayCurve,
// Overlay selection radios — transient view state, not parameters.
kAmpEnvSelect,
kPitchEnvSelect,
kFilterEnvSelect,
// Deck-only controls: processor-side per-instance params — routed to the processor
// setters, never through the parameter set.
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
@@ -68,17 +91,24 @@ enum DeckGroupId {
};
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
// two instance-wide groups. `playMode` picks the AMP group's face, via knob_deck's blank-cell
// reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups.
// two instance-wide groups. `playMode` picks the AMP and FILTER ENV groups' faces — AHDSR in
// Gate, AHD in Trigger — via knob_deck's blank-cell reservation (knob_deck.h) so a mode flip
// never reflows the neighbouring groups.
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// The curve-exponent control a stage knob's INNER DIAL edits, or kCount when the knob shapes
// no curve. THE one place the "every stage except Hold and Sustain is sloped" rule is written
// down: a knob with no entry here draws no inner dial and its inner region resolves as an
// ordinary knob grab.
DeckParam curveParamFor(DeckParam knob);
// Whether control `id` is delivered LIVE — straight to the voices that are already sounding —
// rather than through an instrument reload. The line is drawn at continuously-valued playback
// controls, so this is a routing decision at the editor's commit site rather than a property
// of any one knob; moving a control across the line is a change here and nowhere else.
//
// THE home for why each excluded control is excluded. Five continuous controls are outside the
// live set, plus every discrete toggle:
// THE home for why each excluded control is excluded. Three continuous controls are outside
// the live set, plus every discrete toggle and the overlay radios:
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable)
// name a different sound rather than a different setting of one;
// - the three capture-anchored overrides (root, loop span, start frame) name positions in
@@ -86,13 +116,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// - kKeyTrack and kFilterVel feed values a voice latches at note-on by design (the pitch
// ratio and the velocity-curve result), so live delivery would retune or re-gain a note
// already struck;
// - kTrigLength resolves playEnd_, a fact about the note. kTrigFadeIn/kTrigFadeOut are pure
// amplitude shape and would be live-able in principle, but they live in `sample.play` and
// are baked into SampleData at build time — the engine rebuild copies that verbatim, so
// only a reload can deliver them without widening LiveValues. They fold into the AHD
// alongside Gate's, at which point they inherit its routing; until then they reload.
// Consequence, stated plainly: a Trigger-mode instance gets NO live delivery on its amplitude
// controls. Only the filter and pitch-envelope knobs move a sounding Trigger one-shot.
// - kTrigLength resolves playEnd_, a fact about the note, not a setting of it;
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
// inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too.
bool isLiveDeckParam(DeckParam id);
// The editor drag kinds that can commit live, in this pure module's own vocabulary (the
@@ -102,9 +129,9 @@ enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids
// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither
// reaches the enum); an envelope-node drag is live only in Gate, where it edits the AHDSR —
// in Trigger the same drag rewrites the play span, which is not a live control.
bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode);
// reaches the enum); an envelope-node drag is live in either mode, since every stage value it
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
bool liveCommitFor(LiveDragKind kind, int paramId);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
+123 -65
View File
@@ -2,11 +2,17 @@
#include "core/instrument/ui/envelope_edit.h"
#include "core/util/clamp01.h"
#include <algorithm>
#include <cstdlib> // std::abs
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveFromMidLevel;
using util::curveMidLevel;
namespace {
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
@@ -30,49 +36,84 @@ double levelPerPixel(const Rect& area) {
return 1.0 / static_cast<double>(h - 1);
}
// Origin + ReleaseStart are draw-only anchors, not grabbable.
// Origin is a draw-only anchor; so is an AHDSR's ReleaseEnd, which is pinned to the right edge
// (release is dragged from ReleaseStart instead).
bool isDraggable(EnvNode n) {
switch (n) {
case EnvNode::Origin:
case EnvNode::ReleaseStart:
case EnvNode::ReleaseEnd:
return false;
default:
return true;
}
}
// Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
// Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
bool nodeInMode(EnvNode n, EnvMode m) {
// Guards the degenerate baseline's cross-kind vertices, and keeps the sustain-only nodes off an
// AHD. Applied by both the hit-test and the drag resolver.
bool nodeInKind(EnvNode n, EnvKind k) {
switch (n) {
case EnvNode::AttackEnd:
case EnvNode::HoldEnd:
case EnvNode::DecayEnd:
case EnvNode::ReleaseEnd:
return m == EnvMode::Gate;
case EnvNode::FadeInEnd:
case EnvNode::FadeOutStart:
case EnvNode::LengthEnd:
return m == EnvMode::Trigger;
case EnvNode::Origin:
case EnvNode::AttackCurve:
case EnvNode::DecayCurve:
return true;
case EnvNode::ReleaseStart:
case EnvNode::ReleaseCurve:
return k == EnvKind::Ahdsr;
case EnvNode::Origin:
case EnvNode::ReleaseEnd:
return false;
}
return false;
}
// The two endpoint levels of the segment a curve knot shapes. `ok` is false when the segment
// is level (nothing a curve could express), so the drag is a no-op rather than a division.
struct SegmentLevels {
double start = 0.0;
double end = 0.0;
bool ok = false;
};
SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
const double sus = clamp01(env.sustainLevel);
SegmentLevels s;
switch (knot) {
case EnvNode::AttackCurve: s = {0.0, 1.0, true}; break;
case EnvNode::DecayCurve:
s = {1.0, env.kind == EnvKind::Ahdsr ? sus : 0.0, true};
break;
case EnvNode::ReleaseCurve: s = {sus, 0.0, true}; break;
default: return s;
}
if (s.start == s.end) s.ok = false;
return s;
}
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
// curve_law's inverse. Both directions go through the ONE law, which is why the knot and the
// inner dial cannot express different exponents.
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
const Rect& area, int dyPixels) {
const SegmentLevels seg = segmentLevels(grabEnv, knot);
if (!seg.ok) return grabExponent;
const double grabLevel = seg.start + (seg.end - seg.start) * curveMidLevel(grabExponent);
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / (seg.end - seg.start));
}
} // namespace
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
int y) {
NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds,
int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
// coincidence (FadeOutStart overlaps LengthEnd and wins).
// Nearest draggable, kind-matching node within the pick radius wins (Chebyshev distance);
// ties go to the earlier draw-order node. Knots are appended last, so a knot coincident
// with an endpoint handle loses — a drag there stays a time edit.
NodeHit best;
int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
if (!isDraggable(v.node) || !nodeInKind(v.node, env.kind)) continue;
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order
bestDist = dist;
@@ -82,11 +123,11 @@ NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double tota
return best;
}
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
AmpEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
StageEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
const Rect& rect = area.rect;
const double secPerPx = secondsPerPixel(rect, totalSeconds);
@@ -94,63 +135,80 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Over
const double dSec = static_cast<double>(dxPixels) * secPerPx;
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
if (grabEnv.kind == EnvKind::Ahdsr) {
switch (node) {
// Gate: each cumulative-time node edits its own segment duration. Non-negative durations
// 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.
// Each cumulative-time node edits its own segment duration. Non-negative durations
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
out.holdSeconds =
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(rect);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
break;
}
case EnvNode::ReleaseEnd:
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
// Trigger: fades + length are fractions. X pixels convert to a fraction of the played
// span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
// nodes from crossing (each clamps against the other).
case EnvNode::FadeInEnd: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeInFraction,
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::FadeOutStart: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
// FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx)
// lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta.
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeOutFraction,
1.0 - std::max(0.0, grabEnv.fadeInFraction));
out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::LengthEnd: {
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
break;
}
case EnvNode::Origin:
case EnvNode::ReleaseStart:
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
// The release runs from this node to the anchored right edge, so dragging LEFT
// (negative dx) lengthens it — the delta enters with the opposite sign.
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
break;
case EnvNode::DecayCurve:
out.decayCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
break;
case EnvNode::ReleaseCurve:
out.releaseCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
break;
default:
break;
}
return out;
}
// AHD: the x-axis is the waveform's own, so a stage node moves at 1:1 wall-clock scale.
const AhdSplit s = splitAhdSeconds(grabEnv);
switch (node) {
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd: {
// Hold is a fraction of what attack and decay left, so the node's pixel motion
// converts through that remainder. A zero remainder leaves nothing to divide by and
// nothing the drag could express.
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
if (rem <= 0.0) break;
out.holdFraction = clamp01((s.hold + dSec) / rem);
break;
}
case EnvNode::DecayEnd:
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + dSec, 0.0, bounds.maxDecaySeconds);
break;
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
break;
case EnvNode::DecayCurve:
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
break;
default:
break;
}
return out;
}
+32 -35
View File
@@ -1,19 +1,21 @@
// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
// envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
// envelope nodes and their mid-segment curve knots. Mirror of card_drag/waveform_view: drag
// arithmetic lives here, unit-tested outside the DAW; the shell draws handles, captures the
// grab, and feeds pixel deltas back in.
//
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one
// parameter set every paint), so a node drag and a slider edit are two views on one source of
// truth.
// (edit) map + hit-test. Both read/write the same StageEnvelope fields (the shell re-reads the
// one parameter set every paint), so a node drag, a knot drag, and a knob edit are three views
// on one source of truth — structurally, not through a listener chain.
//
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
// slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
// A drag can never produce a param a knob couldn't: time nodes are range-clamped to the same
// per-param [min,max] the knobs enforce (EnvClampBounds, caller-supplied since those maxima
// live shell-side), and a knot resolves through curve_law's own exponent domain.
//
// Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
// Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
// editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
// Time-only nodes drag on X; DecayEnd in an AHDSR drags on both axes (X = decay time, Y =
// sustain level); a curve knot drags on Y alone. Origin is never draggable, and neither is an
// AHDSR's ReleaseEnd — it is anchored to the right edge, and release is dragged from
// ReleaseStart instead. A node is only editable in its own kind.
#pragma once
@@ -21,7 +23,7 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvKind, StageEnvelope, EnvVertex
namespace reasampler::instrument::ui {
@@ -29,45 +31,40 @@ namespace reasampler::instrument::ui {
// kMarkerGrabWidth.
inline constexpr int kNodeGrabRadius = 6;
// Per-param clamp bounds the shell supplies — the same maxima its sliders map [0,1] onto.
// Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
// Defaults are placeholders; the shell overrides with its live slider domain.
// Per-param clamp bounds the shell supplies — the same maxima its knobs map [0,1] onto.
// Lower bound is always 0. Defaults are placeholders; the shell overrides with its live domain.
struct EnvClampBounds {
double maxAttackSeconds = 4.0;
double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 4.0;
double maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0;
double maxLengthFraction = 1.0;
// sustainLevel is always [0,1] — no shell knob needed.
// sustainLevel is always [0,1] and the hold FRACTION is always [0,1] — no shell knob needed.
};
// Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
// Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
// wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
// for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
// can be dragged open from zero). Gate nodes never coincide (forward map enforces
// kGateNodeSepPx), so every Gate handle is independently grabbable.
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node.
// Nearest node within the radius wins (Chebyshev distance); an exact tie goes to the earlier
// draw-order node, and since knots are appended last, a coincident endpoint handle wins over a
// knot rather than the drag silently becoming a curve edit.
struct NodeHit {
bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
};
// Takes the waveform overlay (not a lane) — see waveform_view.h's overlay contract.
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
int y);
NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds,
int x, int y);
// Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
// shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
// `dyPixels` is the pixel delta since grab.
// * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
// [0, per-param max] and to its monotonic-in-time neighbours.
// * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
// * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
// Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time
// (the shell snapshots it on button-down so the delta is absolute, not accumulated);
// `dxPixels`/`dyPixels` is the pixel delta since grab.
// * X delta -> the node's time param, at the same scale the forward map drew it, clamped to
// [0, per-param max].
// * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve
// exponent. Ignored for time-only nodes.
// * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns
// `grabEnv` unchanged.
// Only the dragged node's param(s) change. Pure.
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
+103 -59
View File
@@ -9,6 +9,8 @@
namespace reasampler::instrument::ui {
using util::clamp01;
using util::curveMap;
using util::curveMidLevel;
int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width);
@@ -21,20 +23,14 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
return area.x + static_cast<int>(px + 0.5);
}
int gateTimedWidth(const Rect& area) {
const int w = std::max(0, area.width);
if (w <= 0) return 0;
const int sustainPx =
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
return std::max(1, w - sustainPx);
}
double gatePxPerSecond(const Rect& area) {
const int timedW = gateTimedWidth(area);
if (timedW <= 0) return 0.0;
// Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
const double usable =
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
const int w = std::max(0, area.width);
if (w <= 0) return 0.0;
// The four timed stages share the canvas minus their four separation bases and the last
// in-bounds column; whatever they leave IS the sustain plateau, which is why a zero release
// puts the plateau's end one separation short of the right edge rather than a fixed
// fraction of the way across.
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds);
}
@@ -50,20 +46,37 @@ int levelToY(const Rect& area, double level) {
return area.y + static_cast<int>(dy);
}
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
AhdSplit out;
const double span = std::max(0.0, env.spanSeconds);
double a = std::max(0.0, env.attackSeconds);
if (a > span) a = span;
double d = std::max(0.0, env.decaySeconds);
if (d > span - a) d = span - a;
const double remaining = span - a - d;
out.attack = a;
out.decay = d;
out.hold = remaining * clamp01(env.holdFraction);
out.total = out.attack + out.hold + out.decay;
return out;
}
namespace {
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level,
bool knot = false) {
EnvVertex v;
v.node = node;
v.x = timeToX(area, totalSeconds, t);
v.y = levelToY(area, level);
v.level = level;
v.knot = knot;
return v;
}
// Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
// timeToX map; clamps in double space before the int cast for the same overflow reason as above.
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
// The AHDSR schematic works in px space rather than the plain timeToX map; clamps in double
// space before the int cast for the same overflow reason as timeToX.
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool knot = false) {
const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0;
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
@@ -72,10 +85,27 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
v.x = area.x + static_cast<int>(px + 0.5);
v.y = levelToY(area, level);
v.level = level;
v.knot = knot;
return v;
}
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
// pixel midpoint. Its level is the curve's own value at the segment midpoint, which is what
// makes the knot's height and the inner dial two readings of one exponent.
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
double endLevel, double exponent) {
const double u = curveMidLevel(exponent);
const double level = startLevel + (endLevel - startLevel) * u;
EnvVertex v;
v.node = node;
v.x = (x0 + x1) / 2;
v.y = levelToY(area, level);
v.level = level;
v.knot = true;
return v;
}
std::vector<EnvVertex> gatePolyline(const StageEnvelope& env, const Rect& area) {
// Clamp defensively — a stored negative duration would be an upstream bug.
const double a = std::max(0.0, env.attackSeconds);
const double h = std::max(0.0, env.holdSeconds);
@@ -83,73 +113,87 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel);
// A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
// kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay
// defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart.
const int W = std::max(1, area.width);
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx);
const double pps = gatePxPerSecond(area);
double xAttack = sep + a * pps; // AttackEnd
double xHold = xAttack + sep + h * pps; // HoldEnd
double xDecay = xHold + sep + d * pps; // DecayEnd (sustain node)
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
// Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
// trailing nodes stay separated instead of piling on the last column. This re-floor only
// bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
const double xMax = static_cast<double>(W - 1);
if (xRelease > xMax) {
xRelease = xMax;
xPlateau = std::min(xPlateau, xRelease - sep);
xDecay = std::min(xDecay, xPlateau - sustainPx);
// The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart —
// the sustain->release join, and the node the user drags — sits a release-length to its
// left. Everything the release does not take is the sustain plateau, so a zero release
// leaves the plateau running to within one separation of the edge.
double xAttack = sep + a * pps;
double xHold = xAttack + sep + h * pps;
double xDecay = xHold + sep + d * pps;
double xPlateau = xMax - sep - r * pps;
const double xRelease = xMax;
// Keep every node separated when the four stages together would overrun the canvas: the
// plateau holds its minimum gap from the edge, then the A/H/D chain compresses from the
// right and re-floors from the left. This only bites at the domain's extremes; gateVtx's
// own clamp wins on a canvas too narrow to hold the gaps at all.
if (xPlateau < xDecay + sep) {
if (xPlateau < 4.0 * sep) xPlateau = 4.0 * sep;
xDecay = std::min(xDecay, xPlateau - sep);
xHold = std::min(xHold, xDecay - sep);
xAttack = std::min(xAttack, xHold - sep);
xAttack = std::max(xAttack, sep);
xHold = std::max(xHold, xAttack + sep);
xDecay = std::max(xDecay, xHold + sep);
xPlateau = std::max(xPlateau, xDecay + sustainPx);
xRelease = std::max(xRelease, xPlateau + sep);
xPlateau = std::max(xPlateau, xDecay + sep);
}
std::vector<EnvVertex> pts;
pts.reserve(6);
pts.reserve(9);
pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0));
pts.push_back(gateVtx(EnvNode::AttackEnd, area, xAttack, 1.0));
pts.push_back(gateVtx(EnvNode::HoldEnd, area, xHold, 1.0));
pts.push_back(gateVtx(EnvNode::DecayEnd, area, xDecay, sus)); // sustain node
pts.push_back(gateVtx(EnvNode::ReleaseStart, area, xPlateau, sus)); // plateau end
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0));
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0)); // anchored
// Knots ride only SLOPED stages that actually have a duration — a zero-length stage has no
// interior to place a handle in, and one there would collide with its own endpoints.
if (a > 0.0) {
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
env.attackCurve));
}
if (d > 0.0) {
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, sus,
env.decayCurve));
}
if (r > 0.0) {
pts.push_back(knotVtx(EnvNode::ReleaseCurve, area, pts[4].x, pts[5].x, sus, 0.0,
env.releaseCurve));
}
return pts;
}
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
std::vector<EnvVertex> ahdPolyline(const StageEnvelope& env, const Rect& area,
double totalSeconds) {
// Played span is lengthFraction of the whole sample; fades are fractions of that span.
const double len = clamp01(env.lengthFraction);
double fadeIn = clamp01(env.fadeInFraction);
double fadeOut = clamp01(env.fadeOutFraction);
// Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp.
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
const double playSeconds = len * totalSeconds;
const double tFadeInEnd = fadeIn * playSeconds;
const double tFadeOutStart = playSeconds - fadeOut * playSeconds; // where fade-out begins
const AhdSplit s = splitAhdSeconds(env);
const double t0 = std::max(0.0, env.originSeconds);
std::vector<EnvVertex> pts;
pts.reserve(4);
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
pts.push_back(vtx(EnvNode::FadeInEnd, area, totalSeconds, tFadeInEnd, 1.0));
pts.push_back(vtx(EnvNode::FadeOutStart, area, totalSeconds, tFadeOutStart, 1.0)); // unity end
pts.push_back(vtx(EnvNode::LengthEnd, area, totalSeconds, playSeconds, 0.0)); // playEnd
pts.reserve(6);
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, t0, 0.0));
pts.push_back(vtx(EnvNode::AttackEnd, area, totalSeconds, t0 + s.attack, 1.0));
pts.push_back(vtx(EnvNode::HoldEnd, area, totalSeconds, t0 + s.attack + s.hold, 1.0));
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0));
if (s.attack > 0.0) {
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
env.attackCurve));
}
if (s.decay > 0.0) {
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, 0.0,
env.decayCurve));
}
return pts;
}
} // namespace
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
double totalSeconds) {
const Rect& rect = area.rect;
if (rect.width <= 0 || rect.height <= 0 || totalSeconds <= 0.0) {
@@ -157,8 +201,8 @@ std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Overl
return {vtx(EnvNode::Origin, rect, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, rect, 1.0, 1.0, 0.0)};
}
return env.mode == EnvMode::Gate ? gatePolyline(env, rect)
: triggerPolyline(env, rect, totalSeconds);
return env.kind == EnvKind::Ahdsr ? gatePolyline(env, rect)
: ahdPolyline(env, rect, totalSeconds);
}
} // namespace reasampler::instrument::ui
+67 -50
View File
@@ -1,8 +1,7 @@
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
// envelope_overlay.h — staged-envelope -> polyline geometry for the Sample-view overlay.
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
// param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
// AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
// hit-test).
// param_slider. The shell packs whichever envelope is overlay-active into StageEnvelope and
// draws the polyline plus a handle at each node (envelope_edit does the hit-test).
#pragma once
@@ -10,92 +9,98 @@
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
#include "core/util/curve_law.h" // the ONE per-segment curve law
namespace reasampler::instrument::ui {
// Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
enum class EnvMode { Gate, Trigger };
// Which LAYOUT POLICY an envelope takes, decided by whether it has a sustain stage rather
// than by which processor it modulates. A gated (AHDSR) envelope right-anchors its release so
// the sustain plateau reads full-width; a sustain-less (AHD) one maps 1:1 onto the waveform's
// own time axis, which only means anything for a trigger shape. The two policies coexist.
enum class EnvKind { Ahdsr, Ahd };
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
// Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
// AHD nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd.
// The three *Curve nodes are the round mid-segment knots whose vertical drag sets that
// segment's curve exponent. Shared by envelope_overlay (forward/draw) and envelope_edit
// (inverse/edit).
enum class EnvNode {
Origin, // t=0, level 0 — not draggable
AttackEnd, // Gate: attack ramp top — sets attackSeconds
HoldEnd, // Gate: hold plateau end — sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
ReleaseEnd, // Gate: release tail end — sets releaseSeconds
FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
AttackEnd, // attack ramp top — sets attackSeconds
HoldEnd, // hold plateau end — AHDSR: holdSeconds; AHD: holdFraction
DecayEnd, // AHDSR: decay settles to sustain (X = decay, Y = sustain); AHD: decay end
ReleaseStart, // AHDSR: sustain plateau end — sets releaseSeconds (drags on X, inverted)
ReleaseEnd, // AHDSR: the envelope's end point — ANCHORED to the right edge, not draggable
AttackCurve, // mid-attack knot — sets attackCurve
DecayCurve, // mid-decay knot — sets decayCurve
ReleaseCurve, // mid-release knot — sets releaseCurve (AHDSR only)
};
// Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
// TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
// core/instrument/CLAUDE.md.
struct AmpEnvelope {
EnvMode mode = EnvMode::Gate;
// The envelope the overlay draws. One struct for both policies: `kind` selects which fields
// are read, so a single pack/unpack pair serves the amp, pitch, and filter envelopes.
struct StageEnvelope {
EnvKind kind = EnvKind::Ahdsr;
// Gate (AHDSR): seconds, plus a dimensionless sustain level.
// AHDSR: seconds at the schematic param-domain scale, plus a dimensionless sustain level.
double attackSeconds = 0.003;
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060;
// Trigger: fractions of the played span.
double lengthFraction = 1.0;
double fadeInFraction = 0.0;
double fadeOutFraction = 0.0;
// AHD: attack/decay seconds plus the Hold FRACTION of the span left after them, laid over
// [originSeconds, originSeconds + spanSeconds) of the waveform's own time axis.
double holdFraction = 1.0;
double originSeconds = 0.0;
double spanSeconds = 0.0;
// Per-segment curve exponents (release is AHDSR-only). curve_law.h owns the domain.
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
double releaseCurve = util::kCurveNeutral;
};
// One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
// inspection.
// One polyline vertex: pixel point plus which node it is. `level` is redundant with y, carried
// for inspection. `knot` marks the round mid-segment curve handles, which draw differently and
// are not part of the traced line.
struct EnvVertex {
EnvNode node = EnvNode::Origin;
int x = 0;
int y = 0;
double level = 0.0;
bool knot = false;
bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level;
return node == o.node && x == o.x && y == o.y && level == o.level && knot == o.knot;
}
};
// Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
// carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
inline constexpr double kGateSustainDisplayFraction = 0.15;
// Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
// Minimum pixel separation between consecutive AHDSR nodes, so zero-duration stages (tier-0
// defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
// radius (6) so a click can never tie between neighbours.
inline constexpr int kGateNodeSepPx = 8;
// Gate schematic's per-stage time domain (seconds) — the timed region represents four stages
// end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
// lands exactly at the canvas edge.
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
inline constexpr double kGateStageMaxSeconds = 2.0;
// Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
// a non-empty area; 0 for a zero/negative-width area.
int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region, independent of the sample's actual duration.
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// the cursor 1:1.
double gatePxPerSecond(const Rect& area);
// Maps an amp 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); vertices
// are in draw order, Origin first.
// 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
// traced vertices come first in draw order (Origin first), then the curve knots.
//
// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
// The AHDSR x-axis is a bounded schematic independent of totalSeconds (it does NOT line up with
// the waveform under it) with its ReleaseEnd anchored to the right edge; the AHD x-axis is
// wall-clock, 1:1 with the waveform. Every vertex is clamped inside the canvas: x in
// [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area or
// totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
// waveform overlay (not a lane) — see waveform_view.h's overlay contract.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
double totalSeconds);
// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
@@ -106,4 +111,16 @@ int timeToX(const Rect& area, double totalSeconds, double t);
// clamped. Shared with envelope_edit's node hit-test.
int levelToY(const Rect& area, double level);
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
// the sum past the span; there is no clamp on the sum because none is possible.
struct AhdSplit {
double attack = 0.0;
double hold = 0.0;
double decay = 0.0;
double total = 0.0;
};
AhdSplit splitAhdSeconds(const StageEnvelope& env);
} // namespace reasampler::instrument::ui
+24 -4
View File
@@ -20,10 +20,11 @@ int knobRowWidth(const DeckGroupDesc& g) {
return w;
}
// The caption-row width: the caption reserve plus the optional caption toggle.
// The caption-row width: the caption reserve plus the optional caption toggle and radio.
int captionRowWidth(const DeckGroupDesc& g) {
int w = g.captionWidth;
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
return w;
}
@@ -37,12 +38,22 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int innerLeft = box.x + kDeckGroupPadX;
const int innerRight = box.right() - kDeckGroupPadX;
// Caption row: text left, compact toggle right-anchored.
// Caption row: text left, then the compact toggle, then the corner radio at the far edge.
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
int captionRight = innerRight;
if (g.captionRadio.id >= 0) {
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
out.captionRadio = DeckRadioLayout{
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
radioTop + kDeckRadioSize)};
captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x;
}
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH);
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
togTop + kDeckToggleH);
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
@@ -59,6 +70,10 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
innerTopPx + kDeckInnerDialSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c);
@@ -133,6 +148,9 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue;
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
}
if (g.captionToggle.id >= 0) {
if (contains(g.captionToggle.seg0, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
@@ -146,7 +164,9 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
}
for (const DeckCellLayout& c : g.cells) {
if (c.id >= 0 && contains(c.cell, x, y)) return {DeckHitKind::Knob, c.id, -1};
if (c.id >= 0 && contains(c.cell, x, y)) {
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
}
}
return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap)
}
+26 -5
View File
@@ -35,6 +35,11 @@ inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
inline constexpr int kDeckInnerDialSize = 14;
// One group box: padding + caption + gap + cell row + padding.
inline constexpr int kDeckGroupH =
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
@@ -45,13 +50,20 @@ struct DeckToggleDesc {
int segWidth = 44; // px per segment
};
// A single-square corner radio (an exclusive selector across groups, so the group itself
// carries no state). id -1 = absent.
struct DeckRadioDesc {
int id = -1;
};
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
// reserves for the caption text (this module does not measure text).
struct DeckGroupDesc {
int id = 0; // shell group id (opaque here)
int captionWidth = 60;
DeckToggleDesc captionToggle; // right-anchored in the caption row; id -1 = none
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
std::vector<int> cellIds; // knob cells; -1 = blank reserve
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
};
@@ -64,10 +76,16 @@ struct DeckToggleLayout {
Rect seg1; // right segment
};
struct DeckRadioLayout {
int id = -1;
Rect box;
};
struct DeckCellLayout {
int id = -1;
Rect cell; // the full 48x58 cell
Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it)
Rect inner; // the concentric kDeckInnerDialSize square inside `knob`
Rect label; // the 12px label band beneath the knob
};
@@ -75,6 +93,7 @@ struct DeckGroupLayout {
int id = 0;
Rect box; // the fenced group box
Rect caption; // caption text rect (left part of the caption row)
DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent
@@ -105,17 +124,19 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
// --- Hit-test --------------------------------------------------------------------------
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle };
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio };
struct DeckHit {
DeckHitKind kind = DeckHitKind::None;
int id = -1; // the control id of the hit element (cell id / toggle id)
int id = -1; // the control id of the hit element (cell id / toggle id / radio id)
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
bool inner = false; // Knob hits only: the grab landed on the cell's inner dial
};
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
// circle — the shell anchors the vertical drag wherever the grab lands), a caption-toggle
// segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
// the caption-row corner radio. Blank cells (id -1) and everything else miss.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui
+8 -1
View File
@@ -3,12 +3,19 @@
## Scope
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
loading, unit-interval clamping, and the absolute-path rejection test.
loading, unit-interval clamping, the absolute-path rejection test, and the
per-segment envelope curve law.
## Modules
- `file_bytes` (`core/util`) — the ONE whole-file byte loader (Q-W1), linked by both artifacts; blocking I/O, off-audio-thread only.
- `clamp01` (`core/util`, header-only) — the ONE unit-interval clamp (Q-W1), replacing four per-module static copies; NaN passes through unchanged rather than collapsing to a bound.
- `curve_law` (`core/util`, header-only) — the ONE per-segment envelope curve law: the
exponent domain (0.1..10, neutral 1.0), the normalized-position -> normalized-level map, and
the mid-segment inverse an overlay knot drags through. Header-only and dependency-free so
the engine's evaluator, the overlay's forward map, its inverse, and the deck's inner dial all
read one law instead of four copies. **The neutral exponent is the IDENTITY, bit for bit**
that is what makes an instance saved before curves existed play unchanged.
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
## Gotchas
+5
View File
@@ -1,2 +1,7 @@
reasampler_pure_library(file_bytes SOURCES file_bytes.cpp)
reasampler_test(file_bytes LINK file_bytes)
# The per-segment envelope curve law is header-only, hence INTERFACE.
add_library(curve_law INTERFACE)
target_include_directories(curve_law INTERFACE ${REASAMPLER_SRC_DIR})
reasampler_test(curve_law LINK curve_law)
+47
View File
@@ -0,0 +1,47 @@
#pragma once
// curve_law — the ONE per-segment envelope curve law: the exponent domain, the map from a
// stage's normalized position to its normalized level, and the mid-segment inverse the
// overlay knot drags through. Header-only and dependency-free so the engine evaluator, the
// overlay's forward map, and its inverse all read the same law rather than three copies.
#include <cmath>
namespace reasampler::util {
// The per-segment curve is exponential: level = phi^exponent over the stage's normalized
// position phi. 1.0 is the LINEAR neutral (phi^1 == phi), which is why a pre-existing
// instance loading at 1.0 plays exactly as it did.
inline constexpr double kCurveNeutral = 1.0;
inline constexpr double kCurveMin = 0.1;
inline constexpr double kCurveMax = 10.0;
// Normalized position -> normalized level. The neutral exponent is compared EXACTLY so the
// at-rest per-sample path pays one predicted branch instead of a transcendental; every
// positive exponent maps 0 -> 0 and 1 -> 1, so a curved stage can never overshoot its own
// endpoint levels.
inline double curveMap(double phi, double exponent) {
if (exponent == kCurveNeutral) return phi;
return std::pow(phi, exponent);
}
inline double clampCurve(double exponent) {
if (!(exponent >= kCurveMin)) return kCurveMin; // also catches NaN
return exponent > kCurveMax ? kCurveMax : exponent;
}
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
// one value: both resolve through this law, not through each other.
inline double curveMidLevel(double exponent) { return curveMap(0.5, clampCurve(exponent)); }
// Mid-level -> exponent: u = 0.5^p, so p = ln(u)/ln(0.5). Out-of-domain u clamps to the
// exponent endpoints rather than producing a non-finite exponent.
inline double curveFromMidLevel(double midLevel) {
const double lo = curveMidLevel(kCurveMax); // smallest reachable mid-level
const double hi = curveMidLevel(kCurveMin); // largest
if (!(midLevel > lo)) return kCurveMax; // also catches NaN
if (midLevel >= hi) return kCurveMin;
return clampCurve(std::log(midLevel) / std::log(0.5));
}
} // namespace reasampler::util
+220 -98
View File
@@ -1,12 +1,14 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
// applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding
// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (the
// trigger-seam converter). Value logic only — no painting, no window plumbing.
// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (which
// stored struct each overlay selection maps onto). Value logic only — no painting, no window
// plumbing.
#include "shell/instrument/reasampler_editor.h"
#include <algorithm>
#include <cmath> // log/exp (the curve knob's logarithmic travel)
#include <cstdint>
#include <cstdio> // snprintf (deck value labels)
#include <string>
@@ -14,17 +16,17 @@
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the Trigger play span)
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
#include "core/util/clamp01.h"
#include "core/util/curve_law.h" // the ONE curve-exponent domain
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam converters
using instrument::ui::EnvMode; // envelope_overlay's mode enum
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam
using instrument::ui::computeSampleBands;
using instrument::ui::chromeRects;
using instrument::ui::deckHeight;
@@ -44,17 +46,26 @@ using util::clamp01;
namespace {
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps
// only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds]
// seconds — rate-free, exactly what the parameter set stores; the build resolves seconds->frames
// at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames
// (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent —
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in
// rate constant, per the no-hardcoded-rate ruling.
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock)
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
// only 0..1). Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly
// what the parameter set stores; the build resolves seconds->frames at the live rate. No knob
// on this surface stores a source-frame count any more, so none needs a rate to draw.
constexpr double kEnvTimeMaxSeconds = 2.0; // every stage-time knob's ceiling (seconds)
constexpr double kPitchDepthMaxSemis = 24.0; // pitch depth throw: +/-24 st, centered
constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%)
// A curve exponent's knob travel is LOGARITHMIC: 0.5 is the linear neutral, so the two halves
// of the throw are the reciprocal shaping directions and the neutral sits at a centre detent.
double curveFromNorm(double norm) {
const double t = clamp01(norm);
return std::exp(std::log(util::kCurveMin) +
t * (std::log(util::kCurveMax) - std::log(util::kCurveMin)));
}
double normFromCurve(double curve) {
const double c = util::clampCurve(curve);
return clamp01((std::log(c) - std::log(util::kCurveMin)) /
(std::log(util::kCurveMax) - std::log(util::kCurveMin)));
}
} // namespace
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
@@ -69,16 +80,9 @@ ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
}
double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A
// stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
// pins it) and gets rewritten down on the next knob touch.
const double fadeMax = fadeMaxFrames();
// Wall-clock seconds -> normalized over the seconds ceiling; fractions and normalized
// control positions pass through; curve exponents take the log travel.
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
const auto framesToNorm = [fadeMax](std::int64_t f) {
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
return fadeMax > 0.0 ? clamp01(static_cast<double>(f) / fadeMax) : 0.0;
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
@@ -87,12 +91,23 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
case ParamControl::kAttackCurve: return normFromCurve(play.adsr.attackCurve);
case ParamControl::kDecayCurve: return normFromCurve(play.adsr.decayCurve);
case ParamControl::kReleaseCurve: return normFromCurve(play.adsr.releaseCurve);
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames);
case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames);
case ParamControl::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
case ParamControl::kTrigHold: return clamp01(play.trigAhd.holdFraction);
case ParamControl::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
case ParamControl::kTrigAttackCurve: return normFromCurve(play.trigAhd.attackCurve);
case ParamControl::kTrigDecayCurve: return normFromCurve(play.trigAhd.decayCurve);
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.shape.attackSeconds);
case ParamControl::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
case ParamControl::kPitchEnvAttackCurve:
return normFromCurve(play.pitchEnv.shape.attackCurve);
case ParamControl::kPitchEnvDecayCurve:
return normFromCurve(play.pitchEnv.shape.decayCurve);
case ParamControl::kPitchEnvDepth:
// Signed depth centered at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
@@ -113,19 +128,26 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
case ParamControl::kFilterEnvDecay: return secToNorm(play.filter.env.decaySeconds);
case ParamControl::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
case ParamControl::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
case ParamControl::kFilterEnvAttackCurve:
return normFromCurve(play.filter.env.attackCurve);
case ParamControl::kFilterEnvDecayCurve:
return normFromCurve(play.filter.env.decayCurve);
case ParamControl::kFilterEnvReleaseCurve:
return normFromCurve(play.filter.env.releaseCurve);
case ParamControl::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
case ParamControl::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
case ParamControl::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
case ParamControl::kFilterTrigAttackCurve:
return normFromCurve(play.filter.trigEnv.attackCurve);
case ParamControl::kFilterTrigDecayCurve:
return normFromCurve(play.filter.trigEnv.decayCurve);
default: return 0.0;
}
}
void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
int segment) const {
const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
const auto normToFrames = [fadeMax](double v) -> std::int64_t {
// Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves.
if (fadeMax <= 0.0) return 0;
return static_cast<std::int64_t>(clamp01(v) * fadeMax + 0.5);
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode:
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
@@ -138,17 +160,33 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
case ParamControl::kAttackCurve: play.adsr.attackCurve = curveFromNorm(value); break;
case ParamControl::kDecayCurve: play.adsr.decayCurve = curveFromNorm(value); break;
case ParamControl::kReleaseCurve: play.adsr.releaseCurve = curveFromNorm(value); break;
case ParamControl::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break;
case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break;
case ParamControl::kTrigAttack: play.trigAhd.attackSeconds = normToSec(value); break;
case ParamControl::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
case ParamControl::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
case ParamControl::kTrigAttackCurve:
play.trigAhd.attackCurve = curveFromNorm(value); break;
case ParamControl::kTrigDecayCurve:
play.trigAhd.decayCurve = curveFromNorm(value); break;
case ParamControl::kPitchEnvEnable:
play.pitchEnv.enabled = (segment == 1);
break;
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvAttack:
play.pitchEnv.shape.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvHold:
play.pitchEnv.shape.holdFraction = clamp01(value); break;
case ParamControl::kPitchEnvDecay:
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvAttackCurve:
play.pitchEnv.shape.attackCurve = curveFromNorm(value); break;
case ParamControl::kPitchEnvDecayCurve:
play.pitchEnv.shape.decayCurve = curveFromNorm(value); break;
case ParamControl::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
@@ -179,6 +217,22 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
play.filter.env.sustainLevel = clamp01(value); break;
case ParamControl::kFilterEnvRelease:
play.filter.env.releaseSeconds = normToSec(value); break;
case ParamControl::kFilterEnvAttackCurve:
play.filter.env.attackCurve = curveFromNorm(value); break;
case ParamControl::kFilterEnvDecayCurve:
play.filter.env.decayCurve = curveFromNorm(value); break;
case ParamControl::kFilterEnvReleaseCurve:
play.filter.env.releaseCurve = curveFromNorm(value); break;
case ParamControl::kFilterTrigAttack:
play.filter.trigEnv.attackSeconds = normToSec(value); break;
case ParamControl::kFilterTrigHold:
play.filter.trigEnv.holdFraction = clamp01(value); break;
case ParamControl::kFilterTrigDecay:
play.filter.trigEnv.decaySeconds = normToSec(value); break;
case ParamControl::kFilterTrigAttackCurve:
play.filter.trigEnv.attackCurve = curveFromNorm(value); break;
case ParamControl::kFilterTrigDecayCurve:
play.filter.trigEnv.decayCurve = curveFromNorm(value); break;
default: break;
}
}
@@ -187,18 +241,6 @@ double ReaSamplerEditor::liveSampleRate() const {
return processor_ ? processor_->sampleRate() : 0.0;
}
double ReaSamplerEditor::fadeMaxFrames() const {
// The Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) resolved
// against the live rate — the same time base the envelope overlay already uses to place
// these source-frame fades on screen. Pre-setupProcessing the rate is still 0: rather than
// substitute a literal rate, callers treat a <= 0 return as "ceiling unavailable yet" and
// degrade the knob to inert rather than guess a rate. Storage stays source frames — this
// resolves the UI ceiling only.
const double rate = liveSampleRate();
if (rate <= 0.0) return 0.0;
return kFadeMaxSeconds * rate;
}
double ReaSamplerEditor::previewVelocity01() const {
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
return static_cast<double>(processor_->previewVelocity()) / 127.0;
@@ -267,16 +309,18 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break;
case ParamControl::kTrigLength:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break;
case ParamControl::kTrigFadeIn:
snprintf(buf, sizeof(buf), "%lldf",
static_cast<long long>(play.trigger.fadeInFrames)); break;
case ParamControl::kTrigFadeOut:
snprintf(buf, sizeof(buf), "%lldf",
static_cast<long long>(play.trigger.fadeOutFrames)); break;
case ParamControl::kTrigAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.trigAhd.attackSeconds); break;
case ParamControl::kTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.trigAhd.holdFraction * 100.0); break;
case ParamControl::kTrigDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.trigAhd.decaySeconds); break;
case ParamControl::kPitchEnvAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break;
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.shape.attackSeconds); break;
case ParamControl::kPitchEnvHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.pitchEnv.shape.holdFraction * 100.0); break;
case ParamControl::kPitchEnvDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break;
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.shape.decaySeconds); break;
case ParamControl::kPitchEnvDepth:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
case ParamControl::kKeyTrack:
@@ -322,6 +366,27 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.env.sustainLevel * 100.0); break;
case ParamControl::kFilterEnvRelease:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.env.releaseSeconds); break;
case ParamControl::kFilterTrigAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.trigEnv.attackSeconds); break;
case ParamControl::kFilterTrigHold:
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.trigEnv.holdFraction * 100.0); break;
case ParamControl::kFilterTrigDecay:
snprintf(buf, sizeof(buf), "%.3fs", play.filter.trigEnv.decaySeconds); break;
// Every curve exponent reads the same way: the neutral shows as 1.00.
case ParamControl::kAttackCurve:
case ParamControl::kDecayCurve:
case ParamControl::kReleaseCurve:
case ParamControl::kTrigAttackCurve:
case ParamControl::kTrigDecayCurve:
case ParamControl::kPitchEnvAttackCurve:
case ParamControl::kPitchEnvDecayCurve:
case ParamControl::kFilterEnvAttackCurve:
case ParamControl::kFilterEnvDecayCurve:
case ParamControl::kFilterEnvReleaseCurve:
case ParamControl::kFilterTrigAttackCurve:
case ParamControl::kFilterTrigDecayCurve:
snprintf(buf, sizeof(buf), "^%.2f", curveFromNorm(controlValue(id, play)));
break;
default:
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
break;
@@ -330,61 +395,118 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
}
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
// Match the control-panel sliders' own domains so a node drag can never produce a param a
// slider couldn't. AHDSR seconds cap at kEnvTimeMaxSeconds; the Trigger fade/length
// fractions cap at 1.0 (the natural full-span bound the sliders use).
// Match the deck knobs' own domains so a node drag can never produce a param a knob
// couldn't. Every stage time caps at kEnvTimeMaxSeconds; the Hold fractions and the sustain
// level are [0,1] by definition and need no bound here.
EnvClampBounds b;
b.maxAttackSeconds = kEnvTimeMaxSeconds;
b.maxHoldSeconds = kEnvTimeMaxSeconds;
b.maxDecaySeconds = kEnvTimeMaxSeconds;
b.maxReleaseSeconds = kEnvTimeMaxSeconds;
b.maxFadeInFraction = 1.0;
b.maxFadeOutFraction = 1.0;
b.maxLengthFraction = 1.0;
return b;
}
AmpEnvelope ReaSamplerEditor::packEnvelope(const PlaySeconds& play, std::int64_t frames,
ReaSamplerEditor::OverlayEnv ReaSamplerEditor::overlayEnvForRadio(int radioId) {
switch (static_cast<ParamControl>(radioId)) {
case ParamControl::kAmpEnvSelect: return OverlayEnv::kAmp;
case ParamControl::kPitchEnvSelect: return OverlayEnv::kPitch;
case ParamControl::kFilterEnvSelect: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
namespace {
// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
// one place rather than two.
void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahdsr;
env.attackSeconds = a.attackSeconds;
env.holdSeconds = a.holdSeconds;
env.decaySeconds = a.decaySeconds;
env.sustainLevel = a.sustainLevel;
env.releaseSeconds = a.releaseSeconds;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.releaseCurve = a.releaseCurve;
}
void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.holdSeconds = env.holdSeconds;
a.decaySeconds = env.decaySeconds;
a.sustainLevel = env.sustainLevel;
a.releaseSeconds = env.releaseSeconds;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
a.releaseCurve = env.releaseCurve;
}
void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahd;
env.attackSeconds = a.attackSeconds;
env.decaySeconds = a.decaySeconds;
env.holdFraction = a.holdFraction;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.originSeconds = originSeconds;
env.spanSeconds = spanSeconds;
}
void unpackAhd(const StageEnvelope& env, AhdSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.decaySeconds = env.decaySeconds;
a.holdFraction = env.holdFraction;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
}
} // namespace
StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play,
std::int64_t frames,
std::int64_t startFrame) const {
AmpEnvelope env;
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
// AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws).
env.attackSeconds = play.adsr.attackSeconds;
env.holdSeconds = play.adsr.holdSeconds;
env.decaySeconds = play.adsr.decaySeconds;
env.sustainLevel = play.adsr.sustainLevel;
env.releaseSeconds = play.adsr.releaseSeconds;
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played
// span (the trigger-seam converter, pack direction). startFrame is the effective start
// point so the fraction denominator matches the voice's actual post-start span. A zero play
// length yields 0 fractions.
env.lengthFraction = play.trigger.lengthFraction;
StageEnvelope env;
const double rate = liveSampleRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen);
env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen);
const double playSpan = rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0;
const double fullSpan =
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
: 0.0;
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
packAhd(play.pitchEnv.shape, t0, fullSpan, env);
break;
case OverlayEnv::kFilter:
if (trigger) packAhd(play.filter.trigEnv, t0, playSpan, env);
else packAhdsr(play.filter.env, env);
break;
case OverlayEnv::kAmp:
case OverlayEnv::kNone:
if (trigger) packAhd(play.trigAhd, t0, playSpan, env);
else packAhdsr(play.adsr, env);
break;
}
return env;
}
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
std::int64_t startFrame, PlaySeconds& play) const {
if (env.mode == EnvMode::Gate) {
play.adsr.attackSeconds = env.attackSeconds;
play.adsr.holdSeconds = env.holdSeconds;
play.adsr.decaySeconds = env.decaySeconds;
play.adsr.sustainLevel = env.sustainLevel;
play.adsr.releaseSeconds = env.releaseSeconds;
} else {
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
// the played span (the trigger-seam converter, unpack direction). startFrame is the
// effective start point so the frame denominator matches the voice's actual
// post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces
// so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen);
play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen);
void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env,
PlaySeconds& play) const {
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
unpackAhd(env, play.pitchEnv.shape);
break;
case OverlayEnv::kFilter:
if (trigger) unpackAhd(env, play.filter.trigEnv);
else unpackAhdsr(env, play.filter.env);
break;
case OverlayEnv::kAmp:
if (trigger) unpackAhd(env, play.trigAhd);
else unpackAhdsr(env, play.adsr);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active, so there is nothing a drag could have edited
}
}
+1
View File
@@ -78,6 +78,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
const Rect curveRect = dragCurveRect_;
drag_ = DragKind::kNone;
dragParamId_ = -1;
dragInnerCellId_ = -1;
curvePointIndex_ = -1;
// hover_ is deliberately not re-resolved during a drag (see resolveHover's caller), so it
// still names wherever the drag started. Re-resolve now against the release position, for
+25 -2
View File
@@ -19,6 +19,7 @@ using namespace reasampler::instrument::ui;
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPitchEnvAttack:
case ParamControl::kPitchEnvHold:
case ParamControl::kPitchEnvDecay:
case ParamControl::kPitchEnvDepth:
return !params_.play.pitchEnv.enabled;
@@ -34,6 +35,9 @@ bool ReaSamplerEditor::deckKnobDisabled(int id) const {
case ParamControl::kFilterEnvDecay:
case ParamControl::kFilterEnvSustain:
case ParamControl::kFilterEnvRelease:
case ParamControl::kFilterTrigAttack:
case ParamControl::kFilterTrigHold:
case ParamControl::kFilterTrigDecay:
return !params_.play.filter.enabled;
default:
return false;
@@ -46,6 +50,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit hit = hitTestDeck(dl, x, y);
if (hit.kind == DeckHitKind::CaptionRadio) {
// Exclusive across the three envelope decks, and clicking the active one clears it —
// "no envelope shown" is a state the user can get back to, not an error.
const OverlayEnv picked = overlayEnvForRadio(hit.id);
overlayEnv_ = (overlayEnv_ == picked) ? OverlayEnv::kNone : picked;
invalidate(); // view state only: no parameter write, no reload
return true;
}
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
switch (static_cast<ParamControl>(hit.id)) {
case ParamControl::kVoiceMode: {
@@ -87,9 +99,14 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
if (hit.kind == DeckHitKind::Knob) {
// Knobs of a disabled group are drawn but inert.
if (deckKnobDisabled(hit.id)) return true;
// A grab on the inner disc drags the CURVE control instead, but only where the stage
// is sloped; on a Hold or Sustain cell the inner region is just more of the knob.
const ParamControl curve = curveParamFor(static_cast<ParamControl>(hit.id));
const bool inner = hit.inner && curve != ParamControl::kCount;
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragKnobStartValue_ = deckControlNorm(hit.id);
dragParamId_ = inner ? static_cast<int>(curve) : hit.id;
dragInnerCellId_ = inner ? hit.id : -1;
dragKnobStartValue_ = deckControlNorm(dragParamId_);
// Processor-side knobs (voice count / master gain) are transient live writes with no
// parameter-set mutation, so they need no rollback snapshot.
dragStartParams_ = params_;
@@ -120,6 +137,12 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl,
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind == DeckHitKind::None) return {};
if (dh.kind == DeckHitKind::CaptionRadio) return {HoverKind::kEnvRadio, dh.id};
if (dh.kind == DeckHitKind::Knob && dh.inner &&
curveParamFor(static_cast<ParamControl>(dh.id)) != ParamControl::kCount) {
// Indexed by the OUTER cell id so the paint side can find the cell it belongs to.
return {HoverKind::kInnerDial, dh.id};
}
return {HoverKind::kControl, dh.id};
}
+11 -12
View File
@@ -28,11 +28,12 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
if (frames <= 0) return false;
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
// Envelope nodes first (they sit on top of the markers), then the wave markers.
// Envelope nodes first (they sit on top of the markers), then the wave markers. With no
// envelope overlay-active there are no nodes at all and the markers take every grab.
const double rate = liveSampleRate();
if (rate > 0.0) {
if (rate > 0.0 && overlayEnv_ != OverlayEnv::kNone) {
const std::int64_t startFrame = params_.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
const double totalSeconds = static_cast<double>(frames) / rate;
const NodeHit nh = nodeAtPoint(env, overlay, totalSeconds, x, y);
if (nh.hit) {
@@ -42,7 +43,6 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
dragStartY_ = y;
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartFrame_ = startFrame;
dragStartParams_ = params_;
return true; // node moves once the cursor drags
}
@@ -68,18 +68,17 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
if (drag_ == DragKind::kEnvNode) {
// Resolve the grabbed envelope node's new params from the pixel delta (through the
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto
// the parameter set. The AmpEnvelope was snapshotted at grab (dragStartEnv_) so the
// delta is absolute.
// pure envelope_edit inverse map, clamped), then unpack them back onto the parameter
// set. The StageEnvelope was snapshotted at grab (dragStartEnv_) so the delta is
// absolute.
const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds,
envClampBounds(), dx, y - dragStartY_);
unpackEnvelope(edited, frames, dragStartFrame_, params_.play);
// In Gate the node IS a live AHDSR control, so the sounding note follows the drag;
// Trigger's nodes rewrite the play span and still commit on release.
const StageEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay,
totalSeconds, envClampBounds(), dx,
y - dragStartY_);
unpackEnvelope(overlayEnv_, edited, params_.play);
if (dragCommitsLive(DragKind::kEnvNode)) commitLive();
invalidate(); // live feedback; commit on WM_LBUTTONUP
return;
+36
View File
@@ -156,6 +156,42 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
toLice(ui::roleColor(needleRole)), 1.0f, 0, true);
}
// The concentric INNER dial: a second value on the same cell, drawn in the categorical
// tertiary accent so it reads as a different KIND of control rather than a louder one — the
// same purple the overlay traces the envelope in, which is what ties a segment's knot to its
// dial by eye. Shares the outer knob's value<->angle map (param_slider's), so both needles
// point the same way for the same normalized value.
inline void drawInnerDial(LICE_IBitmap* bmp, const instrument::ui::Rect& innerRect,
double value01, ui::InteractionState st) {
using instrument::ui::KnobArc;
using instrument::ui::KnobGeometry;
using instrument::ui::KnobPoint;
const KnobGeometry kg = instrument::ui::computeKnob(innerRect);
if (kg.radius <= 1.0) return;
constexpr double kDegToRad = 3.14159265358979323846 / 180.0;
const KnobArc arc{};
const float cx = static_cast<float>(kg.centerX);
const float cy = static_cast<float>(kg.centerY);
const float r = static_cast<float>(kg.radius) - 0.5f;
const bool disabled = (st == ui::InteractionState::Disabled);
const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover);
LICE_FillCircle(bmp, cx, cy, r - 1.f, toLice(ui::roleColorState(ui::Role::BgPanel, st)), 1.0f,
0, true);
const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01);
const float a0 = static_cast<float>((arc.startDeg - 360.0) * kDegToRad);
const float av = static_cast<float>(
(arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad);
const ui::Role arcRole = disabled ? ui::Role::TextDim
: (hot ? ui::Role::AccentHot : ui::Role::AccentTertiary);
LICE_Arc(bmp, cx, cy, r, a0, av, toLice(ui::roleColor(arcRole)), 1.0f, 0, true);
const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v);
LICE_Line(bmp, static_cast<int>(cx + 0.5f), static_cast<int>(cy + 0.5f),
static_cast<int>(tip.x + 0.5f), static_cast<int>(tip.y + 0.5f),
toLice(ui::roleColor(disabled ? ui::Role::TextDim : ui::Role::AccentTertiary)),
1.0f, 0, true);
}
#endif // _WIN32
} // namespace reasampler::vst
+46 -6
View File
@@ -60,11 +60,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
case ParamControl::kDecay: return "Decay";
case ParamControl::kSustain: return "Sustain";
case ParamControl::kRelease: return "Release";
case ParamControl::kTrigFadeIn: return "Fade In";
case ParamControl::kTrigLength: return "Len %";
case ParamControl::kTrigFadeOut: return "Fade Out";
case ParamControl::kTrigAttack: return "Attack";
case ParamControl::kTrigHold: return "Hold";
case ParamControl::kTrigDecay: return "Decay";
case ParamControl::kKeyTrack: return "Key Trk";
case ParamControl::kPitchEnvAttack: return "P.Att";
case ParamControl::kPitchEnvHold: return "P.Hold";
case ParamControl::kPitchEnvDecay: return "P.Dec";
case ParamControl::kPitchEnvDepth: return "P.Depth";
case ParamControl::kVoiceCount: return "Voices";
@@ -81,6 +83,9 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
case ParamControl::kFilterEnvDecay: return "F.Dec";
case ParamControl::kFilterEnvSustain: return "F.Sus";
case ParamControl::kFilterEnvRelease: return "F.Rel";
case ParamControl::kFilterTrigAttack: return "F.Att";
case ParamControl::kFilterTrigHold: return "F.Hold";
case ParamControl::kFilterTrigDecay: return "F.Dec";
default: return "";
}
};
@@ -103,6 +108,22 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
}
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
// The overlay-select radio: filled in the tertiary accent (the colour the overlay
// traces in) when this group's envelope is the one on the waveform, hollow otherwise.
if (g.captionRadio.id >= 0) {
const bool on = (overlayEnv_ == overlayEnvForRadio(g.captionRadio.id));
const bool hov = isHovered(HoverKind::kEnvRadio, g.captionRadio.id);
const Rect& rb = g.captionRadio.box;
LICE_DrawRect(bmp, rb.x, rb.y, rb.width - 1, rb.height - 1,
toLice(roleColor(on || hov ? Role::AccentTertiary
: Role::LineHairline)),
1.0f, 0);
if (on) {
LICE_FillRect(bmp, rb.x + 3, rb.y + 3, rb.width - 6, rb.height - 6,
toLice(roleColor(Role::AccentTertiary)), 1.0f, 0);
}
}
// The compact caption toggle (right-anchored in the caption row, never full-width).
if (g.captionToggle.id >= 0) {
switch (static_cast<ParamControl>(g.captionToggle.id)) {
@@ -142,7 +163,7 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
// The predicate is the input side's, so the drawn state and the inert grab agree.
for (const DeckCellLayout& c : g.cells) {
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
if (c.id < 0) continue; // reserved blank cell (the Trigger face's spare)
const bool disabled = deckKnobDisabled(c.id);
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
@@ -151,9 +172,28 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
: (dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest));
drawKnobFace(bmp, c.knob, deckControlNorm(c.id), st);
const std::string label = (dragging || hov)
? deckValueLabel(c.id)
: std::string(knobName(static_cast<ParamControl>(c.id)));
// The inner dial rides only the knobs whose stage is sloped — deck_groups owns
// that rule, so a Hold or Sustain cell simply has no curve id and draws none.
const ParamControl curve = curveParamFor(static_cast<ParamControl>(c.id));
const bool innerDragging =
(drag_ == DragKind::kDeckKnob && dragInnerCellId_ == c.id);
const bool innerHov = !disabled && isHovered(HoverKind::kInnerDial, c.id);
if (curve != ParamControl::kCount) {
const InteractionState ist =
disabled ? InteractionState::Disabled
: (innerDragging ? InteractionState::Dragging
: (innerHov ? InteractionState::Hover
: InteractionState::Rest));
drawInnerDial(bmp, c.inner, deckControlNorm(static_cast<int>(curve)), ist);
}
// One label band, so the inner dial's readout takes it while the inner dial is the
// one being touched.
std::string label;
if (innerDragging || innerHov) label = deckValueLabel(static_cast<int>(curve));
else if (dragging || hov) label = deckValueLabel(c.id);
else label = std::string(knobName(static_cast<ParamControl>(c.id)));
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
}
}
+28 -16
View File
@@ -100,37 +100,49 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea,
std::int64_t frames) {
if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state
const Rect& area = waveArea.rect;
if (frames <= 0 || area.width <= 0 || area.height <= 0) return;
const double rate = liveSampleRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const std::int64_t startFrame = params_.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
for (std::size_t i = 1; i < poly.size(); ++i) {
const int x0 = (std::max)(area.x, (std::min)(area.right() - 1, poly[i - 1].x));
const int x1 = (std::max)(area.x, (std::min)(area.right() - 1, poly[i].x));
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
// Trace the polyline in the categorical TERTIARY accent (purple): the waveform behind it is
// drawn in the primary lime, and the secondary teal this used to use sits too close to that
// hue to separate from it. Clip x to the wave rect. Knots are handles, not line vertices.
const LICE_pixel line = toLice(roleColor(Role::AccentTertiary));
const EnvVertex* prev = nullptr;
for (const EnvVertex& v : poly) {
if (v.knot) continue;
if (prev != nullptr) {
const int x0 = (std::max)(area.x, (std::min)(area.right() - 1, prev->x));
const int x1 = (std::max)(area.x, (std::min)(area.right() - 1, v.x));
LICE_Line(bmp, x0, prev->y, x1, v.y, line, 1.0f, 0, true);
}
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
// the handle square is additionally clamped inside the band so a 6px box on an edge
// node never overhangs into the neighbouring bands.
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
prev = &v;
}
// Handles: a square per draggable stage node, a ROUND knot per curvable segment. Lit
// accent-hot when this node is the grabbed one. Every vertex is guaranteed in-bounds; the
// handle is additionally clamped inside the band so one on an edge node never overhangs
// into the neighbouring bands.
const LICE_pixel handle = toLice(roleColor(Role::AccentTertiary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
for (const EnvVertex& v : poly) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseEnd) continue;
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
const int r = 3;
const int hx = (std::max)(area.x + r, (std::min)(area.right() - 1 - r, v.x));
const int hy = (std::max)(area.y + r, (std::min)(area.bottom() - 1 - r, v.y));
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
if (v.knot) {
LICE_FillCircle(bmp, static_cast<float>(hx), static_cast<float>(hy),
static_cast<float>(r), grabbed ? handleHot : handle, 1.0f, 0, true);
} else {
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f,
0);
}
}
}
+1
View File
@@ -244,6 +244,7 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
}
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->dragInnerCellId_ = -1; // inner-dial drag state (peer reset)
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
// No cursor position is available here to re-resolve hover (unlike
// onMouseUp's release coordinates), so clear rather than leave it naming
+1 -1
View File
@@ -151,7 +151,7 @@ bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob
: kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode
: LiveDragKind::kOther;
return instrument::ui::liveCommitFor(k, paramId, params_.play.playMode);
return instrument::ui::liveCommitFor(k, paramId);
}
void ReaSamplerEditor::loadSelection(const std::string& id) {
+32 -20
View File
@@ -17,7 +17,7 @@
#include "core/instrument/ui/deck_groups.h" // DeckParam / DeckGroupId / sampleDeckGroups
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
@@ -41,7 +41,6 @@ using instrument::map::PlaySeconds;
using instrument::map::SampleChoice;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::ui::AmpEnvelope;
using instrument::ui::ChromeRects;
using instrument::ui::DeckGroupDesc;
using instrument::ui::EnvClampBounds;
@@ -49,6 +48,7 @@ using instrument::ui::EnvNode;
using instrument::ui::OverlayArea;
using instrument::ui::Rect;
using instrument::ui::SampleBands;
using instrument::ui::StageEnvelope;
class ReaSamplerProcessor;
@@ -81,6 +81,11 @@ private:
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kDeckKnob };
// Which envelope the waveform overlay is drawing and editing. Exclusive, and kNone is a
// valid resting state — the editor opens there. Transient view state: never persisted,
// never a parameter.
enum class OverlayEnv { kNone, kAmp, kPitch, kFilter };
// Controls on the setup surface. The int value is the opaque control id the pure
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
// processor-side per-instance setter. The id space and the deck's group composition are
@@ -107,6 +112,8 @@ private:
kChanStereo, // the stereo channel-mode segment
kPreview, // the preview-trigger button
kControl, // a knob-deck element (index = control id)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the chrome preview-velocity radial knob
kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip
@@ -302,8 +309,8 @@ private:
// pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames.
// The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over
// a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth
// centered at 0.5).
// a fixed ceiling, levels and fractions as-is, semitone depth centered at 0.5, curve
// exponents over their logarithmic travel).
double controlValue(int id, const PlaySeconds& play) const;
// Applies a committed control interaction to `play`: a knob's normalized `value` or a
@@ -315,22 +322,22 @@ private:
// over the knob's 0..1).
void applyParamControl(int id, double value, int segment);
// The Trigger fade-in/out knob full-scale, in source frames: kFadeMaxSeconds resolved
// against the live rate — never a baked-in rate. Returns 0 when the rate is unknown.
double fadeMaxFrames() const;
// The overlay speaks one StageEnvelope whichever envelope is active; pack/unpack are the
// only place that knows which stored struct each `which` maps onto, so the drawn shape and
// a committed node drag can never disagree about it. AHDSR seconds are rate-free and copy
// 1-to-1; an AHD additionally needs the wall-clock span its Hold fraction is taken against,
// which is where `frames`/`startFrame` and the live rate come in.
// envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span,
// while the parameter set stores source frames — pack/unpack own that conversion (see
// envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR
// seconds are rate-free and copy 1-to-1.
// PACK (draw): play params -> AmpEnvelope. `startFrame` is the effective start point.
AmpEnvelope packEnvelope(const PlaySeconds& play, std::int64_t frames,
// PACK (draw): play params -> StageEnvelope. `startFrame` is the effective start point.
StageEnvelope packEnvelope(OverlayEnv which, const PlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const;
// UNPACK (commit): an edited AmpEnvelope -> the play params, in place.
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
PlaySeconds& play) const;
// UNPACK (commit): an edited StageEnvelope -> the play params, in place.
void unpackEnvelope(OverlayEnv which, const StageEnvelope& env, PlaySeconds& play) const;
// The radio control id that selects `which`, and its inverse. One table, so the deck's
// radio and the overlay can never drift apart.
static OverlayEnv overlayEnvForRadio(int radioId);
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
// never produce a param a slider couldn't.
@@ -417,16 +424,21 @@ private:
WaveMarker waveMarker_ = WaveMarker::kStart;
SetupMarkers dragStartMarkers_;
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
std::int64_t dragStartFrame_ = 0; // effective start point at grab time; for env-node drag
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id.
int dragStartScrollOffset_ = 0;
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
// The cell whose INNER dial is under a kDeckKnob drag (dragParamId_ then holds the curve
// control), so the paint side can light the right ring. -1 when the grab was the outer knob.
int dragInnerCellId_ = -1;
// Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta
// Which envelope the overlay draws and edits (kNone = none, the opening state).
OverlayEnv overlayEnv_ = OverlayEnv::kNone;
// Envelope-node drag: which node + the StageEnvelope snapshotted at grab (absolute-delta
// contract, per envelope_edit's grabEnv).
EnvNode envNode_ = EnvNode::Origin;
AmpEnvelope dragStartEnv_{};
StageEnvelope dragStartEnv_{};
// Velocity-curve node drag: which point, the curve snapshotted at grab
// (resolvePointDrag's absolute-delta contract), and the grab-time box rect.
+107 -28
View File
@@ -8,6 +8,7 @@
#include "../src/core/instrument/map/component_state_io.h"
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
#include <cstdio>
#include <cstring>
@@ -285,12 +286,17 @@ static void testComponentStateRoundTrip() {
in.params.play.adsr.sustainLevel = 0.8;
in.params.play.adsr.releaseSeconds = 0.15;
in.params.play.trigger.lengthFraction = 0.75;
in.params.play.trigger.fadeInFrames = 441;
in.params.play.trigger.fadeOutFrames = 882;
in.params.play.trigAhd = AhdSeconds{0.011, 0.022, 0.65, 2.5, 0.4};
in.params.play.adsr.attackCurve = 3.0;
in.params.play.adsr.decayCurve = 0.3;
in.params.play.adsr.releaseCurve = 6.0;
in.params.play.filter.env.attackCurve = 1.25;
in.params.play.filter.env.decayCurve = 0.75;
in.params.play.filter.env.releaseCurve = 8.0;
in.params.play.filter.trigEnv = AhdSeconds{0.033, 0.044, 0.15, 0.2, 9.0};
in.params.play.pitchEngine = PitchEngine::Preserve;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.attackSeconds = 0.02;
in.params.play.pitchEnv.decaySeconds = 0.03;
in.params.play.pitchEnv.shape = AhdSeconds{0.02, 0.03, 0.45, 1.5, 0.6};
in.params.play.pitchEnv.peakSemitones = 5.0;
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
@@ -331,12 +337,31 @@ static void testComponentStateRoundTrip() {
CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(p.play.adsr.releaseSeconds == 0.15);
CHECK(p.play.trigger.lengthFraction == 0.75);
CHECK(p.play.trigger.fadeInFrames == 441);
CHECK(p.play.trigger.fadeOutFrames == 882);
// Every curve exponent, hold fraction and Trigger AHD field survives the round trip
// EXACTLY — the tail is doubles all the way down, so nothing quantizes.
CHECK(p.play.adsr.attackCurve == 3.0);
CHECK(p.play.adsr.decayCurve == 0.3);
CHECK(p.play.adsr.releaseCurve == 6.0);
CHECK(p.play.trigAhd.attackSeconds == 0.011);
CHECK(p.play.trigAhd.decaySeconds == 0.022);
CHECK(p.play.trigAhd.holdFraction == 0.65);
CHECK(p.play.trigAhd.attackCurve == 2.5);
CHECK(p.play.trigAhd.decayCurve == 0.4);
CHECK(p.play.filter.env.attackCurve == 1.25);
CHECK(p.play.filter.env.decayCurve == 0.75);
CHECK(p.play.filter.env.releaseCurve == 8.0);
CHECK(p.play.filter.trigEnv.attackSeconds == 0.033);
CHECK(p.play.filter.trigEnv.decaySeconds == 0.044);
CHECK(p.play.filter.trigEnv.holdFraction == 0.15);
CHECK(p.play.filter.trigEnv.attackCurve == 0.2);
CHECK(p.play.filter.trigEnv.decayCurve == 9.0);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.shape.holdFraction == 0.45);
CHECK(p.play.pitchEnv.shape.attackCurve == 1.5);
CHECK(p.play.pitchEnv.shape.decayCurve == 0.6);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}
@@ -401,12 +426,10 @@ static void testGoldenFullBlobFixture() {
in.params.play.adsr.sustainLevel = 0.8;
in.params.play.adsr.releaseSeconds = 0.15;
in.params.play.trigger.lengthFraction = 0.75;
in.params.play.trigger.fadeInFrames = 100;
in.params.play.trigger.fadeOutFrames = 200;
in.params.play.pitchEngine = PitchEngine::Preserve;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.attackSeconds = 0.02;
in.params.play.pitchEnv.decaySeconds = 0.03;
in.params.play.pitchEnv.shape.attackSeconds = 0.02;
in.params.play.pitchEnv.shape.decaySeconds = 0.03;
in.params.play.pitchEnv.peakSemitones = 5.0;
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
@@ -423,11 +446,11 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x09,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0a,0x00,0x00,
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,0x00,0x00,0x00,0x00,
0xe8,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
@@ -457,6 +480,27 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0
// --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no
// curve or AHD field), in the header's documented order ---
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp attack curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp decay curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp release curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // trig AHD attack 0.0 s
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // trig AHD decay 0.0 s
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD hold 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD att curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // trig AHD dec curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // pitch hold 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // pitch attack curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // pitch decay curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt attack curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt decay curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt release curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // filt AHD attack 0.0 s
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // filt AHD decay 0.0 s
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD hold 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD att curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -504,12 +548,13 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 9);
CHECK(kParamsPayloadVersion == 10);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter tail rode a PAYLOAD bump, not an envelope one — the two axes stay
// independent, so a future envelope field cannot collide with it on one number.
// The filter and staged-curve tails rode PAYLOAD bumps, not envelope ones — the two axes
// stay independent, so a future envelope field cannot collide with either on one number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
CHECK(kParamsCurveVersion > kParamsFilterVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
@@ -707,13 +752,46 @@ static void testSingleZoneMigrationIsLossless() {
CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(p.play.adsr.releaseSeconds == 0.15);
CHECK(p.play.trigger.lengthFraction == 0.75);
CHECK(p.play.trigger.fadeInFrames == 100);
CHECK(p.play.trigger.fadeOutFrames == 200);
// The retired fade pair lifts onto the AHD that replaced it: attack <- fade-in, decay <-
// fade-out (source frames over the project rate), hold <- the whole remainder.
CHECK(p.play.trigAhd.attackSeconds == 100.0 / 48000.0);
CHECK(p.play.trigAhd.decaySeconds == 200.0 / 48000.0);
CHECK(p.play.trigAhd.holdFraction == 1.0);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.shape.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.shape.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
// Everything the change added lifts to its own neutral, so the loaded instance plays as
// the saved one did: every exponent linear, and the pitch envelope with no hold stage.
CHECK(p.play.adsr.attackCurve == util::kCurveNeutral);
CHECK(p.play.adsr.decayCurve == util::kCurveNeutral);
CHECK(p.play.adsr.releaseCurve == util::kCurveNeutral);
CHECK(p.play.trigAhd.attackCurve == util::kCurveNeutral);
CHECK(p.play.trigAhd.decayCurve == util::kCurveNeutral);
CHECK(p.play.pitchEnv.shape.holdFraction == 0.0);
CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
}
// A prior ZERO fade-out lands Decay = 0: the abrupt end an old Trigger instance could express
// stays representable under the AHD, which is what makes the consolidation lossless rather
// than merely close.
static void testZeroFadeOutMigratesToZeroDecay() {
legacy::Zone z;
z.sampleId = "kick";
z.lowNote = 0;
z.highNote = 127;
z.trigger = true;
z.lengthFraction = 1.0;
z.fadeIn = 441;
z.fadeOut = 0;
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 44100.0);
const PlaySeconds& play = out.params.play;
CHECK(play.playMode == PlayMode::Trigger);
CHECK(play.trigAhd.attackSeconds == 441.0 / 44100.0);
CHECK(play.trigAhd.decaySeconds == 0.0);
CHECK(play.trigAhd.holdFraction == 1.0);
}
// A legacy OVERRIDE THAT DISABLES THE LOOP migrates as a PRESENT loopOverride with hasLoop
@@ -872,8 +950,8 @@ static void testLegacyV3FramesConvertAtTheProjectRate() {
const ComponentState st = deserializeComponentState(out, 48000.0);
CHECK(st.selectionId == "kick");
CHECK(st.params.play.adsr.holdSeconds == 0.05);
CHECK(st.params.play.pitchEnv.attackSeconds == 0.02);
CHECK(st.params.play.pitchEnv.decaySeconds == 0.03);
CHECK(st.params.play.pitchEnv.shape.attackSeconds == 0.02);
CHECK(st.params.play.pitchEnv.shape.decaySeconds == 0.03);
CHECK(st.params.play.pitchEnv.peakSemitones == 5.0);
// A/D/S/R are absent in v3 -> the tier-0 seconds defaults hold.
CHECK(st.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
@@ -1001,11 +1079,11 @@ static void testSampleRefsTruncatedMidEntry() {
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
// v9 filter tail) = 292 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4, loop
// 1+8+8, channels4, name 4+0). Cutting 312 keeps the first 27 of entry two's 47 — mid
// loop.start (offset 23..31).
CHECK(bytes.size() > 312);
bytes.resize(bytes.size() - 312);
// v9 filter tail + the 160-byte v10 staged-curve tail) = 452 bytes; entry two is 47 bytes
// (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 472 keeps the first
// 27 of entry two's 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 472);
bytes.resize(bytes.size() - 472);
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
@@ -1087,6 +1165,7 @@ int main() {
testEnvelopePrefixBytesFrozen();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testZeroFadeOutMigratesToZeroDecay();
testSingleZoneMigrationLiftsLoopDisablingOverride();
testLiftedStateReSavesInCurrentFormat();
testMultiZoneMigrationAdoptsFirstZone();
+120
View File
@@ -0,0 +1,120 @@
// Standalone tests for reasampler::util::curve_law — no VST3, no REAPER, no framework. Same
// fast assert loop as the sibling pure tests. This is the ONE law behind the engine's segment
// evaluator, the overlay's knot geometry, and the deck's inner dial, so what it guarantees is
// what all three inherit.
//
// Covers: the LINEAR NEUTRAL (exponent 1.0 returns its input BIT-IDENTICALLY, which is what
// makes a pre-existing instance play unchanged); endpoint exactness at every exponent (no
// segment can overshoot its own endpoint levels); monotonicity and finiteness across the full
// 0.1..10 domain including both endpoints; the mid-level inverse the overlay knot drags
// through, and its round trip against the exponent.
#include "../src/core/util/curve_law.h"
#include <cmath>
#include <cstdio>
using namespace reasampler::util;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// The neutral is not merely "close to linear" — it must be the identity, bit for bit, or a
// blob that loaded at 1.0 would render differently from the engine that wrote it.
static void testNeutralExponentIsTheIdentity() {
for (int i = 0; i <= 1000; ++i) {
const double phi = static_cast<double>(i) / 1000.0;
CHECK(curveMap(phi, kCurveNeutral) == phi);
}
// Including the values a fractional stage position actually takes.
CHECK(curveMap(1.0 / 3.0, 1.0) == 1.0 / 3.0);
CHECK(curveMap(0.1234567890123, 1.0) == 0.1234567890123);
}
// Both endpoints are exact at every exponent, which is the whole overshoot guarantee: a curved
// stage starts where the previous one ended and ends where the next one starts.
static void testEndpointsAreExactAtEveryExponent() {
for (int i = 0; i <= 100; ++i) {
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 100.0);
CHECK(curveMap(0.0, e) == 0.0);
CHECK(curveMap(1.0, e) == 1.0);
}
}
// The full domain, both endpoints included: finite, in range, and strictly rising.
static void testSweepIsFiniteMonotoneAndInRange() {
const double exps[] = {kCurveMin, 0.25, 0.5, kCurveNeutral, 2.0, 4.0, kCurveMax};
for (double e : exps) {
double prev = -1.0;
for (int i = 0; i <= 500; ++i) {
const double phi = static_cast<double>(i) / 500.0;
const double v = curveMap(phi, e);
CHECK(std::isfinite(v));
CHECK(v >= 0.0 && v <= 1.0);
CHECK(v > prev - 1e-15); // non-decreasing
prev = v;
}
CHECK(std::fabs(prev - 1.0) < 1e-12);
}
}
// Which side of the neutral an exponent falls on is the SHAPE, and the two directions must not
// collapse into each other.
static void testExponentDirectionShapesTheSegment() {
CHECK(curveMap(0.5, 4.0) < curveMap(0.5, kCurveNeutral));
CHECK(curveMap(0.5, 0.25) > curveMap(0.5, kCurveNeutral));
CHECK(std::fabs(curveMap(0.5, kCurveNeutral) - 0.5) < 1e-15);
}
static void testClampCurveHoldsTheDomain() {
CHECK(clampCurve(-5.0) == kCurveMin);
CHECK(clampCurve(0.0) == kCurveMin);
CHECK(clampCurve(1e9) == kCurveMax);
CHECK(clampCurve(std::nan("")) == kCurveMin); // a corrupt blob degrades, never propagates
CHECK(clampCurve(2.5) == 2.5);
}
// The mid-level inverse is what a knot drag resolves through: it must be the exact inverse of
// the forward reading over the whole domain, or the knot and the dial could drift.
static void testMidLevelRoundTripsAgainstTheExponent() {
for (int i = 0; i <= 200; ++i) {
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 200.0);
const double mid = curveMidLevel(e);
CHECK(mid > 0.0 && mid < 1.0);
CHECK(std::fabs(curveFromMidLevel(mid) - e) < 1e-9);
}
// The mid-level is strictly DECREASING in the exponent, so a drag has one unambiguous
// direction at every point of the domain.
double prev = 1.0;
for (int i = 0; i <= 200; ++i) {
const double e = kCurveMin + (kCurveMax - kCurveMin) * (i / 200.0);
const double mid = curveMidLevel(e);
CHECK(mid < prev);
prev = mid;
}
}
// A knot dragged past what the domain can express saturates rather than producing a
// non-finite exponent.
static void testMidLevelInverseSaturates() {
CHECK(curveFromMidLevel(0.0) == kCurveMax);
CHECK(curveFromMidLevel(-1.0) == kCurveMax);
CHECK(curveFromMidLevel(1.0) == kCurveMin);
CHECK(curveFromMidLevel(5.0) == kCurveMin);
CHECK(curveFromMidLevel(std::nan("")) == kCurveMax);
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
}
int main() {
testNeutralExponentIsTheIdentity();
testEndpointsAreExactAtEveryExponent();
testSweepIsFiniteMonotoneAndInRange();
testExponentDirectionShapesTheSegment();
testClampCurveHoldsTheDomain();
testMidLevelRoundTripsAgainstTheExponent();
testMidLevelInverseSaturates();
if (g_fail == 0) std::printf("curve_law: all tests passed\n");
else std::printf("curve_law: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}
+117 -25
View File
@@ -78,6 +78,89 @@ static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
CHECK(fe.rowToggle.id == -1);
}
// Exactly the three envelope decks carry an overlay-select radio, each its own, and no other
// group has one — the exclusivity the shell enforces is only meaningful if the id space is.
static void testOnlyTheThreeEnvelopeDecksCarryARadio() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
int radios = 0;
for (const DeckGroupDesc& d : g) {
if (d.captionRadio.id < 0) continue;
++radios;
const int want = d.id == kGroupAmpEnv ? cell(DeckParam::kAmpEnvSelect)
: d.id == kGroupPitchEnv ? cell(DeckParam::kPitchEnvSelect)
: d.id == kGroupFilterEnv ? cell(DeckParam::kFilterEnvSelect)
: -1;
CHECK(d.captionRadio.id == want);
}
CHECK(radios == 3);
}
}
// The mode-driven shape switch, on BOTH the amp and the filter envelope: Gate shows the
// AHDSR's five stages, Trigger the AHD's three (behind the play span on the amp deck), and
// neither mode leaks the other's controls onto the deck.
static void testGateAndTriggerFacesCarryTheirOwnShapes() {
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
const DeckGroupDesc& gAmp = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
const DeckGroupDesc& tAmp = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
const std::vector<int> gateAmp = {cell(DeckParam::kAttack), cell(DeckParam::kHold),
cell(DeckParam::kDecay), cell(DeckParam::kSustain),
cell(DeckParam::kRelease)};
const std::vector<int> trigAmp = {cell(DeckParam::kTrigLength), cell(DeckParam::kTrigAttack),
cell(DeckParam::kTrigHold), cell(DeckParam::kTrigDecay),
-1};
CHECK(gAmp.cellIds == gateAmp);
CHECK(tAmp.cellIds == trigAmp);
const DeckGroupDesc& gFe = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupFilterEnv))];
const DeckGroupDesc& tFe = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupFilterEnv))];
const std::vector<int> trigFe = {cell(DeckParam::kFilterTrigAttack),
cell(DeckParam::kFilterTrigHold),
cell(DeckParam::kFilterTrigDecay), -1, -1};
CHECK(tFe.cellIds == trigFe);
CHECK(gFe.cellIds != tFe.cellIds);
// Same cell count either way, so the group's width — and its neighbours' placement —
// survives a mode flip.
CHECK(gFe.cellIds.size() == tFe.cellIds.size());
CHECK(deckGroupWidth(gFe) == deckGroupWidth(tFe));
}
// Every SLOPED stage knob carries an inner curve dial; Hold, Sustain, and everything that is
// not a stage carries none. This is the "which segments are sloped" rule, asserted rather than
// read.
static void testOnlySlopedStageKnobsCarryAnInnerCurveDial() {
const DeckParam sloped[] = {
DeckParam::kAttack, DeckParam::kDecay, DeckParam::kRelease,
DeckParam::kTrigAttack, DeckParam::kTrigDecay,
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay,
DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvDecay, DeckParam::kFilterEnvRelease,
DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigDecay,
};
for (DeckParam p : sloped) {
const DeckParam c = curveParamFor(p);
CHECK(c != DeckParam::kCount);
// A curve control is itself flat — no inner dial on an inner dial.
CHECK(curveParamFor(c) == DeckParam::kCount);
}
const DeckParam flat[] = {
DeckParam::kHold, DeckParam::kSustain, DeckParam::kTrigHold,
DeckParam::kPitchEnvHold, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvSustain,
DeckParam::kFilterTrigHold, DeckParam::kTrigLength, DeckParam::kPitchEnvDepth,
DeckParam::kFilterCutoff, DeckParam::kMasterGain, DeckParam::kKeyTrack,
};
for (DeckParam p : flat) CHECK(curveParamFor(p) == DeckParam::kCount);
// Every sloped knob maps to a DISTINCT curve control — a copy-paste that pointed two
// stages at one exponent would tie two dials together silently.
for (std::size_t i = 0; i < sizeof(sloped) / sizeof(sloped[0]); ++i) {
for (std::size_t j = i + 1; j < sizeof(sloped) / sizeof(sloped[0]); ++j) {
CHECK(curveParamFor(sloped[i]) != curveParamFor(sloped[j]));
}
}
}
static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
// The reserved blanks are what stop a mode flip reflowing the groups beside AMP.
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
@@ -86,7 +169,7 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
const DeckGroupDesc& b = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
CHECK(deckGroupWidth(a) == deckGroupWidth(b));
CHECK(a.cellIds.size() == b.cellIds.size());
CHECK(b.cellIds[3] == -1 && b.cellIds[4] == -1);
CHECK(b.cellIds[4] == -1); // the Trigger face's one reserved blank
// Every other group is mode-independent, so the whole deck's height is too.
CHECK(deckHeight(gate, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth));
}
@@ -188,16 +271,25 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
}
static void testEveryDeckControlIsClassifiedLiveOrReloading() {
// The live set: the six filter tone/modulation knobs, plus every stage time and stage
// level on all three envelopes.
// The live set: the six filter tone/modulation knobs, plus every stage time, stage level,
// hold fraction and curve exponent on all three envelopes — in BOTH mode shapes.
const DeckParam live[] = {
DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterKeyTrack,
DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain,
DeckParam::kRelease,
DeckParam::kTrigAttack, DeckParam::kTrigHold, DeckParam::kTrigDecay,
DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay,
DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease,
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay, DeckParam::kPitchEnvDepth,
DeckParam::kFilterTrigAttack, DeckParam::kFilterTrigHold, DeckParam::kFilterTrigDecay,
DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvHold, DeckParam::kPitchEnvDecay,
DeckParam::kPitchEnvDepth,
DeckParam::kAttackCurve, DeckParam::kDecayCurve, DeckParam::kReleaseCurve,
DeckParam::kTrigAttackCurve, DeckParam::kTrigDecayCurve,
DeckParam::kPitchEnvAttackCurve, DeckParam::kPitchEnvDecayCurve,
DeckParam::kFilterEnvAttackCurve, DeckParam::kFilterEnvDecayCurve,
DeckParam::kFilterEnvReleaseCurve,
DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve,
};
for (DeckParam p : live) CHECK(isLiveDeckParam(p));
@@ -206,8 +298,9 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
const DeckParam reloads[] = {
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel,
DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kTrigFadeIn,
DeckParam::kTrigFadeOut, DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kKeyTrack, DeckParam::kTrigLength,
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
};
for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p));
@@ -228,28 +321,24 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
static void testOnlyALiveControlsDragTakesTheLiveTier() {
// isLiveDeckParam alone is not what a user experiences — liveCommitFor is, at the editor's
// commit site. Inverting it has to FAIL a test rather than merely read wrong.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kFilterCutoff),
PlayMode::Gate));
// A knob's routing is the knob's, not the play mode's.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack),
PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigFadeIn),
PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain),
PlayMode::Gate));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kFilterCutoff)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack)));
// The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural
// consequence of that consolidation.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigAttack)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigDecayCurve)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigLength)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAmpEnvSelect)));
// The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
// are not parameter-set controls, so they must never reach the enum.
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kCount),
PlayMode::Gate));
// An envelope-node drag edits the AHDSR in Gate; the same drag in Trigger rewrites the
// play span, which is not a live control.
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kCount)));
// Every stage value an envelope node can reach is live, in either mode shape.
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1));
// Every other drag (markers, scrollbar, curve nodes) commits through a reload.
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff),
PlayMode::Gate));
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)));
}
int main() {
@@ -257,6 +346,9 @@ int main() {
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow();
+262 -341
View File
@@ -1,24 +1,19 @@
// Standalone tests for reasampler::instrument::ui::envelope_edit — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3 draggable-node INVERSE
// map: node hit-test + pixel-delta -> clamped/monotonic param set, HARD at the clamp + monotonic
// boundaries (the load-bearing "a drag can never produce a param a slider couldn't" invariant).
// Standalone tests for reasampler::instrument::ui::envelope_edit — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests. Assert the INVERSE (edit) map
// against envelope_overlay's forward map: a grab lands on the node that was drawn there, and a
// pixel delta produces exactly the param a knob would have.
//
// Covers: nodeAtPoint (grabs a drawn handle within the pick radius; misses off every node; skips
// the non-draggable Origin/ReleaseStart anchors AND other-mode nodes; NEAREST-node-wins with
// draw-order tie-break; EVERY Gate node individually grabbable at the tier-0 defaults — FA2);
// resolveNodeDrag Gate (each cumulative node edits its OWN segment at the PARAM-DOMAIN px scale;
// X->time, sustain node's Y->level; lower clamp at 0; upper clamp at the caller's max; only the
// dragged param changes; ReleaseEnd grabbable + draggable; per-node drag round-trip tracks the
// cursor ~1:1 — FA2); resolveNodeDrag Trigger (fades as fractions of the played span;
// fadeIn/fadeOut mutual clamp so they never cross; length clamp; FadeOutStart moves OPPOSITE the
// pixel delta; zero-fade-out node grabbable at the right edge and draggable inward — FA2);
// degenerate area/duration + non-draggable node + cross-mode node -> no motion.
// Covers: nodeAtPoint (every drawn handle grabbable, the anchored ReleaseEnd and the Origin
// never grabbed, other-kind nodes rejected, misses outside the radius); resolveNodeDrag
// (AHDSR stage times at the schematic scale, the sustain level on Y, the release dragged from
// its START with the inverted sign, the caller's clamp domain, AHD stage times at the 1:1
// scale, the hold FRACTION); curve-knot drags (the exponent domain, its endpoints, and the
// round trip through the shared law that keeps knot and dial on one value); degenerate no-ops.
#include "../src/core/instrument/ui/envelope_edit.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <vector>
using namespace reasampler;
@@ -28,9 +23,41 @@ 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 bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100
static constexpr double kTotal = 4.0;
static EnvClampBounds bounds() {
EnvClampBounds b;
b.maxAttackSeconds = 2.0;
b.maxHoldSeconds = 2.0;
b.maxDecaySeconds = 2.0;
b.maxReleaseSeconds = 2.0;
return b;
}
static StageEnvelope ahdsrEnv() {
StageEnvelope e;
e.kind = EnvKind::Ahdsr;
e.attackSeconds = 0.3;
e.holdSeconds = 0.2;
e.decaySeconds = 0.4;
e.sustainLevel = 0.6;
e.releaseSeconds = 0.5;
return e;
}
static StageEnvelope ahdEnv() {
StageEnvelope e;
e.kind = EnvKind::Ahd;
e.attackSeconds = 0.4;
e.decaySeconds = 0.6;
e.holdFraction = 0.5;
e.originSeconds = 0.0;
e.spanSeconds = 3.0;
return e;
}
// Find the first vertex with a given node in a polyline; asserts presence via the returned bool.
static bool findNode(const std::vector<EnvVertex>& poly, EnvNode node, EnvVertex& out) {
for (const EnvVertex& v : poly) {
if (v.node == node) { out = v; return true; }
@@ -38,348 +65,242 @@ static bool findNode(const std::vector<EnvVertex>& poly, EnvNode node, EnvVertex
return false;
}
// 1000px wide, 100px tall, offset origin. Trigger scale: 2.0s over 1000px => 0.002 s/px. Gate
// scale (FA2 param-domain schematic — sample-length-free): (850-1-32)px over the 8.0s schematic
// domain => 102.125 px/s, each segment prefixed by the 8px separation base; the gateEnv() nodes
// draw at A x@28, H x@47, D x@85, RS x@235, RE x@284.
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); }
static constexpr double kTotal = 2.0;
static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
static const double kGateSecPerPx = 1.0 / gatePxPerSecond(wideArea());
static AmpEnvelope gateEnv() {
AmpEnvelope e;
e.mode = EnvMode::Gate;
e.attackSeconds = 0.2;
e.holdSeconds = 0.1;
e.decaySeconds = 0.3;
e.sustainLevel = 0.5;
e.releaseSeconds = 0.4;
return e;
}
static AmpEnvelope triggerEnv() {
AmpEnvelope e;
e.mode = EnvMode::Trigger;
e.lengthFraction = 0.5; // played span 1.0s -> 500px
e.fadeInFraction = 0.2;
e.fadeOutFraction = 0.2;
return e;
}
// --- nodeAtPoint --------------------------------------------------------------
static void testHitGrabsDrawnHandle() {
const AmpEnvelope e = gateEnv();
// Grab exactly where the forward map drew the node.
static NodeHit grabAt(const StageEnvelope& e, EnvNode node) {
const Rect a = wideArea();
// AttackEnd draws at x = left+28 (8px base + 0.2s * 102.125 px/s), y = top (level 1).
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 28, a.y);
CHECK(h.hit && h.node == EnvNode::AttackEnd);
// The sustain node (DecayEnd) at left+85, level 0.5 -> ~top+50.
NodeHit s = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 85, a.y + 50);
CHECK(s.hit && s.node == EnvNode::DecayEnd);
EnvVertex v;
if (!findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), node, v)) return NodeHit{};
return nodeAtPoint(e, overlayOf(a), kTotal, v.x, v.y);
}
static void testHitMissesOffEveryNode() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
// A point far from any drawn handle (right of the release ramp, well away from a node).
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 700, a.y + 5);
CHECK(!h.hit);
// --- hit-test ------------------------------------------------------------------
static void testEveryDrawnHandleIsGrabbable() {
const StageEnvelope e = ahdsrEnv();
const EnvNode want[] = {EnvNode::AttackEnd, EnvNode::HoldEnd, EnvNode::DecayEnd,
EnvNode::ReleaseStart, EnvNode::AttackCurve, EnvNode::DecayCurve,
EnvNode::ReleaseCurve};
for (EnvNode n : want) {
const NodeHit h = grabAt(e, n);
CHECK(h.hit);
CHECK(h.node == n);
}
}
static void testHitSkipsNonDraggableAnchors() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
// Origin draws at (left, bottom-1). Even a pixel-perfect grab there is NOT a draggable node.
NodeHit o = nodeAtPoint(e, overlayOf(a), kTotal, a.x, a.bottom() - 1);
CHECK(!o.hit);
// ReleaseStart draws at (left+235, sustain level ~top+50) — the fixed plateau end. It is
// drawing-only -> not grabbable; no other node is within the radius, so this grab misses.
NodeHit rs = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 235, a.y + 50);
CHECK(!rs.hit);
}
static void testHitNearestNodeWinsOverDrawOrder() {
// FA2 nearest-wins: with a SHORT hold, AttackEnd (x@28) and HoldEnd (x@37 — the 8px base
// plus 0.01s ~= 1px) both fall within the grab radius of a point at x@33 — the NEAREST
// (HoldEnd, 4px) must win, not the earlier draw-order AttackEnd (5px), so tightly packed
// handles stay individually grabbable.
AmpEnvelope e = gateEnv();
e.holdSeconds = 0.01;
const Rect a = wideArea();
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 33, a.y);
CHECK(h.hit && h.node == EnvNode::HoldEnd);
}
static void testGateDefaultsEveryNodeGrabbable() {
// THE FA2 headline regression: at the tier-0 Gate defaults (attack 3ms, hold 0, decay 0,
// sustain 1.0, release 60ms) the forward map's kGateNodeSepPx separation keeps every
// draggable node distinct, and a grab AT each drawn vertex resolves to THAT node — HoldEnd
// and DecayEnd are no longer shadowed by AttackEnd (pre-fix they were permanently
// ungrabbable in the default state).
const AmpEnvelope e; // struct defaults ARE the tier-0 Gate defaults
static void testAnchoredEndAndOriginAreNotGrabbable() {
const StageEnvelope e = ahdsrEnv();
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), kTotal);
CHECK(poly.size() == 6);
for (const EnvVertex& v : poly) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
const NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, v.x, v.y);
CHECK(h.hit && h.node == v.node);
}
EnvVertex end;
CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
// The bottom-right corner is fixed: a grab there either misses or resolves to a NEIGHBOUR,
// never to ReleaseEnd itself.
const NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, end.x, end.y);
CHECK(!h.hit || h.node != EnvNode::ReleaseEnd);
EnvVertex origin;
CHECK(findNode(poly, EnvNode::Origin, origin));
const NodeHit o = nodeAtPoint(e, overlayOf(a), kTotal, origin.x, origin.y);
CHECK(!o.hit || o.node != EnvNode::Origin);
}
// --- resolveNodeDrag Gate -----------------------------------------------------
static void testAhdHasNoSustainNodes() {
const StageEnvelope e = ahdEnv();
CHECK(grabAt(e, EnvNode::AttackEnd).hit);
CHECK(grabAt(e, EnvNode::HoldEnd).hit);
CHECK(grabAt(e, EnvNode::DecayEnd).hit);
// ReleaseStart is not drawn on an AHD at all, so there is nothing to grab.
CHECK(!grabAt(e, EnvNode::ReleaseStart).hit);
// And an explicit resolve of an other-kind node is a no-op rather than a stray write.
const StageEnvelope out = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(wideArea()),
kTotal, bounds(), 40, 0);
CHECK(out.releaseSeconds == e.releaseSeconds);
CHECK(out.attackSeconds == e.attackSeconds);
}
static void testGateAttackDragMovesOnlyAttack() {
const AmpEnvelope e = gateEnv();
static void testMissOutsideTheRadius() {
const StageEnvelope e = ahdsrEnv();
const Rect a = wideArea();
EnvClampBounds b; // default maxima 4.0s
// +50px at the GATE param-domain scale (~0.0098 s/px) on attack. Nothing else moves.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, b, 50, 0);
CHECK(near(out.attackSeconds, 0.2 + 50.0 * kGateSecPerPx));
CHECK(near(out.holdSeconds, e.holdSeconds));
CHECK(near(out.decaySeconds, e.decaySeconds));
CHECK(near(out.sustainLevel, e.sustainLevel));
CHECK(near(out.releaseSeconds, e.releaseSeconds));
}
static void testGateTimeLowerClampAtZero() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
// Drag attack far LEFT (-500px ~= -4.9s at the gate scale) from 0.2s: clamps to 0, never
// negative (monotonic: the segment cannot go below zero).
AmpEnvelope out = resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, b, -500, 0);
CHECK(near(out.attackSeconds, 0.0));
}
static void testGateTimeUpperClampAtSliderMax() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
b.maxDecaySeconds = 1.0; // the shell's decay slider tops out at 1.0s
// Drag decay far RIGHT (+2000px ~= +19.6s at the gate scale) from 0.3s: clamps to the slider
// max 1.0, NOT beyond (the drag can't produce a param the slider couldn't).
AmpEnvelope out = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 2000, 0);
CHECK(near(out.decaySeconds, 1.0));
}
static void testGateSustainNodeBothAxes() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
// DecayEnd: +100px X at the gate timed scale on decay; +bottom-ward Y LOWERS the level. Level
// span is 99 px for [0,1]; drag DOWN by ~10px (positive dy) lowers sustain by ~10/99 ~= 0.101.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 100, 10);
CHECK(near(out.decaySeconds, 0.3 + 100.0 * kGateSecPerPx));
CHECK(out.sustainLevel < e.sustainLevel); // dragged DOWN -> lower sustain
CHECK(near(out.sustainLevel, 0.5 - 10.0 / 99.0, 1e-6));
}
static void testGateSustainLevelClamps01() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
// Drag sustain UP hard (dy very negative): clamps to 1.0.
AmpEnvelope up = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 0, -10000);
CHECK(near(up.sustainLevel, 1.0));
// Drag sustain DOWN hard (dy very positive): clamps to 0.0.
AmpEnvelope dn = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 0, 10000);
CHECK(near(dn.sustainLevel, 0.0));
}
static void testGateTimeOnlyNodeIgnoresY() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
// HoldEnd is time-only: a big Y delta must NOT change any level (there is no level to change).
AmpEnvelope out = resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, b, 0, 500);
CHECK(near(out.holdSeconds, e.holdSeconds)); // dx 0 -> no time change either
CHECK(near(out.sustainLevel, e.sustainLevel)); // Y ignored for a time-only node
}
static void testGateReleaseEndGrabAndDrag() {
// The FA2 fix: ReleaseEnd is a drawn, IN-BOUNDS, grabbable handle (pre-FA2 it mapped past
// area.right() and could never be grabbed). gateEnv() draws it at x@284, level 0 (bottom row).
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 284, a.bottom() - 1);
CHECK(h.hit && h.node == EnvNode::ReleaseEnd);
// Dragging it RIGHT lengthens the release at the gate timed scale; only release changes.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::ReleaseEnd, overlayOf(a), kTotal, b, 85, 0);
CHECK(near(out.releaseSeconds, 0.4 + 85.0 * kGateSecPerPx));
CHECK(near(out.sustainLevel, e.sustainLevel));
CHECK(near(out.decaySeconds, e.decaySeconds));
// Far LEFT clamps to 0; far RIGHT clamps to the slider max.
AmpEnvelope lo = resolveNodeDrag(e, EnvNode::ReleaseEnd, overlayOf(a), kTotal, b, -2000, 0);
CHECK(near(lo.releaseSeconds, 0.0));
AmpEnvelope hi = resolveNodeDrag(e, EnvNode::ReleaseEnd, overlayOf(a), kTotal, b, 5000, 0);
CHECK(near(hi.releaseSeconds, b.maxReleaseSeconds));
}
static void testGateDragRoundTripTracksPixels() {
// 1:1 tracking (FA2): drag a Gate node by N px, rebuild the polyline from the edited params,
// and the node's drawn vertex has moved by ~N px (rounding may shift the landing by 1). The
// forward map is affine in each node's own segment duration with slope gatePxPerSecond and
// the inverse uses exactly the reciprocal, so the handle follows the cursor.
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
const int dx = 25;
for (EnvNode n : {EnvNode::AttackEnd, EnvNode::HoldEnd, EnvNode::DecayEnd,
EnvNode::ReleaseEnd}) {
EnvVertex before, after;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), n, before));
const AmpEnvelope edited = resolveNodeDrag(e, n, overlayOf(a), kTotal, b, dx, 0);
CHECK(findNode(buildEnvelopePolyline(edited, overlayOf(a), kTotal), n, after));
CHECK(std::abs((after.x - before.x) - dx) <= 1);
}
// The sustain node's Y axis tracks too: +10px down moves the drawn vertex ~10px down.
EnvVertex before, after;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::DecayEnd, before));
const AmpEnvelope edited = resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, b, 0, 10);
CHECK(findNode(buildEnvelopePolyline(edited, overlayOf(a), kTotal), EnvNode::DecayEnd, after));
CHECK(std::abs((after.y - before.y) - 10) <= 1);
}
// --- resolveNodeDrag Trigger --------------------------------------------------
static void testTriggerFadeInIsFractionOfPlaySpan() {
const AmpEnvelope e = triggerEnv(); // played span 1.0s -> 500px
const Rect a = wideArea();
EnvClampBounds b;
// +50px = +0.1s on the play timeline = +0.1/1.0 = +0.1 fraction. fadeIn 0.2 -> 0.3.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeInEnd, overlayOf(a), kTotal, b, 50, 0);
CHECK(near(out.fadeInFraction, 0.3));
CHECK(near(out.fadeOutFraction, e.fadeOutFraction)); // unchanged
}
static void testTriggerFadesCannotCross() {
AmpEnvelope e = triggerEnv();
e.fadeInFraction = 0.5;
e.fadeOutFraction = 0.3; // sum 0.8, room 0.2 before they'd cross
const Rect a = wideArea();
EnvClampBounds b;
// Drag fade-in far RIGHT (+2000px): would push fadeIn well past 1-fadeOut=0.7, but the mutual
// clamp caps it at 0.7 so the fade nodes never cross (monotonic on the play timeline).
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeInEnd, overlayOf(a), kTotal, b, 2000, 0);
CHECK(near(out.fadeInFraction, 0.7));
CHECK(near(out.fadeOutFraction, 0.3));
}
static void testTriggerFadeOutMovesOppositePixelDelta() {
const AmpEnvelope e = triggerEnv(); // fadeOut 0.2, play span 1.0s -> 500px
const Rect a = wideArea();
EnvClampBounds b;
// FadeOutStart sits at (1-fadeOut) of the span; dragging it LEFT (-50px) LENGTHENS the fade-out.
// -50px = -0.1s = -0.1 fraction on the span, applied OPPOSITE -> fadeOut 0.2 -> 0.3.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeOutStart, overlayOf(a), kTotal, b, -50, 0);
CHECK(near(out.fadeOutFraction, 0.3));
CHECK(near(out.fadeInFraction, e.fadeInFraction));
}
static void testTriggerZeroFadeOutGrabbableAtRightEdge() {
// The FA2 fix: at fade-out == 0 and full length, FadeOutStart draws AT the right edge
// (right-1, level 1). It must be grabbable there and draggable INWARD to grow the fade from
// zero (drag LEFT -> longer fade-out, opposite the pixel delta).
AmpEnvelope e;
e.mode = EnvMode::Trigger;
e.lengthFraction = 1.0; // played span = full 2.0s -> 1000px
e.fadeInFraction = 0.1;
e.fadeOutFraction = 0.0;
const Rect a = wideArea();
EnvClampBounds b;
NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.right() - 1, a.y);
CHECK(h.hit && h.node == EnvNode::FadeOutStart);
// -100px = -0.2s on the 2.0s played span, applied OPPOSITE -> fadeOut 0.0 -> 0.1.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::FadeOutStart, overlayOf(a), kTotal, b, -100, 0);
CHECK(near(out.fadeOutFraction, 0.1));
CHECK(near(out.lengthFraction, e.lengthFraction)); // length untouched
// LengthEnd sits at the same x but level 0 (bottom row) — grabbable at ITS drawn point.
NodeHit le = nodeAtPoint(e, overlayOf(a), kTotal, a.right() - 1, a.bottom() - 1);
CHECK(le.hit && le.node == EnvNode::LengthEnd);
}
static void testTriggerLengthClampsAtMax() {
const AmpEnvelope e = triggerEnv(); // length 0.5
const Rect a = wideArea();
EnvClampBounds b; // maxLengthFraction 1.0
// LengthEnd maps to a fraction of the WHOLE sample: +2000px = +4.0s = +2.0 fraction, clamps 1.0.
AmpEnvelope out = resolveNodeDrag(e, EnvNode::LengthEnd, overlayOf(a), kTotal, b, 2000, 0);
CHECK(near(out.lengthFraction, 1.0));
// Drag far LEFT clamps to 0.
AmpEnvelope lo = resolveNodeDrag(e, EnvNode::LengthEnd, overlayOf(a), kTotal, b, -2000, 0);
CHECK(near(lo.lengthFraction, 0.0));
}
// --- No-motion guards ---------------------------------------------------------
static void testNonDraggableNodeNoMotion() {
const AmpEnvelope e = gateEnv();
const Rect a = wideArea();
EnvClampBounds b;
AmpEnvelope o = resolveNodeDrag(e, EnvNode::Origin, overlayOf(a), kTotal, b, 500, 500);
CHECK(near(o.attackSeconds, e.attackSeconds) && near(o.sustainLevel, e.sustainLevel));
AmpEnvelope rs = resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, b, 500, 500);
CHECK(near(rs.releaseSeconds, e.releaseSeconds));
}
static void testDegenerateAreaNoMotion() {
const AmpEnvelope e = gateEnv();
EnvClampBounds b;
const Rect zeroW = Rect::ltrb(0, 0, 0, 100);
AmpEnvelope o1 = resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(zeroW), kTotal, b, 500, 0);
CHECK(near(o1.attackSeconds, e.attackSeconds));
AmpEnvelope o2 = resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(wideArea()), 0.0, b, 500, 0); // no time
CHECK(near(o2.attackSeconds, e.attackSeconds));
}
static void testCrossModeNodeNoMotion() {
// A node from the OTHER mode never writes (FA2 guard): the degenerate baseline polyline
// carries a ReleaseEnd vertex regardless of mode, so a Trigger-mode grab of it (e.g. over a
// zero-height canvas) must NOT write releaseSeconds — and symmetrically a Trigger node is
// inert on a Gate envelope.
EnvClampBounds b;
const AmpEnvelope t = triggerEnv();
AmpEnvelope out = resolveNodeDrag(t, EnvNode::ReleaseEnd, overlayOf(wideArea()), kTotal, b, 50, 0);
CHECK(near(out.releaseSeconds, t.releaseSeconds));
const AmpEnvelope g = gateEnv();
out = resolveNodeDrag(g, EnvNode::FadeInEnd, overlayOf(wideArea()), kTotal, b, 50, 0);
CHECK(near(out.fadeInFraction, g.fadeInFraction));
// And the zero-height baseline's ReleaseEnd is not even reported grabbable in Trigger mode.
const Rect flat = Rect::ltrb(0, 0, 100, 0);
const NodeHit h = nodeAtPoint(t, overlayOf(flat), kTotal, 99, 0);
// Far from every handle in both axes.
const NodeHit h = nodeAtPoint(e, overlayOf(a), kTotal, a.x + 3, a.bottom() - 40);
CHECK(!h.hit);
}
// --- AHDSR drags ---------------------------------------------------------------
static void testAhdsrStageTimesTrackTheSchematicScale() {
const Rect a = wideArea();
const StageEnvelope e = ahdsrEnv();
const double secPerPx = 1.0 / gatePxPerSecond(a);
const StageEnvelope attack =
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 50, 0);
CHECK(std::fabs(attack.attackSeconds - (e.attackSeconds + 50 * secPerPx)) < 1e-9);
CHECK(attack.holdSeconds == e.holdSeconds); // only the dragged param moves
const StageEnvelope hold =
resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -20, 0);
CHECK(std::fabs(hold.holdSeconds - (e.holdSeconds - 20 * secPerPx)) < 1e-9);
const StageEnvelope decay =
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 30, 0);
CHECK(std::fabs(decay.decaySeconds - (e.decaySeconds + 30 * secPerPx)) < 1e-9);
}
// The release is dragged from its TOP node and its end is anchored to the canvas edge, so
// pulling that node LEFT lengthens the release — the sign is inverted relative to every other
// stage.
static void testReleaseDragsFromItsStartWithInvertedSign() {
const Rect a = wideArea();
const StageEnvelope e = ahdsrEnv();
const double secPerPx = 1.0 / gatePxPerSecond(a);
const StageEnvelope longer =
resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), -40, 0);
CHECK(std::fabs(longer.releaseSeconds - (e.releaseSeconds + 40 * secPerPx)) < 1e-9);
const StageEnvelope shorter =
resolveNodeDrag(e, EnvNode::ReleaseStart, overlayOf(a), kTotal, bounds(), 40, 0);
CHECK(shorter.releaseSeconds < e.releaseSeconds);
}
static void testSustainLevelOnTheDecayNodesYAxis() {
const Rect a = wideArea();
const StageEnvelope e = ahdsrEnv();
const double lvlPerPx = 1.0 / (a.height - 1);
const StageEnvelope up =
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -10);
CHECK(std::fabs(up.sustainLevel - (e.sustainLevel + 10 * lvlPerPx)) < 1e-9);
// Clamped to [0,1] at both ends.
CHECK(resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, -10000)
.sustainLevel == 1.0);
CHECK(resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 0, 10000)
.sustainLevel == 0.0);
}
static void testStageTimesClampToTheKnobDomain() {
const Rect a = wideArea();
const StageEnvelope e = ahdsrEnv();
CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 100000, 0)
.attackSeconds == bounds().maxAttackSeconds);
CHECK(resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), -100000, 0)
.attackSeconds == 0.0);
}
// --- AHD drags -----------------------------------------------------------------
static void testAhdStageTimesTrackTheWallClockScale() {
const Rect a = wideArea();
const StageEnvelope e = ahdEnv();
const double secPerPx = kTotal / a.width;
const StageEnvelope attack =
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 100, 0);
CHECK(std::fabs(attack.attackSeconds - (e.attackSeconds + 100 * secPerPx)) < 1e-9);
const StageEnvelope decay =
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 100, 0);
CHECK(std::fabs(decay.decaySeconds - (e.decaySeconds + 100 * secPerPx)) < 1e-9);
}
// Hold is a fraction of what attack and decay left, so the node's pixel motion converts through
// that remainder — and the fraction can never leave [0,1], which is what keeps the sum bounded.
static void testAhdHoldNodeEditsTheFraction() {
const Rect a = wideArea();
const StageEnvelope e = ahdEnv();
const double secPerPx = kTotal / a.width;
const AhdSplit s = splitAhdSeconds(e);
const double rem = e.spanSeconds - s.attack - s.decay;
const StageEnvelope moved =
resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 100, 0);
CHECK(std::fabs(moved.holdFraction - ((s.hold + 100 * secPerPx) / rem)) < 1e-9);
CHECK(resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), 100000, 0)
.holdFraction == 1.0);
CHECK(resolveNodeDrag(e, EnvNode::HoldEnd, overlayOf(a), kTotal, bounds(), -100000, 0)
.holdFraction == 0.0);
}
// --- curve knots ---------------------------------------------------------------
static void testKnotDragMovesTheExponentWithinItsDomain() {
const Rect a = wideArea();
StageEnvelope e = ahdsrEnv();
e.attackCurve = util::kCurveNeutral;
const StageEnvelope up =
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, -12);
const StageEnvelope down =
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 12);
// Dragging the attack knot UP (toward the ceiling) is a faster-rising, SMALLER exponent.
CHECK(up.attackCurve < util::kCurveNeutral);
CHECK(down.attackCurve > util::kCurveNeutral);
CHECK(up.attackCurve >= util::kCurveMin && up.attackCurve <= util::kCurveMax);
CHECK(down.attackCurve >= util::kCurveMin && down.attackCurve <= util::kCurveMax);
// Extreme drags saturate at the domain endpoints rather than escaping them.
CHECK(resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, -100000)
.attackCurve == util::kCurveMin);
CHECK(resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 100000)
.attackCurve == util::kCurveMax);
// Only the dragged segment's exponent moves.
CHECK(up.decayCurve == e.decayCurve && up.releaseCurve == e.releaseCurve);
CHECK(up.attackSeconds == e.attackSeconds);
}
// The one-model rule, asserted structurally: the drawn knot's height IS the shared law's
// reading of the stored exponent, and a zero-delta drag from that grab reproduces the exponent
// exactly — so the overlay and the inner dial cannot express different values for one field.
static void testKnotAndModelCannotDiverge() {
const Rect a = wideArea();
for (double exp : {0.2, 0.5, 1.0, 2.0, 7.0}) {
StageEnvelope e = ahdsrEnv();
e.attackCurve = exp;
EnvVertex knot;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::AttackCurve,
knot));
CHECK(std::fabs(knot.level - util::curveMidLevel(exp)) < 1e-12);
const StageEnvelope same =
resolveNodeDrag(e, EnvNode::AttackCurve, overlayOf(a), kTotal, bounds(), 0, 0);
CHECK(std::fabs(same.attackCurve - exp) < 1e-9);
}
}
// A decay into a sustain of exactly 1.0 is a LEVEL segment: there is no curve to express, so
// the drag must leave the exponent alone rather than divide by a zero level span.
static void testKnotOnALevelSegmentIsANoOp() {
const Rect a = wideArea();
StageEnvelope e = ahdsrEnv();
e.sustainLevel = 1.0;
e.decayCurve = 2.5;
const StageEnvelope out =
resolveNodeDrag(e, EnvNode::DecayCurve, overlayOf(a), kTotal, bounds(), 0, -30);
CHECK(out.decayCurve == 2.5);
}
// --- degenerate ----------------------------------------------------------------
static void testDegenerateInputsAreNoOps() {
const StageEnvelope e = ahdsrEnv();
const StageEnvelope zeroArea =
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(Rect{}), kTotal, bounds(), 50, 0);
CHECK(zeroArea.attackSeconds == e.attackSeconds);
const StageEnvelope zeroDur =
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(wideArea()), 0.0, bounds(), 50, 0);
CHECK(zeroDur.attackSeconds == e.attackSeconds);
}
int main() {
testHitGrabsDrawnHandle();
testHitMissesOffEveryNode();
testHitSkipsNonDraggableAnchors();
testHitNearestNodeWinsOverDrawOrder();
testGateDefaultsEveryNodeGrabbable();
testEveryDrawnHandleIsGrabbable();
testAnchoredEndAndOriginAreNotGrabbable();
testAhdHasNoSustainNodes();
testMissOutsideTheRadius();
testGateAttackDragMovesOnlyAttack();
testGateTimeLowerClampAtZero();
testGateTimeUpperClampAtSliderMax();
testGateSustainNodeBothAxes();
testGateSustainLevelClamps01();
testGateTimeOnlyNodeIgnoresY();
testGateReleaseEndGrabAndDrag();
testGateDragRoundTripTracksPixels();
testAhdsrStageTimesTrackTheSchematicScale();
testReleaseDragsFromItsStartWithInvertedSign();
testSustainLevelOnTheDecayNodesYAxis();
testStageTimesClampToTheKnobDomain();
testTriggerFadeInIsFractionOfPlaySpan();
testTriggerFadesCannotCross();
testTriggerFadeOutMovesOppositePixelDelta();
testTriggerZeroFadeOutGrabbableAtRightEdge();
testTriggerLengthClampsAtMax();
testAhdStageTimesTrackTheWallClockScale();
testAhdHoldNodeEditsTheFraction();
testNonDraggableNodeNoMotion();
testDegenerateAreaNoMotion();
testCrossModeNodeNoMotion();
testKnotDragMovesTheExponentWithinItsDomain();
testKnotAndModelCannotDiverge();
testKnotOnALevelSegmentIsANoOp();
testDegenerateInputsAreNoOps();
if (g_fail == 0) std::printf("envelope_edit: all tests passed\n");
else std::printf("envelope_edit: %d FAILED\n", g_fail);
+247 -300
View File
@@ -1,21 +1,19 @@
// Standalone tests for reasampler::instrument::ui::envelope_overlay — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests. Assert the S-VIEW-3/FA2 amp-envelope ->
// polyline FORWARD map: the Gate BOUNDED-SCHEMATIC AHDSR shape (attack ramp / hold plateau /
// decay-to-sustain / fixed-width sustain plateau / in-bounds release) and the Trigger
// fade/%-length shape at the waveform time base.
// Standalone tests for reasampler::instrument::ui::envelope_overlay — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests. Assert the staged-envelope ->
// polyline FORWARD map for BOTH layout policies: the AHDSR bounded schematic with its
// 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 — the FA2
// bounds invariant, no 32-bit overflow on huge times, degenerate area/duration); gateTimedWidth
// + gatePxPerSecond; buildEnvelopePolyline Gate (node order, levels, PARAM-DOMAIN timed-region
// placement independent of sample duration, per-segment kGateNodeSepPx separation every node
// distinct even at the tier-0 zero-hold/zero-decay defaults, fixed sustain-plateau reserve,
// release visible in-bounds, overrun compressed from the right preserving the minimum gaps,
// every vertex in-bounds); buildEnvelopePolyline Trigger (fade-in/unity/fade-out at fractions of
// the played span, overlap clamp, full-length/zero-fade-out nodes in-bounds at right-1);
// degenerate flat baseline.
// Covers: timeToX / levelToY (linear maps, edge clamps, past-end clamped to right-1, no 32-bit
// overflow on huge times, degenerate area/duration); gatePxPerSecond; the AHDSR polyline (node
// order, levels, release anchored at the right edge, the sustain plateau reaching the edge at
// zero release, per-segment separation at the tier-0 defaults, overrun compression, every
// vertex in-bounds); splitAhdSeconds (A+H+D never exceeds the span, hold at 0% and 100%); the
// AHD polyline (1:1 with the time axis, origin offset); curve knots (present only on sloped
// non-zero segments, height following the exponent); the degenerate flat baseline.
#include "../src/core/instrument/ui/envelope_overlay.h"
#include <cmath>
#include <cstdio>
#include <vector>
@@ -32,13 +30,38 @@ static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
// bugs). Under levelToY the level span is height-1 = 99 rows.
static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 110); } // width 1000, height 100
// Find the first vertex with a given node in a polyline; asserts presence via the returned bool.
static bool findNode(const std::vector<EnvVertex>& poly, EnvNode node, EnvVertex& out) {
for (const EnvVertex& v : poly) {
if (v.node == node) { out = v; return true; }
}
return false;
}
static bool hasNode(const std::vector<EnvVertex>& poly, EnvNode node) {
EnvVertex v;
return findNode(poly, node, v);
}
static StageEnvelope ahdsr(double a, double h, double d, double sus, double r) {
StageEnvelope e;
e.kind = EnvKind::Ahdsr;
e.attackSeconds = a;
e.holdSeconds = h;
e.decaySeconds = d;
e.sustainLevel = sus;
e.releaseSeconds = r;
return e;
}
static StageEnvelope ahd(double a, double d, double frac, double origin, double span) {
StageEnvelope e;
e.kind = EnvKind::Ahd;
e.attackSeconds = a;
e.decaySeconds = d;
e.holdFraction = frac;
e.originSeconds = origin;
e.spanSeconds = span;
return e;
}
// --- timeToX / levelToY -------------------------------------------------------
@@ -49,347 +72,271 @@ static void testTimeToXEndpoints() {
CHECK(timeToX(a, 2.0, 1.0) == a.x + 500); // midpoint
}
static void testTimeToXNegativePinsLeft() {
const Rect a = wideArea();
CHECK(timeToX(a, 2.0, -0.5) == a.x); // t<0 pins left
}
static void testTimeToXPastEndClamps() {
// FA2 bounds invariant: t past total pins to the last in-bounds column, never past right.
static void testTimeToXClampsBothEnds() {
const Rect a = wideArea();
CHECK(timeToX(a, 2.0, -0.5) == a.x);
CHECK(timeToX(a, 2.0, 3.0) == a.right() - 1);
CHECK(timeToX(a, 2.0, 1000.0) == a.right() - 1);
// A HUGE t must clamp in double space, not overflow the integer cast (32-bit long on
// Windows would wrap to LONG_MIN and pin to the WRONG edge).
CHECK(timeToX(a, 2.0, 1e15) == a.right() - 1);
}
static void testGateTimedWidth() {
// 15% of the 1000px canvas is reserved for the sustain plateau -> 850px timed region.
CHECK(gateTimedWidth(wideArea()) == 850);
// Zero-width area -> 0; a tiny area still yields >= 1 so the px<->s scale never degenerates.
CHECK(gateTimedWidth(Rect::ltrb(5, 5, 5, 45)) == 0);
CHECK(gateTimedWidth(Rect::ltrb(0, 0, 1, 10)) == 1);
}
static void testGatePxPerSecond() {
// PARAM-DOMAIN scale: (timedW - 1 - 4*sep) px spread over 4 x kGateStageMaxSeconds. For the
// 1000px canvas: (850 - 1 - 32) / 8.0s = 817/8 px/s. Independent of any sample duration.
const double expected = 817.0 / (4.0 * kGateStageMaxSeconds);
CHECK(gatePxPerSecond(wideArea()) == expected);
CHECK(gatePxPerSecond(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0
CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0
}
static void testTimeToXDegenerate() {
const Rect a = wideArea();
CHECK(timeToX(a, 0.0, 1.0) == a.x); // no duration -> left
const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width
CHECK(timeToX(z, 2.0, 1.0) == z.x);
}
static void testLevelToYEndpoints() {
static void testLevelToY() {
const Rect a = wideArea();
CHECK(levelToY(a, 1.0) == a.y); // level 1 -> top row
CHECK(levelToY(a, 0.0) == a.bottom() - 1); // level 0 -> bottom row
CHECK(levelToY(a, 0.5) == a.y + 50); // mid: round((1-0.5)*99)=round(49.5)=50
CHECK(levelToY(a, 0.5) == a.y + 50); // 99-row span, rounded
CHECK(levelToY(a, 5.0) == a.y); // clamps
CHECK(levelToY(a, -5.0) == a.bottom() - 1);
}
static void testLevelToYClamps() {
const Rect a = wideArea();
CHECK(levelToY(a, 2.0) == a.y); // >1 clamps to top
CHECK(levelToY(a, -1.0) == a.bottom() - 1); // <0 clamps to bottom
const Rect z = Rect::ltrb(5, 5, 45, 5); // zero height
CHECK(levelToY(z, 0.5) == z.y);
static void testDegenerateAreaAndDuration() {
CHECK(timeToX(Rect{}, 2.0, 1.0) == 0);
CHECK(timeToX(wideArea(), 0.0, 1.0) == wideArea().x);
CHECK(levelToY(Rect{}, 0.5) == 0);
CHECK(gatePxPerSecond(Rect{}) == 0.0);
}
// --- Gate polyline ------------------------------------------------------------
// --- the AHDSR schematic ------------------------------------------------------
static void testGateNodeOrderAndLevels() {
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.4;
static void testAhdsrNodeOrderAndLevels() {
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
// Six vertices, in draw order.
CHECK(poly.size() == 6);
CHECK(poly[0].node == EnvNode::Origin);
CHECK(poly[1].node == EnvNode::AttackEnd);
CHECK(poly[2].node == EnvNode::HoldEnd);
CHECK(poly[3].node == EnvNode::DecayEnd);
CHECK(poly[4].node == EnvNode::ReleaseStart);
CHECK(poly[5].node == EnvNode::ReleaseEnd);
// Levels: origin 0, attack/hold peak 1, decay settles to sustain, plateau holds sustain,
// release ends at 0.
CHECK(poly[0].level == 0.0);
CHECK(poly[1].level == 1.0);
CHECK(poly[2].level == 1.0);
CHECK(poly[3].level == 0.5); // sustain
CHECK(poly[4].level == 0.5); // plateau end holds sustain
CHECK(poly[5].level == 0.0);
}
static void testGateSchematicPlacement() {
// FA2 bounded schematic at the PARAM-DOMAIN scale: timed region = 850px (150px reserved
// plateau), pps = (850-1-32)/8s = 102.125 px/s, each segment prefixed by the 8px separation
// base. attack .2 -> x@round(8+20.425)=28; hold .1 -> x@round(28.425+8+10.2125)=47; decay
// .3 -> x@round(46.6375+8+30.6375)=85; plateau is the FIXED 150px reserve -> ReleaseStart
// x@235; release .4 -> x@round(235.275+8+40.85)=284, well inside the canvas.
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.4;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(0.2, 0.1, 0.3, 0.5, 0.4), overlayOf(a), 4.0);
EnvVertex v;
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 28);
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 47);
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 85);
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 235);
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 284);
}
static void testGateLayoutIndependentOfSampleDuration() {
// The Gate schematic is scaled by the PARAM domain, NOT the capture length: the same params
// produce the SAME polyline over a 0.3s and a 10s sample (pre-fix, a 60ms release on a 10s
// capture collapsed to ~5px while 2s stages on a 0.3s capture pinned to the right edge).
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.06;
const Rect a = wideArea();
CHECK(buildEnvelopePolyline(env, overlayOf(a), 0.3) == buildEnvelopePolyline(env, overlayOf(a), 10.0));
}
static void testGateMinSeparationAtDefaults() {
// THE FA2 headline: at the tier-0 Gate defaults (attack 3ms, hold 0, decay 0, sustain 1.0,
// release 60ms) every consecutive node pair is at least kGateNodeSepPx apart — no node ever
// renders on top of its neighbour, so each is individually grabbable.
const AmpEnvelope env; // struct defaults ARE the tier-0 Gate defaults
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
CHECK(poly.size() == 6);
for (size_t i = 1; i < poly.size(); ++i) {
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx);
CHECK(poly.size() >= 6);
CHECK(poly[0].node == EnvNode::Origin && poly[0].level == 0.0);
CHECK(poly[1].node == EnvNode::AttackEnd && poly[1].level == 1.0);
CHECK(poly[2].node == EnvNode::HoldEnd && poly[2].level == 1.0);
CHECK(poly[3].node == EnvNode::DecayEnd && poly[3].level == 0.5);
CHECK(poly[4].node == EnvNode::ReleaseStart && poly[4].level == 0.5);
CHECK(poly[5].node == EnvNode::ReleaseEnd && poly[5].level == 0.0);
// Monotone in x across the traced line.
for (std::size_t i = 1; i < 6; ++i) CHECK(poly[i].x >= poly[i - 1].x);
// Every vertex in-bounds.
for (const EnvVertex& p : poly) {
CHECK(p.x >= a.x && p.x <= a.right() - 1);
CHECK(p.y >= a.y && p.y <= a.bottom() - 1);
}
CHECK(findNode(poly, EnvNode::ReleaseEnd, v));
CHECK(v.x == a.right() - 1); // ANCHORED, whatever the release is
}
static void testGateSustainPlateauFixedWidth() {
// The sustain plateau is ALWAYS the reserved width (canvas - timed region), independent of
// the AHDSR times — the bounded region that replaces the old plateau-to-sample-end.
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.1;
env.holdSeconds = 0.0;
env.decaySeconds = 0.2;
env.sustainLevel = 0.6;
env.releaseSeconds = 0.3;
// The layout failure this policy exists to fix: at zero release the sustain plateau must run to
// (near) the right edge instead of the figure bunching left.
static void testZeroReleasePutsTheSustainPlateauAtTheRightEdge() {
const Rect a = wideArea();
const int plateauPx = a.width - gateTimedWidth(a); // 150
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
EnvVertex decay, plateauEnd;
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(0.05, 0.0, 0.05, 0.7, 0.0), overlayOf(a), 4.0);
EnvVertex plateau, end;
CHECK(findNode(poly, EnvNode::ReleaseStart, plateau));
CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
CHECK(end.x == a.right() - 1);
// One node separation short of the edge — the plateau spans essentially the whole canvas.
CHECK(plateau.x == a.right() - 1 - kGateNodeSepPx);
EnvVertex decay;
CHECK(findNode(poly, EnvNode::DecayEnd, decay));
CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd));
CHECK(plateauEnd.x - decay.x == plateauPx);
CHECK(plateauEnd.level == 0.6); // plateau holds the sustain level
CHECK(plateau.x - decay.x > a.width / 2);
}
static void testGateReleaseVisibleInBounds() {
// The FA2 fix: Release is a VISIBLE, in-bounds segment — ReleaseEnd sits strictly right of
// the plateau end and strictly inside the canvas (pre-FA2 it mapped past area.right() and the
// shell clipped its handle away).
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.4;
// The release END never moves; the release START is what a longer release pushes left.
static void testReleaseGrowsLeftwardFromTheAnchor() {
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
EnvVertex plateauEnd, rel;
CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd));
CHECK(findNode(poly, EnvNode::ReleaseEnd, rel));
CHECK(rel.x > plateauEnd.x); // a visible ramp, not a collapsed point
CHECK(rel.x < a.right()); // strictly in-bounds
CHECK(rel.level == 0.0);
EnvVertex shortStart, longStart, shortEnd, longEnd;
const std::vector<EnvVertex> shortR =
buildEnvelopePolyline(ahdsr(0.1, 0.0, 0.1, 0.5, 0.1), overlayOf(a), 4.0);
const std::vector<EnvVertex> longR =
buildEnvelopePolyline(ahdsr(0.1, 0.0, 0.1, 0.5, 1.5), overlayOf(a), 4.0);
CHECK(findNode(shortR, EnvNode::ReleaseStart, shortStart));
CHECK(findNode(longR, EnvNode::ReleaseStart, longStart));
CHECK(findNode(shortR, EnvNode::ReleaseEnd, shortEnd));
CHECK(findNode(longR, EnvNode::ReleaseEnd, longEnd));
CHECK(longStart.x < shortStart.x);
CHECK(shortEnd.x == longEnd.x);
}
static void testGateOverrunCompressesFromRight() {
// Stages BEYOND the schematic domain (4.0s each > kGateStageMaxSeconds): the layout
// compresses from the right preserving the minimum gaps — ReleaseEnd pins to the last
// in-bounds column, but the trailing nodes stay strictly increasing and individually
// separated (>= kGateNodeSepPx), NOT piled on one pixel. NOTHING maps past area.right().
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 4.0;
env.holdSeconds = 4.0;
env.decaySeconds = 4.0;
env.sustainLevel = 0.7;
env.releaseSeconds = 4.0;
// Tier-0 defaults are zero hold and zero decay; every node still has to be independently
// grabbable, which is what the per-segment separation base buys.
static void testTierZeroDefaultsKeepEveryNodeDistinct() {
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
CHECK(poly.size() == 6);
EnvVertex plateauEnd, rel;
CHECK(findNode(poly, EnvNode::ReleaseStart, plateauEnd));
CHECK(findNode(poly, EnvNode::ReleaseEnd, rel));
CHECK(rel.x == a.right() - 1); // pinned to the last in-bounds column
CHECK(plateauEnd.level == 0.7); // still at sustain
for (size_t i = 1; i < poly.size(); ++i) {
CHECK(poly[i].x > poly[i - 1].x); // strictly monotonic
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1); // min gaps survive compression
CHECK(poly[i].x >= a.x && poly[i].x < a.right()); // in-bounds
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(0.003, 0.0, 0.0, 1.0, 0.060), overlayOf(a), 4.0);
for (std::size_t i = 1; i < 6; ++i) {
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1);
}
}
static void testGateAllVerticesInBounds() {
// The FA2 bounds invariant, swept over representative param sets (including extremes): every
// vertex of every polyline stays inside the canvas rect.
// Every stage maxed: the schematic exactly fills the canvas, the plateau collapses to its
// minimum gap, and nothing escapes the rect.
static void testMaxedStagesCompressWithoutOverrunning() {
const Rect a = wideArea();
const AmpEnvelope base; // defaults
AmpEnvelope big = base;
big.mode = EnvMode::Gate;
big.attackSeconds = 4.0; big.holdSeconds = 4.0; big.decaySeconds = 4.0;
big.sustainLevel = 1.0; big.releaseSeconds = 4.0;
AmpEnvelope zero = base;
zero.mode = EnvMode::Gate;
zero.attackSeconds = 0.0; zero.holdSeconds = 0.0; zero.decaySeconds = 0.0;
zero.sustainLevel = 0.0; zero.releaseSeconds = 0.0;
AmpEnvelope trig = base;
trig.mode = EnvMode::Trigger;
trig.lengthFraction = 1.0; trig.fadeInFraction = 0.0; trig.fadeOutFraction = 0.0;
// ABSURD stage values must clamp in double space, not overflow the integer cast (32-bit
// long on Windows would wrap negative and land on the WRONG edge).
AmpEnvelope huge = base;
huge.mode = EnvMode::Gate;
huge.releaseSeconds = 1e12;
const double m = kGateStageMaxSeconds;
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(m, m, m, 0.5, m), overlayOf(a), 4.0);
for (std::size_t i = 1; i < 6; ++i) {
CHECK(poly[i].x >= poly[i - 1].x);
CHECK(poly[i].x <= a.right() - 1);
}
EnvVertex end;
CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
CHECK(end.x == a.right() - 1);
}
for (const AmpEnvelope& env : {base, big, zero, trig, huge}) {
for (const EnvVertex& v : buildEnvelopePolyline(env, overlayOf(a), 2.0)) {
CHECK(v.x >= a.x && v.x < a.right());
CHECK(v.y >= a.y && v.y < a.bottom());
// --- the AHD split ------------------------------------------------------------
// The combined-time bound, asserted structurally across the full domains: no (attack, decay,
// fraction) triple can exceed the span, and no clamp on the SUM exists to be exercised.
static void testAhdSplitNeverExceedsTheSpan() {
const double span = 3.0;
for (int ai = 0; ai <= 20; ++ai) {
for (int di = 0; di <= 20; ++di) {
for (int fi = 0; fi <= 10; ++fi) {
const StageEnvelope e =
ahd(ai * 0.25, di * 0.25, fi * 0.1, 0.0, span);
const AhdSplit s = splitAhdSeconds(e);
CHECK(s.attack >= 0.0 && s.hold >= 0.0 && s.decay >= 0.0);
CHECK(s.total <= span + 1e-9);
CHECK(std::fabs(s.total - (s.attack + s.hold + s.decay)) < 1e-12);
}
}
}
}
// --- Trigger polyline ---------------------------------------------------------
static void testHoldFractionEndpoints() {
const StageEnvelope none = ahd(0.5, 0.5, 0.0, 0.0, 4.0);
const AhdSplit s0 = splitAhdSeconds(none);
CHECK(s0.hold == 0.0);
CHECK(std::fabs(s0.total - 1.0) < 1e-12);
static void testTriggerShape() {
// played span = length * total = 0.5 * 2.0 = 1.0s -> 500px wide. fadeIn .2 of play -> 0.2s
// (x@100), fade-out .3 of play -> begins at 0.7s (x@350), playEnd at 1.0s (x@500).
AmpEnvelope env;
env.mode = EnvMode::Trigger;
env.lengthFraction = 0.5;
env.fadeInFraction = 0.2;
env.fadeOutFraction = 0.3;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
const StageEnvelope full = ahd(0.5, 0.5, 1.0, 0.0, 4.0);
const AhdSplit s1 = splitAhdSeconds(full);
// 100% of what attack and decay left: 4 - 0.5 - 0.5 = 3.
CHECK(std::fabs(s1.hold - 3.0) < 1e-12);
CHECK(std::fabs(s1.total - 4.0) < 1e-12);
CHECK(poly.size() == 4);
CHECK(poly[0].node == EnvNode::Origin);
CHECK(poly[1].node == EnvNode::FadeInEnd);
CHECK(poly[2].node == EnvNode::FadeOutStart);
CHECK(poly[3].node == EnvNode::LengthEnd);
EnvVertex v;
CHECK(findNode(poly, EnvNode::FadeInEnd, v) && v.x == a.x + 100 && v.level == 1.0);
CHECK(findNode(poly, EnvNode::FadeOutStart, v) && v.x == a.x + 350 && v.level == 1.0);
CHECK(findNode(poly, EnvNode::LengthEnd, v) && v.x == a.x + 500 && v.level == 0.0);
// Attack + decay alone longer than the span: they fit by their own per-stage bounds and the
// remainder — and therefore hold — is zero. Still no clamp on the sum.
const AhdSplit s2 = splitAhdSeconds(ahd(3.0, 3.0, 1.0, 0.0, 4.0));
CHECK(std::fabs(s2.attack - 3.0) < 1e-12);
CHECK(std::fabs(s2.decay - 1.0) < 1e-12);
CHECK(s2.hold == 0.0);
CHECK(std::fabs(s2.total - 4.0) < 1e-12);
}
static void testTriggerFadeOverlapClamp() {
// fadeIn + fadeOut > 1: the fade-out is trimmed so they meet exactly (no crossed nodes).
AmpEnvelope env;
env.mode = EnvMode::Trigger;
env.lengthFraction = 1.0; // played span = full 2.0s -> 1000px
env.fadeInFraction = 0.8; // fade-in end at 0.8*2.0 = 1.6s -> x@800
env.fadeOutFraction = 0.6; // would be 1.4s -> clamped to 1-0.8=0.2 -> begins at 0.8*2.0 too
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
// --- the AHD polyline ---------------------------------------------------------
EnvVertex fin, fout;
CHECK(findNode(poly, EnvNode::FadeInEnd, fin));
CHECK(findNode(poly, EnvNode::FadeOutStart, fout));
CHECK(fin.x == fout.x); // fades meet exactly, never cross
CHECK(fin.x == a.x + 800);
// The 1:1 property: a stage boundary at N seconds sits over the waveform at N seconds.
static void testAhdIsOneToOneWithTheTimeAxis() {
const Rect a = wideArea();
const double total = 8.0;
const StageEnvelope e = ahd(1.0, 2.0, 0.5, 1.0, 6.0);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(e, overlayOf(a), total);
const AhdSplit s = splitAhdSeconds(e);
EnvVertex origin, attack, hold, decay;
CHECK(findNode(poly, EnvNode::Origin, origin));
CHECK(findNode(poly, EnvNode::AttackEnd, attack));
CHECK(findNode(poly, EnvNode::HoldEnd, hold));
CHECK(findNode(poly, EnvNode::DecayEnd, decay));
CHECK(origin.x == timeToX(a, total, 1.0));
CHECK(attack.x == timeToX(a, total, 1.0 + s.attack));
CHECK(hold.x == timeToX(a, total, 1.0 + s.attack + s.hold));
CHECK(decay.x == timeToX(a, total, 1.0 + s.total));
// Levels: rises to unity, holds, falls to zero. No sustain-only nodes exist.
CHECK(origin.level == 0.0 && attack.level == 1.0 && hold.level == 1.0 && decay.level == 0.0);
CHECK(!hasNode(poly, EnvNode::ReleaseStart));
CHECK(!hasNode(poly, EnvNode::ReleaseEnd));
CHECK(!hasNode(poly, EnvNode::ReleaseCurve));
}
static void testTriggerFullLengthZeroFadeOutInBounds() {
// The FA2 fix: at full length + zero fade-out, FadeOutStart and LengthEnd land AT the last
// in-bounds column (right-1), NOT at the half-open right edge — so the shell draws their
// handles and the fade-out node is grabbable even when fade-out == 0.
AmpEnvelope env;
env.mode = EnvMode::Trigger;
env.lengthFraction = 1.0;
env.fadeInFraction = 0.1;
env.fadeOutFraction = 0.0;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
// --- curve knots --------------------------------------------------------------
EnvVertex fout, lend;
CHECK(findNode(poly, EnvNode::FadeOutStart, fout));
CHECK(findNode(poly, EnvNode::LengthEnd, lend));
CHECK(fout.x == a.right() - 1); // present + in-bounds at zero fade-out
CHECK(lend.x == a.right() - 1);
CHECK(fout.level == 1.0 && lend.level == 0.0);
// A knot rides every sloped stage that has a duration, and none that does not — a zero-length
// stage has no interior to put a handle in.
static void testKnotsRideOnlySlopedNonZeroSegments() {
const Rect a = wideArea();
const std::vector<EnvVertex> full =
buildEnvelopePolyline(ahdsr(0.2, 0.2, 0.2, 0.5, 0.2), overlayOf(a), 4.0);
CHECK(hasNode(full, EnvNode::AttackCurve));
CHECK(hasNode(full, EnvNode::DecayCurve));
CHECK(hasNode(full, EnvNode::ReleaseCurve));
const std::vector<EnvVertex> flat =
buildEnvelopePolyline(ahdsr(0.0, 0.2, 0.0, 0.5, 0.0), overlayOf(a), 4.0);
CHECK(!hasNode(flat, EnvNode::AttackCurve));
CHECK(!hasNode(flat, EnvNode::DecayCurve));
CHECK(!hasNode(flat, EnvNode::ReleaseCurve));
const std::vector<EnvVertex> ahdPoly =
buildEnvelopePolyline(ahd(0.5, 0.5, 0.5, 0.0, 4.0), overlayOf(a), 4.0);
CHECK(hasNode(ahdPoly, EnvNode::AttackCurve));
CHECK(hasNode(ahdPoly, EnvNode::DecayCurve));
// Every knot is flagged as one and every stage node is not.
for (const EnvVertex& v : ahdPoly) {
const bool isKnot = v.node == EnvNode::AttackCurve || v.node == EnvNode::DecayCurve;
CHECK(v.knot == isKnot);
}
}
// --- Degenerate ---------------------------------------------------------------
// The knot's HEIGHT is the exponent, read through the shared law: neutral sits at the segment
// midpoint level, a larger exponent pulls the attack knot toward the floor, a smaller one
// toward the ceiling. This is the visible half of the one-model rule.
static void testKnotHeightTracksTheExponent() {
const Rect a = wideArea();
StageEnvelope e = ahdsr(0.4, 0.0, 0.0, 1.0, 0.0);
EnvVertex neutral, steep, shallow;
static void testDegenerateFlatBaseline() {
AmpEnvelope env; // any params
const Rect zeroW = Rect::ltrb(0, 0, 0, 100);
const std::vector<EnvVertex> p1 = buildEnvelopePolyline(env, overlayOf(zeroW), 2.0);
CHECK(p1.size() == 2); // always a drawable line
CHECK(p1.front().level == 0.0 && p1.back().level == 0.0);
e.attackCurve = 1.0;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, neutral));
CHECK(std::fabs(neutral.level - 0.5) < 1e-12); // linear: half way up at half way across
CHECK(neutral.y == levelToY(a, 0.5));
const Rect ok = wideArea();
const std::vector<EnvVertex> p2 = buildEnvelopePolyline(env, overlayOf(ok), 0.0); // no duration
CHECK(p2.size() == 2);
CHECK(p2.front().level == 0.0 && p2.back().level == 0.0);
CHECK(p2.front().x == ok.x && p2.back().x == ok.right() - 1); // spans the area, in-bounds
e.attackCurve = 4.0;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, steep));
CHECK(steep.level < neutral.level);
CHECK(steep.y > neutral.y); // lower on screen
e.attackCurve = 0.25;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, shallow));
CHECK(shallow.level > neutral.level);
CHECK(shallow.y < neutral.y);
// The knot sits between its segment's endpoints in x, and inside the canvas in y.
CHECK(steep.x > a.x && steep.x < a.right() - 1);
CHECK(steep.y >= a.y && steep.y <= a.bottom() - 1);
}
// --- degenerate ---------------------------------------------------------------
static void testDegenerateSurfaceYieldsFlatBaseline() {
const std::vector<EnvVertex> zeroArea =
buildEnvelopePolyline(ahdsr(0.1, 0.1, 0.1, 0.5, 0.1), overlayOf(Rect{}), 4.0);
CHECK(zeroArea.size() == 2);
CHECK(zeroArea[0].level == 0.0 && zeroArea[1].level == 0.0);
const std::vector<EnvVertex> zeroDur =
buildEnvelopePolyline(ahd(0.1, 0.1, 0.5, 0.0, 1.0), overlayOf(wideArea()), 0.0);
CHECK(zeroDur.size() == 2);
}
int main() {
testTimeToXEndpoints();
testTimeToXNegativePinsLeft();
testTimeToXPastEndClamps();
testTimeToXDegenerate();
testGateTimedWidth();
testGatePxPerSecond();
testLevelToYEndpoints();
testLevelToYClamps();
testTimeToXClampsBothEnds();
testLevelToY();
testDegenerateAreaAndDuration();
testGateNodeOrderAndLevels();
testGateSchematicPlacement();
testGateLayoutIndependentOfSampleDuration();
testGateMinSeparationAtDefaults();
testGateSustainPlateauFixedWidth();
testGateReleaseVisibleInBounds();
testGateOverrunCompressesFromRight();
testGateAllVerticesInBounds();
testAhdsrNodeOrderAndLevels();
testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
testReleaseGrowsLeftwardFromTheAnchor();
testTierZeroDefaultsKeepEveryNodeDistinct();
testMaxedStagesCompressWithoutOverrunning();
testTriggerShape();
testTriggerFadeOverlapClamp();
testTriggerFullLengthZeroFadeOutInBounds();
testAhdSplitNeverExceedsTheSpan();
testHoldFractionEndpoints();
testAhdIsOneToOneWithTheTimeAxis();
testDegenerateFlatBaseline();
testKnotsRideOnlySlopedNonZeroSegments();
testKnotHeightTracksTheExponent();
testDegenerateSurfaceYieldsFlatBaseline();
if (g_fail == 0) std::printf("envelope_overlay: all tests passed\n");
else std::printf("envelope_overlay: %d FAILED\n", g_fail);
+55 -10
View File
@@ -26,27 +26,27 @@ static int g_fail = 0;
// toggle + row toggle), MASTER (1 cell, no toggle).
static std::vector<DeckGroupDesc> shellLikeDeck() {
std::vector<DeckGroupDesc> g;
g.push_back({0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}});
g.push_back({1, 38, {101, 48}, {6}, {}});
g.push_back({2, 58, {102, 32}, {7, 8, 9}, {}});
g.push_back({3, 38, {103, 40}, {10}, {104, 44}});
g.push_back({4, 46, {}, {11}, {}});
g.push_back({0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}});
g.push_back({1, 38, {}, {101, 48}, {6}, {}});
g.push_back({2, 58, {}, {102, 32}, {7, 8, 9}, {}});
g.push_back({3, 38, {}, {103, 40}, {10}, {104, 44}});
g.push_back({4, 46, {}, {}, {11}, {}});
return g;
}
static void testGroupWidth() {
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
DeckGroupDesc amp{0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}};
DeckGroupDesc amp{0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}};
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
DeckGroupDesc pitch{1, 38, {101, 48}, {6}, {}};
DeckGroupDesc pitch{1, 38, {}, {101, 48}, {6}, {}};
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
// Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
DeckGroupDesc voice{3, 38, {103, 40}, {10}, {104, 44}};
DeckGroupDesc voice{3, 38, {}, {103, 40}, {10}, {104, 44}};
CHECK(deckGroupWidth(voice) ==
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
// No toggles: max(caption, cells) + padding.
DeckGroupDesc master{4, 46, {}, {11}, {}};
DeckGroupDesc master{4, 46, {}, {}, {11}, {}};
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
}
@@ -148,7 +148,7 @@ static void testHitTest() {
// A blank cell (id -1) misses even though its rect exists.
std::vector<DeckGroupDesc> trig;
trig.push_back({0, 78, {100, 44}, {20, 21, 22, -1, -1}, {}});
trig.push_back({0, 78, {}, {100, 44}, {20, 21, 22, -1, -1}, {}});
const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
const DeckCellLayout& blank = tl.groups[0].cells[4];
CHECK(blank.id == -1);
@@ -162,6 +162,49 @@ static void testHitTest() {
CHECK(h.kind == DeckHitKind::None);
}
// The corner radio widens the caption row, takes the far corner, and pushes the caption
// toggle left of itself — the three properties the overlay-select switch relies on.
static void testCaptionRadioGeometryAndHit() {
const DeckGroupDesc bare{7, 78, {}, {200, 44}, {1, 2}, {}};
const DeckGroupDesc withRadio{7, 78, {201}, {200, 44}, {1, 2}, {}};
// Caption row grows by exactly gap + radio; the knob row is unchanged, so a group whose
// caption row already dominated grows by that much.
CHECK(deckGroupWidth(withRadio) - deckGroupWidth(bare) ==
kDeckToggleGap + kDeckRadioSize);
std::vector<DeckGroupDesc> g{withRadio};
const DeckLayout dl = layoutDeck(g, 0, 0, 800);
const DeckGroupLayout& lay = dl.groups[0];
CHECK(lay.captionRadio.id == 201);
CHECK(lay.captionRadio.box.width == kDeckRadioSize);
// Far corner: flush with the group's inner right edge.
CHECK(lay.captionRadio.box.right() == lay.box.right() - kDeckGroupPadX);
// The toggle sits entirely left of the radio, and the caption text left of the toggle.
CHECK(lay.captionToggle.seg1.right() <= lay.captionRadio.box.x);
CHECK(lay.caption.right() <= lay.captionToggle.seg0.x);
const DeckHit h = hitTestDeck(dl, lay.captionRadio.box.x + 2, lay.captionRadio.box.y + 2);
CHECK(h.kind == DeckHitKind::CaptionRadio && h.id == 201);
}
// The inner dial is a concentric sub-region of the knob: a grab there still names the cell,
// with `inner` set, so a cell with no inner value simply ignores the flag.
static void testInnerDialHit() {
const std::vector<DeckGroupDesc> g = shellLikeDeck();
const DeckLayout dl = layoutDeck(g, 0, 0, 900);
const DeckCellLayout& c = dl.groups[0].cells[0];
CHECK(c.inner.width == kDeckInnerDialSize && c.inner.height == kDeckInnerDialSize);
// Concentric with the knob square.
CHECK(c.inner.x + c.inner.width / 2 == c.knob.x + c.knob.width / 2);
CHECK(c.inner.y + c.inner.height / 2 == c.knob.y + c.knob.height / 2);
DeckHit h = hitTestDeck(dl, c.inner.x + c.inner.width / 2, c.inner.y + c.inner.height / 2);
CHECK(h.kind == DeckHitKind::Knob && h.id == c.id && h.inner);
// A grab on the outer ring is the same cell WITHOUT the inner flag.
h = hitTestDeck(dl, c.knob.x + 1, c.knob.y + 1);
CHECK(h.kind == DeckHitKind::Knob && h.id == c.id && !h.inner);
}
static void testEmptyDeck() {
const std::vector<DeckGroupDesc> none;
CHECK(deckRowCount(none, 800) == 0);
@@ -176,6 +219,8 @@ int main() {
testFirstGroupAlwaysPlaces();
testGroupInnerGeometry();
testHitTest();
testCaptionRadioGeometryAndHit();
testInnerDialHit();
testEmptyDeck();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
+68 -24
View File
@@ -191,36 +191,76 @@ static void testSustainLevelChangeGlides() {
}
static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
// No hold stage, so the shape is the attack-decay one the pre-AHD envelope had.
PitchEnvParams p;
p.enabled = true;
p.attackFrames = 0;
p.decayFrames = 1000;
p.peakSemitones = 12.0;
p.shape.attackFrames = 0;
p.shape.decayFrames = 1000;
p.shape.holdFraction = 0.0;
PitchEnvelope a, b;
a.configure(p);
b.configure(p);
a.configure(100000, p);
b.configure(100000, p);
a.noteOn();
b.noteOn();
for (int i = 0; i < 400; ++i) { a.tick(); b.tick(); }
b.applyLive(0, 2000, 12.0); // decay doubled mid-decay
PitchEnvParams longer = p;
longer.shape.decayFrames = 2000;
b.applyLive(longer); // decay doubled mid-decay
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
// A depth move is a level step, so it glides rather than jumping: the first frame after
// the edit is exactly what the unedited peer emits.
PitchEnvelope c, d;
c.configure(p);
d.configure(p);
c.configure(100000, p);
d.configure(100000, p);
c.noteOn();
d.noteOn();
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
c.applyLive(0, 1000, 0.0); // depth to zero mid-decay
PitchEnvParams noDepth = p;
noDepth.peakSemitones = 0.0;
c.applyLive(noDepth); // depth to zero mid-decay
CHECK(c.tick() == d.tick());
// ...and it does eventually reach the new depth rather than staying put.
for (int i = 0; i < 400; ++i) c.tick();
CHECK(c.tick() == 0.0);
}
// The pitch envelope's new middle stage, on the same phi rule: a hold dialled mid-hold keeps
// the level (flat by definition) and moves the boundary, and the fraction is taken against
// what attack and decay left rather than against the whole span.
static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() {
PitchEnvParams p;
p.enabled = true;
p.peakSemitones = 12.0;
p.shape.attackFrames = 100;
p.shape.decayFrames = 100;
p.shape.holdFraction = 0.5; // half of (1000 - 200) = 400 frames of hold
PitchEnvelope e;
e.configure(1000, p);
e.noteOn();
for (int i = 0; i < 100; ++i) e.tick(); // through the attack
CHECK(e.tick() == 12.0); // frame 100: at the peak, holding
for (int i = 0; i < 398; ++i) e.tick(); // to the last frame of the hold
CHECK(e.tick() == 12.0); // frame 499: still holding
CHECK(e.tick() == 12.0); // frame 500: decay's own first frame
CHECK(std::fabs(e.tick() - 12.0 * (1.0 - 1.0 / 100.0)) < 1e-12); // frame 501: descending
// A live hold change mid-hold is continuous (the stage is flat) and the envelope still
// finishes inside the span.
PitchEnvelope f;
f.configure(1000, p);
f.noteOn();
for (int i = 0; i < 300; ++i) f.tick();
PitchEnvParams wider = p;
wider.shape.holdFraction = 1.0;
f.applyLive(wider);
CHECK(f.tick() == 12.0);
for (int i = 0; i < 1200; ++i) f.tick();
CHECK(f.tick() == 0.0);
}
// --- The fresh-note path: snap, never the phi rule ---------------------------------------
static void testAFreshEnvelopeTakesANewlyDialledStageTimeOutright() {
@@ -258,9 +298,12 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
PitchEnvParams stale; // enabled, but every leg zero
stale.enabled = true;
PitchEnvelope env;
env.configure(stale);
env.configure(100000, stale);
env.noteOn();
env.snapLive(0, 1000, 12.0);
PitchEnvParams dialled = stale;
dialled.peakSemitones = 12.0;
dialled.shape.decayFrames = 1000;
env.snapLive(dialled);
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
for (int i = 0; i < 499; ++i) env.tick();
CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
@@ -412,25 +455,25 @@ static void testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote() {
{"pitch env attack",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.attackFrames = 48000;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.shape.attackFrames = 48000;
s.play.pitchEnv.shape.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvAttackFrames = 4000; }, -1},
[](LiveValues& v) { v.pitchEnv.shape.attackFrames = 4000; }, -1},
{"pitch env decay",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 48000;
s.play.pitchEnv.shape.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvDecayFrames = 8000; }, -1},
[](LiveValues& v) { v.pitchEnv.shape.decayFrames = 8000; }, -1},
{"pitch env depth",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.decayFrames = 480000;
s.play.pitchEnv.shape.decayFrames = 480000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnvPeakSemitones = 0.0; }, -1},
[](LiveValues& v) { v.pitchEnv.peakSemitones = 0.0; }, -1},
};
for (const Case& c : cases) {
assertLiveFieldMovesTheSoundingNote(c.name, c.rig, c.mutate, c.noteOffBlock);
@@ -646,9 +689,9 @@ static void testPitchRatioAndVelocityGainStayLatched() {
hostile.filterKeyTrack = 2.0;
hostile.filterSettings.cutoffNorm = 0.0f;
hostile.filterModAmount = 1.0;
hostile.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 24.0;
hostile.pitchEnv.shape.attackFrames = 4800;
hostile.pitchEnv.shape.decayFrames = 4800;
hostile.pitchEnv.peakSemitones = 24.0;
hostile.adsr.attackFrames = 96000; // a timed stage the voice is already past
for (int blk = 0; blk < 8; ++blk) {
if (blk == 2) block.publish(hostile);
@@ -696,7 +739,7 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
filterSweep(rig);
rig.play.filter.velAmount = 0.0;
rig.play.pitchEnv.enabled = true;
rig.play.pitchEnv.decayFrames = 24000;
rig.play.pitchEnv.shape.decayFrames = 24000;
rig.play.pitchEnv.peakSemitones = 3.0;
LiveValues hostile = foldLive(rig.play);
@@ -705,9 +748,9 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
hostile.filterModAmount = -1.0;
hostile.filterEnv.decayFrames = 4800;
hostile.filterEnv.sustainLevel = 0.0;
hostile.pitchEnvAttackFrames = 4800;
hostile.pitchEnvDecayFrames = 4800;
hostile.pitchEnvPeakSemitones = 24.0;
hostile.pitchEnv.shape.attackFrames = 4800;
hostile.pitchEnv.shape.decayFrames = 4800;
hostile.pitchEnv.peakSemitones = 24.0;
hostile.adsr.sustainLevel = 0.4;
SampleData quiet = rig, loud = rig, untouched = rig;
@@ -743,6 +786,7 @@ int main() {
testShortenedStageStillLandsContinuously();
testSustainLevelChangeGlides();
testPitchEnvelopeHoldsPhaseAndGlidesDepth();
testPitchEnvelopeHoldStagePlaysAndHoldsPhase();
testAFreshEnvelopeTakesANewlyDialledStageTimeOutright();
testAFreshPitchEnvelopeTakesTheNewTimesOutright();
testCutoffMoveAcrossPrepareDoesNotStep();
+21 -6
View File
@@ -27,7 +27,9 @@ static_assert(std::is_trivially_copyable_v<LiveValues>, "the live block must sta
static void testFoldCarriesEveryContinuousControl() {
PlayParams p;
p.adsr = AdsrParams{11, 22, 33, 0.44, 55};
p.adsr = AdsrParams{11, 22, 33, 0.44, 55, 2.0, 0.5, 3.0};
p.trigAhd = AhdParams{61, 62, 0.63, 4.0, 0.25};
p.filter.trigEnv = AhdParams{71, 72, 0.73, 5.0, 0.2};
p.filter.enabled = true;
p.filter.settings.cutoffNorm = 0.25f;
p.filter.settings.resonanceNorm = 0.5f;
@@ -36,8 +38,7 @@ static void testFoldCarriesEveryContinuousControl() {
p.filter.modAmount = -0.6;
p.filter.keyTrack = 1.5;
p.filter.env = AdsrParams{1, 2, 3, 0.4, 5};
p.pitchEnv.attackFrames = 7;
p.pitchEnv.decayFrames = 9;
p.pitchEnv.shape = AhdParams{7, 9, 0.4, 1.5, 0.75};
p.pitchEnv.peakSemitones = -3.5;
const LiveValues v = foldLive(p);
@@ -46,6 +47,17 @@ static void testFoldCarriesEveryContinuousControl() {
CHECK(v.adsr.decayFrames == 33);
CHECK(v.adsr.sustainLevel == 0.44);
CHECK(v.adsr.releaseFrames == 55);
CHECK(v.adsr.attackCurve == 2.0);
CHECK(v.adsr.decayCurve == 0.5);
CHECK(v.adsr.releaseCurve == 3.0);
CHECK(v.ampAhd.attackFrames == 61);
CHECK(v.ampAhd.decayFrames == 62);
CHECK(v.ampAhd.holdFraction == 0.63);
CHECK(v.ampAhd.attackCurve == 4.0);
CHECK(v.ampAhd.decayCurve == 0.25);
CHECK(v.filterAhd.attackFrames == 71);
CHECK(v.filterAhd.holdFraction == 0.73);
CHECK(v.filterAhd.decayCurve == 0.2);
CHECK(v.filterSettings.cutoffNorm == 0.25f);
CHECK(v.filterSettings.resonanceNorm == 0.5f);
CHECK(v.filterSettings.morphNorm == 0.75f);
@@ -54,9 +66,12 @@ static void testFoldCarriesEveryContinuousControl() {
CHECK(v.filterKeyTrack == 1.5);
CHECK(v.filterEnv.decayFrames == 3);
CHECK(v.filterEnv.sustainLevel == 0.4);
CHECK(v.pitchEnvAttackFrames == 7);
CHECK(v.pitchEnvDecayFrames == 9);
CHECK(v.pitchEnvPeakSemitones == -3.5);
CHECK(v.pitchEnv.shape.attackFrames == 7);
CHECK(v.pitchEnv.shape.decayFrames == 9);
CHECK(v.pitchEnv.shape.holdFraction == 0.4);
CHECK(v.pitchEnv.shape.attackCurve == 1.5);
CHECK(v.pitchEnv.shape.decayCurve == 0.75);
CHECK(v.pitchEnv.peakSemitones == -3.5);
}
static void testUnpublishedBlockReadsAsNothing() {
+28 -12
View File
@@ -566,8 +566,8 @@ static void testLegacyLiftDecision() {
// --- resolvePlay: stored SECONDS -> engine FRAMES at the live rate --------------
static void testResolvePlayConvertsWallClockAtTheRate() {
// Wall-clock times convert at the LIVE rate; source-timeline quantities (the Trigger
// %-length and its fades) carry through untouched, and levels/depths are not times.
// Wall-clock times convert at the LIVE rate; the Trigger %-length, every hold FRACTION and
// every curve exponent are rate-free and carry through untouched, as do levels and depths.
PlaySeconds st;
st.playMode = PlayMode::Trigger;
st.adsr.attackSeconds = 0.01;
@@ -575,13 +575,20 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
st.adsr.decaySeconds = 0.02;
st.adsr.sustainLevel = 0.8;
st.adsr.releaseSeconds = 0.15;
st.adsr.attackCurve = 2.5;
st.adsr.decayCurve = 0.4;
st.adsr.releaseCurve = 3.5;
st.trigger.lengthFraction = 0.75;
st.trigger.fadeInFrames = 441;
st.trigger.fadeOutFrames = 882;
st.trigAhd.attackSeconds = 0.01;
st.trigAhd.decaySeconds = 0.02;
st.trigAhd.holdFraction = 0.6;
st.trigAhd.attackCurve = 1.75;
st.trigAhd.decayCurve = 0.8;
st.pitchEngine = PitchEngine::Preserve;
st.pitchEnv.enabled = true;
st.pitchEnv.attackSeconds = 0.02;
st.pitchEnv.decaySeconds = 0.03;
st.pitchEnv.shape.attackSeconds = 0.02;
st.pitchEnv.shape.decaySeconds = 0.03;
st.pitchEnv.shape.holdFraction = 0.25;
st.pitchEnv.peakSemitones = 5.0;
const PlayParams at48 = resolvePlay(st, 48000);
@@ -591,13 +598,20 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
CHECK(at48.adsr.decayFrames == 960);
CHECK(at48.adsr.sustainLevel == 0.8); // a level, not a time
CHECK(at48.adsr.releaseFrames == 7200);
CHECK(at48.adsr.attackCurve == 2.5); // dimensionless
CHECK(at48.adsr.decayCurve == 0.4);
CHECK(at48.adsr.releaseCurve == 3.5);
CHECK(at48.trigger.lengthFraction == 0.75); // source-timeline, unconverted
CHECK(at48.trigger.fadeInFrames == 441);
CHECK(at48.trigger.fadeOutFrames == 882);
CHECK(at48.trigAhd.attackFrames == 480);
CHECK(at48.trigAhd.decayFrames == 960);
CHECK(at48.trigAhd.holdFraction == 0.6); // a fraction, not a time
CHECK(at48.trigAhd.attackCurve == 1.75);
CHECK(at48.trigAhd.decayCurve == 0.8);
CHECK(at48.pitchEngine == PitchEngine::Preserve);
CHECK(at48.pitchEnv.enabled);
CHECK(at48.pitchEnv.attackFrames == 960);
CHECK(at48.pitchEnv.decayFrames == 1440);
CHECK(at48.pitchEnv.shape.attackFrames == 960);
CHECK(at48.pitchEnv.shape.decayFrames == 1440);
CHECK(at48.pitchEnv.shape.holdFraction == 0.25);
CHECK(at48.pitchEnv.peakSemitones == 5.0); // a depth, not a time
// THE no-hardcoded-rate contract: the SAME stored seconds yield different frame counts
@@ -606,8 +620,10 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
CHECK(at96.adsr.attackFrames == 960);
CHECK(at96.adsr.holdFrames == 4800);
CHECK(at96.adsr.releaseFrames == 14400);
CHECK(at96.pitchEnv.attackFrames == 1920);
CHECK(at96.trigger.fadeInFrames == 441); // still unconverted
CHECK(at96.pitchEnv.shape.attackFrames == 1920);
CHECK(at96.trigAhd.attackFrames == 960);
CHECK(at96.trigAhd.holdFraction == 0.6); // still unconverted
CHECK(at96.adsr.attackCurve == 2.5);
}
static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
+18 -12
View File
@@ -1023,17 +1023,20 @@ static void testAhdsrHoldZeroEqualsAdsr() {
}
// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope
// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp.
// * velocity directly. `attack`/`decay` are the AHD's ramp lengths in frames, with Hold taking
// the whole remainder — the shape that replaced the retired fade pair. Varispeed so no shift
// colours the amp.
static SampleData triggerSample(std::size_t frames, double lengthFraction,
std::int64_t fadeIn, std::int64_t fadeOut,
std::int64_t attack, std::int64_t decay,
std::int64_t startFrame = 0) {
SampleData s = dcSample(frames, 60);
s.startFrame = startFrame;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate amp shape from pitch
s.play.trigger.lengthFraction = lengthFraction;
s.play.trigger.fadeInFrames = fadeIn;
s.play.trigger.fadeOutFrames = fadeOut;
s.play.trigAhd.attackFrames = attack;
s.play.trigAhd.decayFrames = decay;
s.play.trigAhd.holdFraction = 1.0;
return s;
}
@@ -1181,10 +1184,13 @@ static void testPreserveDurationInvariance() {
const std::size_t atUp = lengthAt(72); // +12
const std::size_t atDown = lengthAt(48); // -12
// All three within a small tolerance of the source length (Preserve holds duration). The
// tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x).
CHECK(atRoot >= frames - 20 && atRoot <= frames + 20);
CHECK(atUp >= frames - 20 && atUp <= frames + 20);
CHECK(atDown >= frames - 20 && atDown <= frames + 20);
// tolerance covers the shifter's fill/latency edge and the terminal ring-out Preserve ends
// on (voice.h's seedTerminalDeclick — bounded by the declick floor at ~185 frames), not a
// duration scaling, which would be 2x.
const std::size_t kTail = 200;
CHECK(atRoot >= frames - 20 && atRoot <= frames + kTail);
CHECK(atUp >= frames - 20 && atUp <= frames + kTail);
CHECK(atDown >= frames - 20 && atDown <= frames + kTail);
// The decisive assertion: the up/down lengths track the root length (NOT halved/doubled).
CHECK(atUp > frames / 2 + 200); // an octave up did NOT halve the duration (Varispeed would)
CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it
@@ -1220,8 +1226,8 @@ static void testPitchEnvOffBitIdentical() {
if (withDisabledEnv) {
s.play.pitchEnv.enabled = false; // explicitly disabled (offset always 0)
s.play.pitchEnv.peakSemitones = 12.0; // a depth that WOULD matter if enabled
s.play.pitchEnv.attackFrames = 0;
s.play.pitchEnv.decayFrames = 500;
s.play.pitchEnv.shape.attackFrames = 0;
s.play.pitchEnv.shape.decayFrames = 500;
}
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
@@ -1248,8 +1254,8 @@ static void testPitchEnvOnBendsVarispeed() {
SampleData s = sineSample(n, 40.0, 60);
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.attackFrames = 0; // start at the peak
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.shape.attackFrames = 0; // start at the peak
s.play.pitchEnv.shape.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
+529
View File
@@ -0,0 +1,529 @@
// Standalone tests for the STAGED ENVELOPE system in the pure engine — no VST3, no REAPER, no
// framework. The engine's other seams are covered by sampler_core_tests (allocation, repitch,
// loops), sampler_filter_tests (the filter in the voice path) and live_delivery_tests (what a
// published block does to a sounding voice); this file covers what shape the envelopes have.
//
// Covers: the LINEAR NEUTRAL (exponent 1.0 reproduces the pre-curve evaluation bit for bit on
// every sloped stage of all three envelopes); the exponent sweep across the full domain
// (finite, monotone within a stage, never past the stage's endpoint levels); the AHD span split
// (A+H+D can never exceed the span, for any triple, with no clamp on the sum; hold at 0% and
// 100%); the Gate/Trigger shape switch on both the amp and the filter envelope, with each
// mode's stage values surviving the other; and the Trigger tail's terminal behaviour under
// Preserve in both voice modes, against a Varispeed render that must not change.
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
using namespace reasampler::instrument::engine;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
constexpr double kPi = 3.14159265358979323846;
static SampleData dcSample(std::size_t frames, int rootNote = 60) {
SampleData s;
s.frames.assign(frames, 1.0f);
s.rootNote = rootNote;
s.sampleRate = 48000;
return s;
}
// --- The linear neutral -------------------------------------------------------
// The migration bar, at the evaluator: with every exponent at 1.0 each sloped stage emits
// EXACTLY the closed-form linear value the pre-curve engine emitted. Bit-identical, not close:
// a hair of drift here is a project reopening with a different sound.
static void testNeutralExponentReproducesTheLinearEvaluationExactly() {
// AHDSR attack, decay and release, each measured over its whole span.
AdsrParams p;
p.attackFrames = 400;
p.holdFrames = 0;
p.decayFrames = 500;
p.sustainLevel = 0.25;
p.releaseFrames = 300;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
for (int i = 0; i < 400; ++i) {
CHECK(env.tick() == static_cast<double>(i) / 400.0);
}
for (int i = 0; i < 500; ++i) {
CHECK(env.tick() == 1.0 + (0.25 - 1.0) * (static_cast<double>(i) / 500.0));
}
CHECK(env.tick() == 0.25); // sustain
env.noteOff();
for (int i = 0; i < 300; ++i) {
CHECK(env.tick() == 0.25 * (1.0 - static_cast<double>(i) / 300.0));
}
// The AHD's two sloped stages, against the same closed forms.
AhdParams a;
a.attackFrames = 200;
a.decayFrames = 300;
a.holdFraction = 0.0;
AhdEnvelope ahd;
ahd.configure(1000, a);
for (int i = 0; i < 200; ++i) {
CHECK(ahd.amplitudeAt(i) == static_cast<double>(i) / 200.0);
}
for (int i = 0; i < 300; ++i) {
CHECK(ahd.amplitudeAt(200 + i) == 1.0 - static_cast<double>(i) / 300.0);
}
// The pitch envelope's two, scaled by the depth.
PitchEnvParams pe;
pe.enabled = true;
pe.peakSemitones = 12.0;
pe.shape.attackFrames = 200;
pe.shape.decayFrames = 300;
pe.shape.holdFraction = 0.0;
PitchEnvelope pitch;
pitch.configure(1000, pe);
pitch.noteOn();
for (int i = 0; i < 200; ++i) {
CHECK(pitch.tick() == 12.0 * (static_cast<double>(i) / 200.0));
}
for (int i = 0; i < 300; ++i) {
CHECK(pitch.tick() == 12.0 * (1.0 - static_cast<double>(i) / 300.0));
}
}
// --- The exponent sweep -------------------------------------------------------
// The whole domain including both endpoints: every emitted value finite, inside the stage's
// own endpoint levels, and monotone in the stage's direction. An exponent can reshape a stage
// but never make it overshoot or wander.
static void testExponentSweepStaysFiniteMonotoneAndInRange() {
const double exps[] = {util::kCurveMin, 0.3, 0.7, 1.0, 2.0, 5.0, util::kCurveMax};
for (double e : exps) {
AdsrParams p;
p.attackFrames = 256;
p.decayFrames = 256;
p.sustainLevel = 0.3;
p.releaseFrames = 256;
p.attackCurve = e;
p.decayCurve = e;
p.releaseCurve = e;
AdsrEnvelope env;
env.configure(p);
env.noteOn();
double prev = -1.0;
for (int i = 0; i < 256; ++i) { // attack: rises 0 -> 1
const double v = env.tick();
CHECK(std::isfinite(v));
CHECK(v >= 0.0 && v <= 1.0);
CHECK(v >= prev);
prev = v;
}
prev = 2.0;
for (int i = 0; i < 256; ++i) { // decay: falls 1 -> sustain, never below it
const double v = env.tick();
CHECK(std::isfinite(v));
CHECK(v >= 0.3 - 1e-12 && v <= 1.0 + 1e-12);
CHECK(v <= prev);
prev = v;
}
env.tick(); // sustain
env.noteOff();
prev = 2.0;
for (int i = 0; i < 256; ++i) { // release: falls to 0, never below
const double v = env.tick();
CHECK(std::isfinite(v));
CHECK(v >= -1e-12 && v <= 0.3 + 1e-12);
CHECK(v <= prev);
prev = v;
}
// The AHD's own two stages under the same exponent.
AhdParams a;
a.attackFrames = 256;
a.decayFrames = 256;
a.holdFraction = 0.0;
a.attackCurve = e;
a.decayCurve = e;
AhdEnvelope ahd;
ahd.configure(1000, a);
prev = -1.0;
for (int i = 0; i < 256; ++i) {
const double v = ahd.amplitudeAt(i);
CHECK(std::isfinite(v) && v >= 0.0 && v <= 1.0 && v >= prev);
prev = v;
}
prev = 2.0;
for (int i = 0; i < 256; ++i) {
const double v = ahd.amplitudeAt(256 + i);
CHECK(std::isfinite(v) && v >= 0.0 && v <= 1.0 && v <= prev);
prev = v;
}
}
}
// The exponent has to be AUDIBLE, not merely stored: the same stage at two exponents renders
// measurably different levels at the same position.
static void testExponentActuallyReshapesTheStage() {
AhdParams a;
a.attackFrames = 1000;
a.holdFraction = 0.0;
AhdEnvelope steep, shallow;
a.attackCurve = 8.0;
steep.configure(2000, a);
a.attackCurve = 0.15;
shallow.configure(2000, a);
CHECK(steep.amplitudeAt(500) < 0.05);
CHECK(shallow.amplitudeAt(500) > 0.85);
CHECK(shallow.amplitudeAt(500) - steep.amplitudeAt(500) > 0.5);
}
// --- The AHD span split -------------------------------------------------------
// The combined-time bound, swept across the full domains: no (attack, decay, hold-fraction)
// triple can push the sum past the span. The property is structural — Hold is a fraction of
// what is LEFT — so there is no clamp on the sum for a case to slip past.
static void testAhdSumNeverExceedsTheSpanForAnyTriple() {
const std::int64_t span = 1000;
for (std::int64_t a = 0; a <= 2000; a += 125) {
for (std::int64_t d = 0; d <= 2000; d += 125) {
for (int f = 0; f <= 10; ++f) {
AhdParams p;
p.attackFrames = a;
p.decayFrames = d;
p.holdFraction = f * 0.1;
const AhdSpan s = fitAhd(span, p);
CHECK(s.attack >= 0 && s.hold >= 0 && s.decay >= 0);
CHECK(s.total == s.attack + s.hold + s.decay);
CHECK(s.total <= span);
// And the envelope itself is silent at and past the fitted total.
AhdEnvelope e;
e.configure(span, p);
CHECK(e.amplitudeAt(static_cast<double>(s.total)) == 0.0);
}
}
}
}
static void testHoldFractionEndpoints() {
AhdParams p;
p.attackFrames = 100;
p.decayFrames = 200;
p.holdFraction = 0.0;
const AhdSpan none = fitAhd(1000, p);
CHECK(none.hold == 0); // 0% takes no time at all
CHECK(none.total == 300);
p.holdFraction = 1.0;
const AhdSpan full = fitAhd(1000, p);
CHECK(full.hold == 700); // exactly the remainder after attack and decay
CHECK(full.total == 1000);
// A negative/NaN fraction degrades to none rather than to a negative stage.
p.holdFraction = -1.0;
CHECK(fitAhd(1000, p).hold == 0);
p.holdFraction = std::nan("");
CHECK(fitAhd(1000, p).hold == 0);
}
// --- The Gate/Trigger shape switch --------------------------------------------
// Each mode plays its OWN stage values: the parameter set carries both, so flipping to Trigger
// and back cannot lose either mode's dialled envelope. Asserted on rendered output, not on the
// struct — a voice reading the wrong field would still store the right one.
static void testEachModePlaysItsOwnStageValuesAndTheOtherSurvives() {
SampleData s = dcSample(4000);
// Gate: a slow attack. Trigger: an instant onset and a long decay. Deliberately opposite,
// so a voice reading the wrong shape is unmistakable.
s.play.adsr.attackFrames = 2000;
s.play.adsr.sustainLevel = 1.0;
s.play.trigAhd.attackFrames = 0;
s.play.trigAhd.decayFrames = 2000;
s.play.trigAhd.holdFraction = 0.0;
const auto renderFirst = [&](PlayMode mode) {
SampleData copy = s;
copy.play.playMode = mode;
VoiceEngine eng(1, copy);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1000);
return out;
};
const std::vector<AudioSample> gate = renderFirst(PlayMode::Gate);
CHECK(gate[0] < 0.01f); // halfway up a 2000-frame attack
CHECK(std::fabs(gate[999] - 999.0f / 2000.0f) < 1e-3f);
const std::vector<AudioSample> trig = renderFirst(PlayMode::Trigger);
CHECK(trig[0] > 0.99f); // instant onset
CHECK(std::fabs(trig[999] - (1.0f - 999.0f / 2000.0f)) < 1e-3f);
// Back to Gate: the AHDSR values were never touched by the excursion.
const std::vector<AudioSample> again = renderFirst(PlayMode::Gate);
for (std::size_t i = 0; i < gate.size(); ++i) CHECK(again[i] == gate[i]);
}
// The same switch on the FILTER envelope, which follows the amp's rule rather than its own:
// the two shapes are stored side by side and each mode reads only its own.
static void testFilterEnvelopeFollowsTheModeShape() {
SampleData s = dcSample(4000);
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.1f;
s.play.filter.modAmount = 0.9;
// Gate: the filter envelope opens slowly. Trigger: it opens instantly and closes.
s.play.filter.env.attackFrames = 2000;
s.play.filter.env.sustainLevel = 1.0;
s.play.filter.trigEnv.attackFrames = 0;
s.play.filter.trigEnv.decayFrames = 2000;
s.play.filter.trigEnv.holdFraction = 0.0;
const auto brightnessAt = [&](PlayMode mode, std::size_t frame) {
SampleData copy = s;
copy.play.playMode = mode;
// A DC source through a swept low-pass: the settled level tracks the corner, so the
// rendered value at a frame is a proxy for how far the envelope has opened it.
VoiceEngine eng(1, copy);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, frame + 1);
return static_cast<double>(out[frame]);
};
// Gate opens over time; Trigger starts open and closes. The orderings invert, which cannot
// happen if both modes read one envelope.
CHECK(brightnessAt(PlayMode::Gate, 20) < brightnessAt(PlayMode::Gate, 1500));
CHECK(brightnessAt(PlayMode::Trigger, 20) > brightnessAt(PlayMode::Trigger, 1500));
}
// --- The Trigger tail (item 4) ------------------------------------------------
// The largest sample-to-sample step in the last `window` frames a voice actually produced,
// plus where the voice stopped. A hard cut at a non-zero level shows up here as a step the
// size of that level.
struct TailMeasure {
double worstStep = 0.0;
double lastLevel = 0.0;
std::size_t soundingFrames = 0;
};
static TailMeasure renderTail(VoiceMode voiceMode, PitchEngine engine, std::size_t maxFrames) {
// A sine, not DC: the shifter's splice machinery needs real waveform to recycle, and a DC
// source would hide exactly the discontinuity under test.
SampleData s;
s.frames.resize(4000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
}
s.sampleRate = 48000;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = engine;
s.play.trigger.lengthFraction = 1.0;
// The abrupt-end case the spec keeps representable: zero decay, so nothing in the ENVELOPE
// hides a discontinuity at the sample end.
s.play.trigAhd.attackFrames = 0;
s.play.trigAhd.decayFrames = 0;
s.play.trigAhd.holdFraction = 1.0;
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512, voiceMode);
eng.noteOn(67, 127); // transposed, so Preserve genuinely runs its shifter
TailMeasure m;
std::vector<AudioSample> out;
for (std::size_t f = 0; f < maxFrames; ++f) {
eng.render(out, 1);
if (eng.activeVoiceCount() == 0) break;
m.soundingFrames = f + 1;
}
// Include the frame after the voice freed: the cut itself is the step from the last
// sounding sample to the silence that follows it.
const std::size_t end = std::min(m.soundingFrames + 1, out.size());
for (std::size_t i = 1; i < end; ++i) {
m.worstStep = std::max(m.worstStep,
std::fabs(static_cast<double>(out[i]) -
static_cast<double>(out[i - 1])));
}
if (m.soundingFrames > 0) m.lastLevel = std::fabs(static_cast<double>(out[end - 1]));
return m;
}
// A Trigger one-shot in Preserve must end without a terminal discontinuity, in both voice
// modes. The threshold is stated rather than eyeballed: the source's own steepest
// sample-to-sample slope is 0.8*2*pi/40 ~= 0.126, so the cut must not exceed what the waveform
// itself already does. (Neither of us can judge this by ear — this is the measurable proxy;
// the audible check is Daniel's.)
static void testTriggerPreserveEndsWithoutATerminalDiscontinuity() {
const double kSourceSlope = 0.8 * 2.0 * kPi / 40.0;
for (VoiceMode vm : {VoiceMode::Poly, VoiceMode::Mono}) {
const TailMeasure m = renderTail(vm, PitchEngine::Preserve, 8000);
CHECK(m.soundingFrames > 0);
CHECK(m.worstStep <= kSourceSlope * 1.5);
// And the voice genuinely reaches silence rather than being left ringing.
CHECK(m.lastLevel < 1e-3);
}
}
// The same cut on the peer path: an AHD whose stages end BEFORE the play span (hold under
// 100% with a zero decay) stops the voice mid-tail, and under Preserve that tail is just as
// synthetic as the one at the sample end.
static void testTriggerPreserveAhdEndingEarlyAlsoRingsOut() {
SampleData s;
s.frames.resize(4000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
}
s.sampleRate = 48000;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.attackFrames = 0;
s.play.trigAhd.decayFrames = 0;
s.play.trigAhd.holdFraction = 0.25; // ends at ~1000 frames, far short of the 4000-frame span
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(67, 127);
std::vector<AudioSample> out;
std::size_t sounding = 0;
for (std::size_t f = 0; f < 4000; ++f) {
eng.render(out, 1);
if (eng.activeVoiceCount() == 0) break;
sounding = f + 1;
}
CHECK(sounding > 900 && sounding < 1400); // the AHD ended, not the span
double worst = 0.0;
const std::size_t end = std::min(sounding + 1, out.size());
for (std::size_t i = 1; i < end; ++i) {
worst = std::max(worst, std::fabs(static_cast<double>(out[i]) -
static_cast<double>(out[i - 1])));
}
CHECK(worst <= (0.8 * 2.0 * kPi / 40.0) * 1.5);
}
// Varispeed is not implicated and must be left exactly as it was: its terminal sample is real
// source content at its natural end, so no ring-out is armed there. Asserted as byte-identity
// between two renders of the same rig, one of which would differ if the Preserve-only guard
// were ever widened.
static void testVarispeedTailIsUntouched() {
const auto render = [](std::size_t frames) {
SampleData s;
s.frames.resize(2000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
}
s.sampleRate = 48000;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.holdFraction = 1.0;
VoiceEngine eng(1, s);
eng.noteOn(60, 127); // unity ratio: the read head walks the source frame for frame
std::vector<AudioSample> out;
eng.render(out, frames);
return out;
};
const std::vector<AudioSample> out = render(2400);
// Unity Varispeed reproduces the source exactly through its span, then stops dead — the
// pre-change behaviour, with no ring-out appended.
for (std::size_t i = 0; i < 2000; ++i) {
CHECK(out[i] == static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0)));
}
for (std::size_t i = 2000; i < out.size(); ++i) CHECK(out[i] == 0.0f);
}
// --- Migration contour --------------------------------------------------------
// The retired fade pair was an EQUAL-POWER ramp (sin/cos); the AHD that replaced it is the
// curve law's neutral, which is LINEAR. Migration preserves the stage LENGTHS exactly, so the
// contour tracks the old one to within the fixed sin(x)-vs-x gap — max |sin(t*pi/2) - t| over
// [0,1], which is ~0.2105 at t ~= 0.4. Stated as the measured bound rather than judged: whether
// that difference matters is Daniel's call, not this test's.
static void testMigratedFadeContourMatchesTheRetiredShapeWithinTheStatedBound() {
const std::int64_t span = 1000;
const std::int64_t fadeIn = 200;
const std::int64_t fadeOut = 300;
AhdParams migrated;
migrated.attackFrames = fadeIn; // Attack <- fade-in
migrated.decayFrames = fadeOut; // Decay <- fade-out
migrated.holdFraction = 1.0; // Hold <- the whole remainder
AhdEnvelope ahd;
ahd.configure(span, migrated);
// Stage LENGTHS are exact: the fades land on the same frames they always did.
CHECK(ahd.stages().attack == fadeIn);
CHECK(ahd.stages().decay == fadeOut);
CHECK(ahd.stages().total == span);
// The pre-change evaluator, written out so the comparison is against a stated reference
// rather than against whatever the code now does.
const auto retired = [&](double off) {
if (off < 0.0 || off >= static_cast<double>(span)) return 0.0;
if (off < static_cast<double>(fadeIn)) {
return std::sin(off / static_cast<double>(fadeIn) * (kPi / 2.0));
}
const double foStart = static_cast<double>(span - fadeOut);
if (off >= foStart) {
return std::cos((off - foStart) / static_cast<double>(fadeOut) * (kPi / 2.0));
}
return 1.0;
};
double worst = 0.0;
for (std::int64_t i = 0; i < span; ++i) {
worst = std::max(worst, std::fabs(ahd.amplitudeAt(static_cast<double>(i)) -
retired(static_cast<double>(i))));
}
CHECK(worst <= 0.2106); // the sin-vs-linear bound, and nothing beyond it
// Both agree exactly where it matters structurally: the onset, the plateau, and the end.
CHECK(ahd.amplitudeAt(0.0) == retired(0.0));
CHECK(ahd.amplitudeAt(600.0) == retired(600.0));
CHECK(ahd.amplitudeAt(static_cast<double>(span)) == retired(static_cast<double>(span)));
}
// A prior ZERO fade-out migrates to Decay = 0 and keeps the abrupt end the old controls could
// express — nothing the retired mechanism could say is lost.
static void testZeroFadeOutMigratesToAnAbruptEnd() {
AhdParams migrated;
migrated.attackFrames = 0;
migrated.decayFrames = 0;
migrated.holdFraction = 1.0;
AhdEnvelope ahd;
ahd.configure(500, migrated);
CHECK(ahd.amplitudeAt(0.0) == 1.0);
CHECK(ahd.amplitudeAt(499.0) == 1.0); // still at unity on the last frame
CHECK(ahd.amplitudeAt(500.0) == 0.0); // and off on the next
CHECK(ahd.finished());
}
int main() {
testNeutralExponentReproducesTheLinearEvaluationExactly();
testExponentSweepStaysFiniteMonotoneAndInRange();
testExponentActuallyReshapesTheStage();
testAhdSumNeverExceedsTheSpanForAnyTriple();
testHoldFractionEndpoints();
testEachModePlaysItsOwnStageValuesAndTheOtherSurvives();
testFilterEnvelopeFollowsTheModeShape();
testTriggerPreserveEndsWithoutATerminalDiscontinuity();
testTriggerPreserveAhdEndingEarlyAlsoRingsOut();
testVarispeedTailIsUntouched();
testMigratedFadeContourMatchesTheRetiredShapeWithinTheStatedBound();
testZeroFadeOutMigratesToAnAbruptEnd();
if (g_fail == 0) {
std::printf("all staged_envelopes tests passed\n");
return 0;
}
std::printf("%d staged_envelopes check(s) failed\n", g_fail);
return 1;
}
+22
View File
@@ -121,6 +121,27 @@ static void testSecondaryTertiaryAreDistinguishable() {
CHECK(delta >= 60);
}
// The instrument's envelope overlay is traced OVER the waveform, which draws in the primary
// accent — an accent-on-accent pair no floor covers, since neither is a surface. It moved from
// the secondary to the tertiary for exactly this reason, so the pair is pinned two ways: the
// tertiary must separate from the primary MORE than the secondary did (the measurable half of
// the move), and the separation is a hue one, since two pastels sit close in luminance by
// construction. Whether the result reads clearly is a perceptual call, not this test's.
static void testOverlayAccentSeparatesFromTheWaveformAccent() {
const KitColor wave = roleColor(Role::AccentPrimary);
const KitColor overlay = roleColor(Role::AccentTertiary);
const KitColor prior = roleColor(Role::AccentSecondary);
CHECK(contrastRatio(overlay, wave) > contrastRatio(prior, wave));
// Hue divergence against the waveform: the waveform's green dominates its red, the
// overlay's red dominates its green — opposite balances, not two shades of one.
CHECK(wave.g > wave.r);
CHECK(overlay.r > overlay.g);
const int delta = std::abs(int(wave.r) - int(overlay.r)) +
std::abs(int(wave.g) - int(overlay.g)) +
std::abs(int(wave.b) - int(overlay.b)) ;
CHECK(delta >= 60);
}
static void testWarnClearsStateFloorOnBackground() {
// warn (destructive) must be unmistakable -> clears the state floor on the base.
CHECK(contrastRatio(roleColor(Role::Warn), roleColor(Role::BgBase))
@@ -237,6 +258,7 @@ int main() {
testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable();
testOverlayAccentSeparatesFromTheWaveformAccent();
testWarnClearsStateFloorOnBackground();
testLabelOnActiveSurfaceClearsFloor();
testRolesAreDistinctAndElevationMonotonic();
+3 -95
View File
@@ -1,10 +1,9 @@
// Standalone tests for reasampler::instrument::map::trigger_seam — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests.
//
// Covers: triggerPlayLength (zero play length, startFrame set, startFrame past frameCount,
// rounding); framesToFadeFraction (zero play length, basic ratio); fadeFractionToFrames
// (zero play length, rounding); round-trip fidelity; the Finding 1 regression (start-point
// set — the case that was broken before this module existed).
// Covers triggerPlayLength: zero play length, startFrame set, startFrame past frameCount,
// rounding, and the Finding 1 regression (start-point set — the case that was broken before
// this module existed).
#include "../src/core/instrument/map/trigger_seam.h"
@@ -58,85 +57,6 @@ static void testPlayLengthRounding() {
CHECK(triggerPlayLength(0.6, 3, 0) == 2);
}
// --- framesToFadeFraction -----------------------------------------------------
static void testFramesToFadeFractionBasic() {
// 100 frames fade over 1000 play length -> 0.1.
const double frac = framesToFadeFraction(100, 1000);
CHECK(frac > 0.0999 && frac < 0.1001);
}
static void testFramesToFadeFractionZeroPlayLength() {
// Degenerate: zero play length -> 0.0 (no division by zero).
CHECK(framesToFadeFraction(100, 0) == 0.0);
CHECK(framesToFadeFraction(0, 0) == 0.0);
}
static void testFramesToFadeFractionFullSpan() {
// fadeFrames == playLength -> fraction 1.0.
const double frac = framesToFadeFraction(500, 500);
CHECK(frac > 0.9999 && frac < 1.0001);
}
// --- fadeFractionToFrames -----------------------------------------------------
static void testFadeFractionToFramesBasic() {
// 0.1 of 1000 play length -> round(100.0) = 100.
CHECK(fadeFractionToFrames(0.1, 1000) == 100);
}
static void testFadeFractionToFramesZeroPlayLength() {
// Degenerate: play length 0 -> 0 frames.
CHECK(fadeFractionToFrames(0.5, 0) == 0);
}
static void testFadeFractionToFramesRounding() {
// 0.333... of 3 -> round(1.0) = 1.
CHECK(fadeFractionToFrames(1.0 / 3.0, 3) == 1);
// 0.5 of 3 -> round(1.5) = 2.
CHECK(fadeFractionToFrames(0.5, 3) == 2);
}
// --- Round-trip ---------------------------------------------------------------
static void testRoundTripNoStartPoint() {
// Pack then unpack: fadeInFrames should survive (within 1 frame of rounding).
// frameCount=44100, startFrame=0, lengthFraction=1.0 -> playLength=44100.
// fadeInFrames = 2205 (5% of 44100).
const std::int64_t fadeIn = 2205;
const std::int64_t playLen = triggerPlayLength(1.0, 44100, 0);
const double frac = framesToFadeFraction(fadeIn, playLen);
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
// Should be exact (2205 / 44100 * 44100 = 2205.0).
CHECK(recovered == fadeIn);
}
static void testRoundTripWithStartPoint() {
// The Finding 1 case: startFrame set. frameCount=44100, startFrame=8820 (20%).
// postStart=35280, lengthFraction=1.0 -> playLength=35280.
// fadeInFrames = 1764 (5% of 35280).
const std::int64_t frameCount = 44100;
const std::int64_t startFrame = 8820;
const std::int64_t fadeIn = 1764;
const std::int64_t playLen = triggerPlayLength(1.0, frameCount, startFrame);
CHECK(playLen == 35280);
const double frac = framesToFadeFraction(fadeIn, playLen);
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
CHECK(recovered == fadeIn);
}
static void testRoundTripFadeGreaterThanSpan() {
// fadeFrames > playLength -> fraction > 1 (returned unclamped; the overlay clamps at draw).
// The shell is responsible for clamping before writing AmpEnvelope.
const std::int64_t playLen = 100;
const std::int64_t fadeIn = 150;
const double frac = framesToFadeFraction(fadeIn, playLen);
CHECK(frac > 1.0); // intentionally unclamped from this module's perspective
// The round-trip still recovers the original fade, so the shell can clamp after.
const std::int64_t recovered = fadeFractionToFrames(frac, playLen);
CHECK(recovered == fadeIn);
}
int main() {
testPlayLengthNoStartPoint();
testPlayLengthWithStartPoint();
@@ -144,18 +64,6 @@ int main() {
testPlayLengthStartFramePastEnd();
testPlayLengthRounding();
testFramesToFadeFractionBasic();
testFramesToFadeFractionZeroPlayLength();
testFramesToFadeFractionFullSpan();
testFadeFractionToFramesBasic();
testFadeFractionToFramesZeroPlayLength();
testFadeFractionToFramesRounding();
testRoundTripNoStartPoint();
testRoundTripWithStartPoint();
testRoundTripFadeGreaterThanSpan();
if (g_fail == 0) std::printf("trigger_seam: all tests passed\n");
else std::printf("trigger_seam: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;