From 13e8c5c4d99c3036c01946a4de69e5173d7c554f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 31 Jul 2026 08:37:57 -0400 Subject: [PATCH] =?UTF-8?q?instrument:=20one=20staged-envelope=20system=20?= =?UTF-8?q?=E2=80=94=20per-segment=20curves,=20the=20sustain-less=20AHD,?= =?UTF-8?q?=20and=20a=20shared=20overlay=20for=20all=20three=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/instrument/CLAUDE.md | 85 ++- src/core/instrument/engine/CMakeLists.txt | 4 + src/core/instrument/engine/envelopes.h | 278 ++++---- src/core/instrument/engine/live_params.cpp | 6 +- src/core/instrument/engine/live_params.h | 10 +- src/core/instrument/engine/play_params.h | 65 +- src/core/instrument/engine/voice.cpp | 54 +- src/core/instrument/engine/voice.h | 65 +- src/core/instrument/map/CMakeLists.txt | 4 +- .../instrument/map/component_state_io.cpp | 271 +------- src/core/instrument/map/component_state_io.h | 34 +- src/core/instrument/map/params_payload.cpp | 354 ++++++++++ src/core/instrument/map/params_payload.h | 41 ++ src/core/instrument/map/sample_map.cpp | 23 +- src/core/instrument/map/sample_map.h | 34 +- src/core/instrument/map/trigger_seam.cpp | 10 - src/core/instrument/map/trigger_seam.h | 16 +- src/core/instrument/ui/deck_groups.cpp | 79 ++- src/core/instrument/ui/deck_groups.h | 83 ++- src/core/instrument/ui/envelope_edit.cpp | 198 ++++-- src/core/instrument/ui/envelope_edit.h | 71 +-- src/core/instrument/ui/envelope_overlay.cpp | 174 +++-- src/core/instrument/ui/envelope_overlay.h | 119 ++-- src/core/instrument/ui/knob_deck.cpp | 28 +- src/core/instrument/ui/knob_deck.h | 31 +- src/core/util/CLAUDE.md | 9 +- src/core/util/CMakeLists.txt | 5 + src/core/util/curve_law.h | 47 ++ src/shell/instrument/editor_controls.cpp | 320 +++++++--- src/shell/instrument/editor_input.cpp | 1 + src/shell/instrument/editor_input_deck.cpp | 27 +- .../instrument/editor_input_waveform.cpp | 23 +- src/shell/instrument/editor_internal.h | 36 ++ src/shell/instrument/editor_paint_deck.cpp | 52 +- .../instrument/editor_paint_waveform.cpp | 44 +- src/shell/instrument/editor_platform.cpp | 1 + src/shell/instrument/editor_session.cpp | 2 +- src/shell/instrument/reasampler_editor.h | 52 +- tests/test_component_state_io.cpp | 135 +++- tests/test_curve_law.cpp | 120 ++++ tests/test_deck_groups.cpp | 142 ++++- tests/test_envelope_edit.cpp | 603 ++++++++---------- tests/test_envelope_overlay.cpp | 559 ++++++++-------- tests/test_knob_deck.cpp | 65 +- tests/test_live_delivery.cpp | 92 ++- tests/test_live_params.cpp | 27 +- tests/test_sample_map.cpp | 40 +- tests/test_sampler_core.cpp | 30 +- tests/test_staged_envelopes.cpp | 529 +++++++++++++++ tests/test_theme.cpp | 22 + tests/test_trigger_seam.cpp | 98 +-- 51 files changed, 3406 insertions(+), 1812 deletions(-) create mode 100644 src/core/instrument/map/params_payload.cpp create mode 100644 src/core/instrument/map/params_payload.h create mode 100644 src/core/util/curve_law.h create mode 100644 tests/test_curve_law.cpp create mode 100644 tests/test_staged_envelopes.cpp diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index a21413b..219e5ab 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 33c13fe..43b0781 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/engine/envelopes.h b/src/core/instrument/engine/envelopes.h index 8608cf8..2a5fc0c 100644 --- a/src/core/instrument/engine/envelopes.h +++ b/src/core/instrument/engine/envelopes.h @@ -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 #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(static_cast(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(s.total)) return 0.0; + if (s.attack > 0 && offset < static_cast(s.attack)) { + return curveMap(offset / static_cast(s.attack), attackCurve); + } + const double decayStart = static_cast(s.total - s.decay); + if (s.decay > 0 && offset >= decayStart) { + double t = (offset - decayStart) / static_cast(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(params.attackFrames); - return l > 1.0 ? 1.0 : l; + double l = stagePos_ / static_cast(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(params.decayFrames); - return 1.0 + (params.sustainLevel - 1.0) * t; + double t = stagePos_ / static_cast(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(params.releaseFrames); - const double l = releaseFrom_ * (1.0 - t); - return l < 0.0 ? 0.0 : l; + double t = stagePos_ / static_cast(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(playLength_)) { - // At/past the play length the one-shot is done; the voice also frees on - // readPos >= playEnd. - if (sourceOffset >= static_cast(playLength_)) finished_ = true; + if (finished_ || sourceOffset >= static_cast(fit_.total)) { + if (sourceOffset >= static_cast(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(playLength_ - fadeOut_); - if (fadeIn_ > 0 && sourceOffset < static_cast(fadeIn_)) { - const double phase = sourceOffset / static_cast(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(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(params_.attackFrames) : 0.0; - const double d = params_.decayFrames > 0 ? static_cast(params_.decayFrames) : 0.0; - const double na = attackFrames > 0 ? static_cast(attackFrames) : 0.0; - const double nd = decayFrames > 0 ? static_cast(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(params.attackFrames) : 0.0; - const double d = params.decayFrames > 0 ? static_cast(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(old.attack); + const double oh = static_cast(old.hold); + const double od = static_cast(old.decay); + const double na = static_cast(next.attack); + const double nh = static_cast(next.hold); + const double nd = static_cast(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_; }; diff --git a/src/core/instrument/engine/live_params.cpp b/src/core/instrument/engine/live_params.cpp index 70cedc1..e946b18 100644 --- a/src/core/instrument/engine/live_params.cpp +++ b/src/core/instrument/engine/live_params.cpp @@ -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; } diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index bfb7941..283a3fc 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -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. diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index 8a4d910..ad12272 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -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; diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index b04066c..758d5db 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -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(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( - static_cast(span) * frac + 0.5); // round + static_cast(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(p.filter.settings.resonanceNorm)); rMorph_.set(static_cast(p.filter.settings.morphNorm)); rDrive_.set(static_cast(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(live.filterSettings.cutoffNorm); filterKeyTrack_ = live.filterKeyTrack; filterSettings_.morphLaw = live.filterSettings.morphLaw; diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 58b6496..e72e740 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -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(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(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(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(filterBaseCutoff_) + filterModAmount_ * envOut; if (cut < 0.0) cut = 0.0; if (cut > 1.0) cut = 1.0; const float cutNorm = static_cast(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(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; diff --git a/src/core/instrument/map/CMakeLists.txt b/src/core/instrument/map/CMakeLists.txt index f4d761f..532e891 100644 --- a/src/core/instrument/map/CMakeLists.txt +++ b/src/core/instrument/map/CMakeLists.txt @@ -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. diff --git a/src/core/instrument/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp index 8e1acee..2a3b71b 100644 --- a/src/core/instrument/map/component_state_io.cpp +++ b/src/core/instrument/map/component_state_io.cpp @@ -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 // std::min (bounded curve-point reserve) -#include // assert (v3-lift projectRate guard) #include // std::isfinite (v8 master-gain validation) #include // std::memcpy (serializeSelection) #include // 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(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& out, const InstrumentParams& p) { - out.push_back(p.rootOverride ? 1 : 0); - if (p.rootOverride) { - putLE(out, static_cast(static_cast(*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& out, const VelocityCurve& curve) { - const std::vector& pts = curve.points(); - putLE(out, static_cast(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& 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(f.settings.cutoffNorm))); - putLE(out, doubleToBits(static_cast(f.settings.resonanceNorm))); - putLE(out, doubleToBits(static_cast(f.settings.morphNorm))); - putLE(out, doubleToBits(static_cast(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 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(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(bitsToDouble(r.u64())); - f.settings.resonanceNorm = static_cast(bitsToDouble(r.u64())); - f.settings.morphNorm = static_cast(bitsToDouble(r.u64())); - f.settings.driveNorm = static_cast(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, 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(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(r.i64()) / liftRate; - p.play.pitchEnv.decaySeconds = static_cast(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) { diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index 141968e..07c3635 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -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 #include @@ -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 diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp new file mode 100644 index 0000000..2d325d8 --- /dev/null +++ b/src/core/instrument/map/params_payload.cpp @@ -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 // std::min (bounded curve-point reserve) +#include // assert (v3-lift projectRate guard) +#include // std::isfinite (wire-value validation) +#include // 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(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& out, const InstrumentParams& p) { + out.push_back(p.rootOverride ? 1 : 0); + if (p.rootOverride) { + putLE(out, static_cast(static_cast(*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& out, const VelocityCurve& curve) { + const std::vector& pts = curve.points(); + putLE(out, static_cast(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& 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(fadeInFrames > 0 ? fadeInFrames : 0) / rate; + out.decaySeconds = static_cast(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 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(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(bitsToDouble(r.u64())); + f.settings.resonanceNorm = static_cast(bitsToDouble(r.u64())); + f.settings.morphNorm = static_cast(bitsToDouble(r.u64())); + f.settings.driveNorm = static_cast(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, 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(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(r.i64()) / liftRate; + p.play.pitchEnv.shape.decaySeconds = static_cast(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& 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(f.settings.cutoffNorm))); + putLE(out, doubleToBits(static_cast(f.settings.resonanceNorm))); + putLE(out, doubleToBits(static_cast(f.settings.morphNorm))); + putLE(out, doubleToBits(static_cast(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 diff --git a/src/core/instrument/map/params_payload.h b/src/core/instrument/map/params_payload.h new file mode 100644 index 0000000..f481c9e --- /dev/null +++ b/src/core/instrument/map/params_payload.h @@ -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 +#include +#include + +// 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& 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 diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 7c39102..0eac216 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -212,6 +212,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) { if (f < 0.0) f = 0.0; return static_cast(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; } diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 9b78799..e5ccfb8 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -143,21 +143,35 @@ std::vector extractChannel(const std::vector& 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 }; diff --git a/src/core/instrument/map/trigger_seam.cpp b/src/core/instrument/map/trigger_seam.cpp index 096bb76..c9a439c 100644 --- a/src/core/instrument/map/trigger_seam.cpp +++ b/src/core/instrument/map/trigger_seam.cpp @@ -14,14 +14,4 @@ std::int64_t triggerPlayLength(double lengthFraction, return static_cast(lengthFraction * static_cast(postStart) + 0.5); } -double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) { - if (playLength <= 0) return 0.0; - return static_cast(fadeFrames) / static_cast(playLength); -} - -std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) { - if (playLength <= 0) return 0; - return static_cast(fadeFraction * static_cast(playLength) + 0.5); -} - } // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/trigger_seam.h b/src/core/instrument/map/trigger_seam.h index 2d24975..31c49e1 100644 --- a/src/core/instrument/map/trigger_seam.h +++ b/src/core/instrument/map/trigger_seam.h @@ -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 diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 8f144be..e2ca02b 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -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 sampleDeckGroups(PlayMode playMode) { + const bool trigger = (playMode == PlayMode::Trigger); std::vector out; { DeckGroupDesc pitch; @@ -28,8 +29,10 @@ std::vector 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 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 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(DeckParam::kCount) && isLiveDeckParam(static_cast(paramId)); case LiveDragKind::kEnvNode: - return playMode == PlayMode::Gate; + return true; case LiveDragKind::kOther: return false; } diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index 8974dcd..a84688a 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -7,7 +7,7 @@ #include -#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 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 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 diff --git a/src/core/instrument/ui/envelope_edit.cpp b/src/core/instrument/ui/envelope_edit.cpp index 8f11bc1..35c1368 100644 --- a/src/core/instrument/ui/envelope_edit.cpp +++ b/src/core/instrument/ui/envelope_edit.cpp @@ -2,11 +2,17 @@ #include "core/instrument/ui/envelope_edit.h" +#include "core/util/clamp01.h" + #include #include // 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(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(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 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(dxPixels) * secPerPx; const double gateDSec = static_cast(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(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(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; } diff --git a/src/core/instrument/ui/envelope_edit.h b/src/core/instrument/ui/envelope_edit.h index c9b5e06..705d250 100644 --- a/src/core/instrument/ui/envelope_edit.h +++ b/src/core/instrument/ui/envelope_edit.h @@ -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 #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 diff --git a/src/core/instrument/ui/envelope_overlay.cpp b/src/core/instrument/ui/envelope_overlay.cpp index 30f4ee3..924ba4d 100644 --- a/src/core/instrument/ui/envelope_overlay.cpp +++ b/src/core/instrument/ui/envelope_overlay.cpp @@ -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(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(kGateSustainDisplayFraction * static_cast(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(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(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(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(w - 1)) px = static_cast(w - 1); @@ -72,10 +85,27 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) { v.x = area.x + static_cast(px + 0.5); v.y = levelToY(area, level); v.level = level; + v.knot = knot; return v; } -std::vector 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 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 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(W - gateTimedWidth(area)); const double sep = static_cast(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(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 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 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 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 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 buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area, +std::vector 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 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 diff --git a/src/core/instrument/ui/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h index e764f8f..fe93e10 100644 --- a/src/core/instrument/ui/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -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 #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 buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area, +std::vector 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 diff --git a/src/core/instrument/ui/knob_deck.cpp b/src/core/instrument/ui/knob_deck.cpp index 43bb8a9..5594969 100644 --- a/src/core/instrument/ui/knob_deck.cpp +++ b/src/core/instrument/ui/knob_deck.cpp @@ -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& 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) } diff --git a/src/core/instrument/ui/knob_deck.h b/src/core/instrument/ui/knob_deck.h index 22290e9..a7644cd 100644 --- a/src/core/instrument/ui/knob_deck.h +++ b/src/core/instrument/ui/knob_deck.h @@ -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 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 cells; DeckToggleLayout rowToggle; // id -1 when absent @@ -105,17 +124,19 @@ DeckLayout layoutDeck(const std::vector& 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 diff --git a/src/core/util/CLAUDE.md b/src/core/util/CLAUDE.md index f979495..9831010 100644 --- a/src/core/util/CLAUDE.md +++ b/src/core/util/CLAUDE.md @@ -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 diff --git a/src/core/util/CMakeLists.txt b/src/core/util/CMakeLists.txt index 897e2ba..b6d9031 100644 --- a/src/core/util/CMakeLists.txt +++ b/src/core/util/CMakeLists.txt @@ -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) diff --git a/src/core/util/curve_law.h b/src/core/util/curve_law.h new file mode 100644 index 0000000..3d9979d --- /dev/null +++ b/src/core/util/curve_law.h @@ -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 + +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 diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index cbf83eb..5823024 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -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 +#include // log/exp (the curve knob's logarithmic travel) #include #include // snprintf (deck value labels) #include @@ -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(f) / fadeMax) : 0.0; - }; switch (static_cast(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(clamp01(v) * fadeMax + 0.5); - }; switch (static_cast(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(kPreviewVelocityDefault) / 127.0; return static_cast(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(play.trigger.fadeInFrames)); break; - case ParamControl::kTrigFadeOut: - snprintf(buf, sizeof(buf), "%lldf", - static_cast(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(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(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(playLen) / rate : 0.0; + const double fullSpan = + rate > 0.0 ? static_cast((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 } } diff --git a/src/shell/instrument/editor_input.cpp b/src/shell/instrument/editor_input.cpp index 322db2e..c9dff13 100644 --- a/src/shell/instrument/editor_input.cpp +++ b/src/shell/instrument/editor_input.cpp @@ -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 diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index e7ce840..e5beeff 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -19,6 +19,7 @@ using namespace reasampler::instrument::ui; bool ReaSamplerEditor::deckKnobDisabled(int id) const { switch (static_cast(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(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(hit.id)); + const bool inner = hit.inner && curve != ParamControl::kCount; drag_ = DragKind::kDeckKnob; - dragParamId_ = hit.id; - dragKnobStartValue_ = deckControlNorm(hit.id); + dragParamId_ = inner ? static_cast(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(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}; } diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index b2b60dc..ce2e8f3 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -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(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(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; diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h index 4669abb..c8cdfb9 100644 --- a/src/shell/instrument/editor_internal.h +++ b/src/shell/instrument/editor_internal.h @@ -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(kg.centerX); + const float cy = static_cast(kg.centerY); + const float r = static_cast(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((arc.startDeg - 360.0) * kDegToRad); + const float av = static_cast( + (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(cx + 0.5f), static_cast(cy + 0.5f), + static_cast(tip.x + 0.5f), static_cast(tip.y + 0.5f), + toLice(ui::roleColor(disabled ? ui::Role::TextDim : ui::Role::AccentTertiary)), + 1.0f, 0, true); +} + #endif // _WIN32 } // namespace reasampler::vst diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index 6121e08..634c693 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -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(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(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(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(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(curve)); + else if (dragging || hov) label = deckValueLabel(c.id); + else label = std::string(knobName(static_cast(c.id))); kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim); } } diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp index 75ce070..8bdc423 100644 --- a/src/shell/instrument/editor_paint_waveform.cpp +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -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(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 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(hx), static_cast(hy), + static_cast(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); + } } } diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index 7c2e077..70e11cf 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -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 diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index fc50a46..f6b4dd2 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -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) { diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index a6c7579..ec22429 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -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. diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 4521589..38faddb 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -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 #include @@ -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 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 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(); diff --git a/tests/test_curve_law.cpp b/tests/test_curve_law.cpp new file mode 100644 index 0000000..6603b20 --- /dev/null +++ b/tests/test_curve_law.cpp @@ -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 +#include + +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(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(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; +} diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index f0994ff..b6e7d87 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -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 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 gate = sampleDeckGroups(PlayMode::Gate); + const std::vector trig = sampleDeckGroups(PlayMode::Trigger); + const DeckGroupDesc& gAmp = gate[static_cast(indexOfGroup(gate, kGroupAmpEnv))]; + const DeckGroupDesc& tAmp = trig[static_cast(indexOfGroup(trig, kGroupAmpEnv))]; + const std::vector gateAmp = {cell(DeckParam::kAttack), cell(DeckParam::kHold), + cell(DeckParam::kDecay), cell(DeckParam::kSustain), + cell(DeckParam::kRelease)}; + const std::vector 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(indexOfGroup(gate, kGroupFilterEnv))]; + const DeckGroupDesc& tFe = trig[static_cast(indexOfGroup(trig, kGroupFilterEnv))]; + const std::vector 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 gate = sampleDeckGroups(PlayMode::Gate); @@ -86,7 +169,7 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() { const DeckGroupDesc& b = trig[static_cast(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(DeckParam::kFilterCutoff), - PlayMode::Gate)); - // A knob's routing is the knob's, not the play mode's. - CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kAttack), - PlayMode::Trigger)); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigFadeIn), - PlayMode::Trigger)); - CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kMasterGain), - PlayMode::Gate)); + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kFilterCutoff))); + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(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(DeckParam::kTrigAttack))); + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigDecayCurve))); + CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigLength))); + CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kMasterGain))); + CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(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(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(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(DeckParam::kFilterCutoff), - PlayMode::Gate)); + CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff))); } int main() { @@ -257,6 +346,9 @@ int main() { testOnlyALiveControlsDragTakesTheLiveTier(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); testFilterGroupCarriesItsFiveToneControlsPlusModulation(); + testOnlyTheThreeEnvelopeDecksCarryARadio(); + testGateAndTriggerFacesCarryTheirOwnShapes(); + testOnlySlopedStageKnobsCarryAnInnerCurveDial(); testAmpGroupWidthSurvivesAGateTriggerFlip(); testWrappedDeckHeightAtTheEditorFloorWidth(); testDeckFitsInsideTheEnforcedMinimumWindow(); diff --git a/tests/test_envelope_edit.cpp b/tests/test_envelope_edit.cpp index f4a77e2..048cb96 100644 --- a/tests/test_envelope_edit.cpp +++ b/tests/test_envelope_edit.cpp @@ -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 #include -#include #include 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& 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& 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 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); diff --git a/tests/test_envelope_overlay.cpp b/tests/test_envelope_overlay.cpp index e32e018..60fcc87 100644 --- a/tests/test_envelope_overlay.cpp +++ b/tests/test_envelope_overlay.cpp @@ -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 #include #include @@ -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& 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& 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 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 poly = buildEnvelopePolyline(env, overlayOf(a), 2.0); - + const std::vector 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 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 poly = buildEnvelopePolyline(env, overlayOf(a), 2.0); - - EnvVertex decay, plateauEnd; + const std::vector 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 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 shortR = + buildEnvelopePolyline(ahdsr(0.1, 0.0, 0.1, 0.5, 0.1), overlayOf(a), 4.0); + const std::vector 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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); diff --git a/tests/test_knob_deck.cpp b/tests/test_knob_deck.cpp index d48f316..97e1c43 100644 --- a/tests/test_knob_deck.cpp +++ b/tests/test_knob_deck.cpp @@ -26,27 +26,27 @@ static int g_fail = 0; // toggle + row toggle), MASTER (1 cell, no toggle). static std::vector shellLikeDeck() { std::vector 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 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 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 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 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); diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp index 2f5b30c..189e938 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -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(); diff --git a/tests/test_live_params.cpp b/tests/test_live_params.cpp index 71095b3..e9b1011 100644 --- a/tests/test_live_params.cpp +++ b/tests/test_live_params.cpp @@ -27,7 +27,9 @@ static_assert(std::is_trivially_copyable_v, "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() { diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 00a2836..84c14b7 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -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() { diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index eaec933..dadbca7 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -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); diff --git a/tests/test_staged_envelopes.cpp b/tests/test_staged_envelopes.cpp new file mode 100644 index 0000000..801e962 --- /dev/null +++ b/tests/test_staged_envelopes.cpp @@ -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 +#include +#include +#include + +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(i) / 400.0); + } + for (int i = 0; i < 500; ++i) { + CHECK(env.tick() == 1.0 + (0.25 - 1.0) * (static_cast(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(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(i) / 200.0); + } + for (int i = 0; i < 300; ++i) { + CHECK(ahd.amplitudeAt(200 + i) == 1.0 - static_cast(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(i) / 200.0)); + } + for (int i = 0; i < 300; ++i) { + CHECK(pitch.tick() == 12.0 * (1.0 - static_cast(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(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 out; + eng.render(out, 1000); + return out; + }; + + const std::vector 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 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 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 out; + eng.render(out, frame + 1); + return static_cast(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(0.8 * std::sin(2.0 * kPi * static_cast(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 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(out[i]) - + static_cast(out[i - 1]))); + } + if (m.soundingFrames > 0) m.lastLevel = std::fabs(static_cast(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(0.8 * std::sin(2.0 * kPi * static_cast(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 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(out[i]) - + static_cast(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(0.8 * std::sin(2.0 * kPi * static_cast(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 out; + eng.render(out, frames); + return out; + }; + const std::vector 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(0.8 * std::sin(2.0 * kPi * static_cast(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(span)) return 0.0; + if (off < static_cast(fadeIn)) { + return std::sin(off / static_cast(fadeIn) * (kPi / 2.0)); + } + const double foStart = static_cast(span - fadeOut); + if (off >= foStart) { + return std::cos((off - foStart) / static_cast(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(i)) - + retired(static_cast(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(span)) == retired(static_cast(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; +} diff --git a/tests/test_theme.cpp b/tests/test_theme.cpp index 545d91c..8ca4460 100644 --- a/tests/test_theme.cpp +++ b/tests/test_theme.cpp @@ -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(); diff --git a/tests/test_trigger_seam.cpp b/tests/test_trigger_seam.cpp index 702c44d..8961712 100644 --- a/tests/test_trigger_seam.cpp +++ b/tests/test_trigger_seam.cpp @@ -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;