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:
@@ -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 0–1'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 (1–32, 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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
return params.peakSemitones * (1.0 - (pos_ - a) / d);
|
||||
}
|
||||
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_;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
||||
};
|
||||
|
||||
// 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
|
||||
bool enabled = false;
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
filterEnv_.configure(p.filter.env);
|
||||
filterEnv_.noteOn();
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 peakSemitones = 0.0; // signed depth at the peak
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.cellIds = {id(DeckParam::kFilterEnvAttack),
|
||||
id(DeckParam::kFilterEnvHold),
|
||||
id(DeckParam::kFilterEnvDecay),
|
||||
id(DeckParam::kFilterEnvSustain),
|
||||
id(DeckParam::kFilterEnvRelease)};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
AmpEnvelope out = grabEnv;
|
||||
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
|
||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
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) {
|
||||
// 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);
|
||||
break;
|
||||
case EnvNode::DecayEnd: {
|
||||
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
|
||||
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
break;
|
||||
}
|
||||
case EnvNode::ReleaseStart:
|
||||
// The release runs from this node to the anchored right edge, so dragging LEFT
|
||||
// (negative dx) lengthens it — the delta enters with the opposite sign.
|
||||
out.releaseSeconds =
|
||||
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
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) {
|
||||
// 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.
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
||||
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd:
|
||||
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.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
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::ReleaseEnd:
|
||||
out.releaseSeconds =
|
||||
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
case EnvNode::DecayEnd:
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + dSec, 0.0, bounds.maxDecaySeconds);
|
||||
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));
|
||||
case EnvNode::AttackCurve:
|
||||
out.attackCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
||||
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));
|
||||
case EnvNode::DecayCurve:
|
||||
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
||||
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);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -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,46 +31,41 @@ 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,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -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);
|
||||
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);
|
||||
|
||||
// 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 + 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,
|
||||
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
|
||||
std::vector<EnvVertex> ahdPolyline(const StageEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
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
|
||||
|
||||
@@ -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).
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
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;
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
prev = &v;
|
||||
}
|
||||
// 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));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 -> StageEnvelope. `startFrame` is the effective start point.
|
||||
StageEnvelope packEnvelope(OverlayEnv which, const PlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const;
|
||||
|
||||
// PACK (draw): play params -> AmpEnvelope. `startFrame` is the effective start point.
|
||||
AmpEnvelope packEnvelope(const PlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const;
|
||||
// UNPACK (commit): an edited StageEnvelope -> the play params, in place.
|
||||
void unpackEnvelope(OverlayEnv which, const StageEnvelope& env, PlaySeconds& play) 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;
|
||||
// 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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
+253
-306
@@ -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,364 +30,313 @@ 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 -------------------------------------------------------
|
||||
|
||||
static void testTimeToXEndpoints() {
|
||||
const Rect a = wideArea();
|
||||
CHECK(timeToX(a, 2.0, 0.0) == a.x); // t=0 -> left
|
||||
CHECK(timeToX(a, 2.0, 2.0) == a.right() - 1); // t=total -> last in-bounds column
|
||||
CHECK(timeToX(a, 2.0, 1.0) == a.x + 500); // midpoint
|
||||
CHECK(timeToX(a, 2.0, 0.0) == a.x); // t=0 -> left
|
||||
CHECK(timeToX(a, 2.0, 2.0) == a.right() - 1); // t=total -> last in-bounds column
|
||||
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() {
|
||||
static void testLevelToY() {
|
||||
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);
|
||||
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); // 99-row span, rounded
|
||||
CHECK(levelToY(a, 5.0) == a.y); // clamps
|
||||
CHECK(levelToY(a, -5.0) == a.bottom() - 1);
|
||||
}
|
||||
|
||||
static void testLevelToYEndpoints() {
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
static void testLevelToYClamps() {
|
||||
// --- the AHDSR schematic ------------------------------------------------------
|
||||
|
||||
static void testAhdsrNodeOrderAndLevels() {
|
||||
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);
|
||||
}
|
||||
|
||||
// --- Gate polyline ------------------------------------------------------------
|
||||
|
||||
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;
|
||||
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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user