instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes
Trigger's fade pair folds into the AHD (and goes live); the release anchors right; Preserve rings its synthetic tail out instead of cutting it. Payload v10.
This commit is contained in:
@@ -9,8 +9,8 @@ subdirectories:
|
||||
velocity curve, and master-gain taper math.
|
||||
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
|
||||
`ComponentState` codec, and the small pure helpers the engine/shell share
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
|
||||
frame↔fraction conversion).
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, the Trigger
|
||||
play-span formula).
|
||||
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
|
||||
resolution, and anchored start/end offsets — the one record and resolver a
|
||||
capture-signal popup and the offline bake read from, so they cannot diverge.
|
||||
@@ -120,13 +120,17 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
note-off, `level→0` over release. `holdFrames == 0` is exactly the pre-Gate ADSR — a
|
||||
back-compat degenerate.
|
||||
- **Trigger — one-shot drum-pad.** Note-on fires playback of a defined `%` of sample
|
||||
length with a fade-in and fade-out ramp; note-off is ignored (the voice plays through,
|
||||
no sustain loop). Frame span `[startFrame, playEnd)` where `playEnd = startFrame +
|
||||
round(lengthFraction·(frames − startFrame))`; amplitude ramps `0→1` over
|
||||
`fadeInFrames` at the head and `1→0` over `fadeOutFrames` anchored to `playEnd`; fades
|
||||
clamp so `fadeInFrames + fadeOutFrames ≤ play length`. Fade curve is equal-power
|
||||
(constant-power sin/cos). **Note-off in Trigger is a no-op** — choke-on-note-off is
|
||||
held/out of scope (fork S15-F1).
|
||||
length; note-off is ignored (the voice plays through, no sustain loop). Frame span
|
||||
`[startFrame, playEnd)` where `playEnd = startFrame +
|
||||
round(lengthFraction·(frames − startFrame))`. The amplitude over that span is the staged
|
||||
**AHD** (below), not a fade pair. **Note-off in Trigger is a no-op** — choke-on-note-off
|
||||
is held/out of scope (fork S15-F1).
|
||||
|
||||
> **Superseded, do not reintroduce:** Trigger's amplitude was once a fade-in/unity/
|
||||
> fade-out shape with its own equal-power curve and its own `fadeInFrames`/`fadeOutFrames`
|
||||
> pair, clamped so the two fades fit the span. That is retired — one staged-envelope
|
||||
> design now covers what were two mechanisms. A saved instance's fades lift onto the AHD
|
||||
> at the codec boundary (attack ← fade-in, decay ← fade-out, hold ← the remainder).
|
||||
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
|
||||
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
|
||||
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
|
||||
@@ -138,12 +142,12 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
Contract for Gate's sustain loop under Preserve: *loop the source, shift the output*
|
||||
(loop points stay source-frame facts). `WDL_Resampler` is **not** a Preserve engine (it
|
||||
is a resampler that couples duration) — never wire it as the duration-preserving path.
|
||||
- **Pitch envelope — AD, off by default.** A short attack-decay pitch-offset curve
|
||||
(`peakSemitones` over `attackFrames`, decaying to 0 over `decayFrames`) riding on top of
|
||||
whichever pitch engine; a zero attack gives a pure percussive pitch drop. **Off by
|
||||
default** — a regression that applies pitch modulation when the envelope is disabled is
|
||||
a bug. Under Varispeed the offset is a per-frame multiply of `ratio_`; under Preserve it
|
||||
is added to the shifter's shift amount.
|
||||
- **Pitch envelope — AHD, off by default.** A pitch-offset curve rising to `peakSemitones`
|
||||
over attack, holding, then decaying to 0, riding on top of whichever pitch engine; a zero
|
||||
attack gives a pure percussive pitch drop. **Off by default** — a regression that applies
|
||||
pitch modulation when the envelope is disabled is a bug. Its hold fraction defaults to 0,
|
||||
which is exactly the attack-decay shape it grew out of. Under Varispeed the offset is a
|
||||
per-frame multiply of `ratio_`; under Preserve it is added to the shifter's shift amount.
|
||||
- **Preserve RT discipline.** The shifter pre-warms at voice-allocation; no allocation in
|
||||
`process()` in steady state. **Note (supersedes an earlier framing):** the
|
||||
shifter's onset latency (~25 ms, half-window) was once described as "an
|
||||
@@ -201,19 +205,33 @@ automatable parameters."* It rejects the precedent, not one instance of it.
|
||||
- **Do not spec Tier 2/3** from this directory. Tier 2 is held, Tier 3 is
|
||||
optional-forever; don't let their feature lists drive Tier 0–1's build shape.
|
||||
|
||||
### Envelope overlay + draggable nodes (S-VIEW, settled 2026-07-27, landed)
|
||||
### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)
|
||||
|
||||
The amp envelope is drawn as a curve over the Sample view's hero waveform at the shared
|
||||
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
|
||||
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
|
||||
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
|
||||
one model: both read/write the same envelope fields of the one parameter set, so a drag
|
||||
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
|
||||
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
|
||||
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
|
||||
same per-param min/max the sliders enforce, so node-drag can never produce a param the
|
||||
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
|
||||
`envelope_overlay` and `envelope_edit` in Modules below.
|
||||
The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a
|
||||
transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at
|
||||
most one is overlay-active, and **none is a valid resting state — the editor opens there.**
|
||||
Never persisted; it selects what is drawn, not what is played.
|
||||
|
||||
**The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round
|
||||
mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a
|
||||
knot drag and the deck knobs are surfaces onto ONE model: all three read/write the same
|
||||
fields of the one parameter set, so an edit on any of them re-lays the others — one source
|
||||
of truth, structural (re-read-every-paint), never a listener chain. Every drag is
|
||||
range-clamped to the same per-param min/max the knobs enforce, so no drag can produce a
|
||||
param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps —
|
||||
see `envelope_overlay` and `envelope_edit` in Modules below.
|
||||
|
||||
**Which shape an envelope takes is decided by the play mode, not by what it modulates:**
|
||||
pitch is always AHD; amp and filter are AHDSR in Gate and AHD in Trigger. Both mode shapes
|
||||
are STORED per envelope, so flipping modes cannot lose either mode's dialled values (the
|
||||
migration case forces it: an old instance carries both its AHDSR values and its Trigger
|
||||
fades, and one shared set could not preserve both modes' prior sound).
|
||||
|
||||
**And which LAYOUT an envelope takes follows from whether it has a sustain stage** — the
|
||||
same rule, applied once: an AHDSR right-anchors its release (the end point is fixed at the
|
||||
canvas edge and release is dragged from its top node), a sustain-less AHD maps 1:1 onto the
|
||||
waveform's time axis. The two policies coexist rather than merge; the 1:1 mapping only means
|
||||
anything for a trigger shape.
|
||||
|
||||
### Parameter ownership and persistence (D-B)
|
||||
|
||||
@@ -235,7 +253,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
||||
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class. `AdsrEnvelope`/`PitchEnvelope` also own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the two level steps φ cannot cover.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
@@ -247,9 +265,10 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
||||
|
||||
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
|
||||
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
|
||||
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
||||
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
||||
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
|
||||
- `trigger_seam` — the shared Trigger play-SPAN formula: how the stored %-length becomes the source-frame span the voice plays and the overlay draws over, threading `startFrame` correctly. (Its fade frames↔fraction converters retired with the fade pair itself.)
|
||||
|
||||
### `ui/`
|
||||
|
||||
@@ -266,13 +285,13 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
|
||||
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
|
||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||
- `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
|
||||
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
|
||||
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
|
||||
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample.
|
||||
- **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
|
||||
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
|
||||
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
|
||||
|
||||
@@ -40,3 +40,7 @@ reasampler_test(sampler_filter LINK sampler_core)
|
||||
# Live delivery is the third integration seam over the same engine: what a published block
|
||||
# does to a voice that is already sounding, and what it must leave alone.
|
||||
reasampler_test(live_delivery LINK sampler_core)
|
||||
|
||||
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
|
||||
# both mode shapes share, and the Trigger tail's terminal behaviour.
|
||||
reasampler_test(staged_envelopes LINK sampler_core)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, Trigger fade
|
||||
// shape, AD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, sustain-less AHD,
|
||||
// AHD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop.
|
||||
// NEVER give them a common base or a virtual tick() — that vtable lands on the hottest
|
||||
// inner loop in the program (root CLAUDE.md, structural heuristic 3).
|
||||
@@ -9,9 +9,62 @@
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/util/curve_law.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using util::curveMap;
|
||||
|
||||
// The A/H/D split of a bounded span, in frames.
|
||||
struct AhdSpan {
|
||||
std::int64_t attack = 0;
|
||||
std::int64_t hold = 0;
|
||||
std::int64_t decay = 0;
|
||||
std::int64_t total = 0; // attack + hold + decay; <= span by construction
|
||||
};
|
||||
|
||||
// THE span split, shared by every sustain-less envelope so they cannot disagree about where a
|
||||
// stage boundary is. Attack takes at most the whole span and Decay at most what Attack left,
|
||||
// so `remaining` is non-negative without a clamp; Hold then takes its FRACTION of that
|
||||
// remainder, which is why total <= span holds for every (attack, decay, fraction) triple and
|
||||
// there is no sum to clamp. The two per-stage mins reproduce the retired Trigger fade clamp
|
||||
// exactly (head first, tail into what is left), so a migrated instance keeps its stage lengths.
|
||||
inline AhdSpan fitAhd(std::int64_t spanFrames, const AhdParams& p) {
|
||||
AhdSpan out;
|
||||
const std::int64_t span = spanFrames > 0 ? spanFrames : 0;
|
||||
std::int64_t a = p.attackFrames > 0 ? p.attackFrames : 0;
|
||||
if (a > span) a = span;
|
||||
std::int64_t d = p.decayFrames > 0 ? p.decayFrames : 0;
|
||||
if (d > span - a) d = span - a;
|
||||
const std::int64_t remaining = span - a - d;
|
||||
double frac = p.holdFraction;
|
||||
if (!(frac > 0.0)) frac = 0.0; // also catches NaN
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
out.attack = a;
|
||||
out.decay = d;
|
||||
out.hold = static_cast<std::int64_t>(static_cast<double>(remaining) * frac + 0.5);
|
||||
out.total = out.attack + out.hold + out.decay;
|
||||
return out;
|
||||
}
|
||||
|
||||
// The AHD's normalized level at `offset` frames into the span: 0 -> 1 over attack, flat 1
|
||||
// across hold, 1 -> 0 over decay, 0 outside. Pure over the offset so both the ticking pitch
|
||||
// envelope and the positional amplitude one read one shape.
|
||||
inline double ahdLevelAt(double offset, const AhdSpan& s, double attackCurve,
|
||||
double decayCurve) {
|
||||
if (offset < 0.0 || offset >= static_cast<double>(s.total)) return 0.0;
|
||||
if (s.attack > 0 && offset < static_cast<double>(s.attack)) {
|
||||
return curveMap(offset / static_cast<double>(s.attack), attackCurve);
|
||||
}
|
||||
const double decayStart = static_cast<double>(s.total - s.decay);
|
||||
if (s.decay > 0 && offset >= decayStart) {
|
||||
double t = (offset - decayStart) / static_cast<double>(s.decay);
|
||||
if (t > 1.0) t = 1.0;
|
||||
return 1.0 - curveMap(t, decayCurve);
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Absorbs a step a live parameter move would otherwise put straight into an evaluator's
|
||||
// output, as an offset that decays to EXACTLY zero — so the at-rest path carries no residue
|
||||
// and the smoother's own branch stays predictably false. Per-frame decay rather than a
|
||||
@@ -152,8 +205,9 @@ private:
|
||||
switch (stage_) {
|
||||
case Stage::Attack: {
|
||||
if (params.attackFrames <= 0) return 1.0;
|
||||
const double l = stagePos_ / static_cast<double>(params.attackFrames);
|
||||
return l > 1.0 ? 1.0 : l;
|
||||
double l = stagePos_ / static_cast<double>(params.attackFrames);
|
||||
if (l > 1.0) l = 1.0;
|
||||
return curveMap(l, params.attackCurve);
|
||||
}
|
||||
case Stage::Hold:
|
||||
// A zero-length hold falls straight through to Decay on the next tick, whose
|
||||
@@ -162,16 +216,17 @@ private:
|
||||
return (params.decayFrames <= 0) ? params.sustainLevel : 1.0;
|
||||
case Stage::Decay: {
|
||||
if (params.decayFrames <= 0) return params.sustainLevel;
|
||||
const double t = stagePos_ / static_cast<double>(params.decayFrames);
|
||||
return 1.0 + (params.sustainLevel - 1.0) * t;
|
||||
double t = stagePos_ / static_cast<double>(params.decayFrames);
|
||||
if (t > 1.0) t = 1.0; // never bites on the un-edited path (transitions at >=)
|
||||
return 1.0 + (params.sustainLevel - 1.0) * curveMap(t, params.decayCurve);
|
||||
}
|
||||
case Stage::Sustain:
|
||||
return params.sustainLevel;
|
||||
case Stage::Release: {
|
||||
if (params.releaseFrames <= 0) return 0.0;
|
||||
const double t = stagePos_ / static_cast<double>(params.releaseFrames);
|
||||
const double l = releaseFrom_ * (1.0 - t);
|
||||
return l < 0.0 ? 0.0 : l;
|
||||
double t = stagePos_ / static_cast<double>(params.releaseFrames);
|
||||
if (t > 1.0) t = 1.0;
|
||||
return releaseFrom_ * (1.0 - curveMap(t, params.releaseCurve));
|
||||
}
|
||||
default:
|
||||
return 0.0;
|
||||
@@ -276,145 +331,146 @@ private:
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end).
|
||||
// A negative fade is treated as 0. When both fades together exceed the play length,
|
||||
// shrink the fade-out first (the head fade-in is the more perceptually load-bearing
|
||||
// onset ramp), then the fade-in — never letting either go negative or the sum exceed
|
||||
// the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
// The sustain-less AHD amplitude shape, evaluated at a source-frame offset into the span
|
||||
// rather than by ticking output frames: under Varispeed a transposed voice consumes source
|
||||
// faster than output, so driving the shape off the read position keeps every stage boundary on
|
||||
// the same source frames regardless of engine. Note-off-immune and time-boxed by the span.
|
||||
//
|
||||
// Positional means there is no phase counter to hold across a live edit, so the phi rule
|
||||
// AdsrEnvelope applies has nothing to act on here; a live reshape is a level step, absorbed by
|
||||
// the same bounded smoother.
|
||||
class AhdEnvelope {
|
||||
public:
|
||||
// `spanFrames` is the bound the stages are fitted into — (playEnd - startFrame) for the
|
||||
// Trigger amp and filter envelopes. A zero/negative span finishes immediately.
|
||||
void configure(std::int64_t spanFrames, const AhdParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
fit(params);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
// Peer of AdsrEnvelope::snapLive: a voice that has rendered nothing takes the new shape
|
||||
// outright, with no step to absorb.
|
||||
void snapLive(const AhdParams& params) {
|
||||
fit(params);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Live delivery to a sounding voice at its current `sourceOffset`. See the class note for
|
||||
// why this smooths rather than holding a normalized position.
|
||||
void applyLive(double sourceOffset, const AhdParams& params) {
|
||||
const double before = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
fit(params);
|
||||
const double after = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
// Amplitude at `sourceOffset` = (readPos - startFrame). Latches finished() at or past the
|
||||
// fitted total, which is what frees the voice.
|
||||
double amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on
|
||||
// readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
if (finished_ || sourceOffset >= static_cast<double>(fit_.total)) {
|
||||
if (sourceOffset >= static_cast<double>(fit_.total)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over
|
||||
// [playLength_-fadeOut_, playLength_). Unity between. The two ramps never overlap
|
||||
// (configure clamps fadeIn_ + fadeOut_ <= length). The offset is fractional (the read
|
||||
// head is fractional under repitch), so the ramps are smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_);
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
const double out = ahdLevelAt(sourceOffset, fit_, attackCurve_, decayCurve_);
|
||||
return smooth_.active() ? out + smooth_.advance() : out;
|
||||
}
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
const AhdSpan& stages() const { return fit_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
void fit(const AhdParams& p) {
|
||||
fit_ = fitAhd(span_, p);
|
||||
attackCurve_ = p.attackCurve;
|
||||
decayCurve_ = p.decayCurve;
|
||||
finished_ = (fit_.total <= 0);
|
||||
}
|
||||
|
||||
std::int64_t span_ = 0;
|
||||
AhdSpan fit_;
|
||||
double attackCurve_ = util::kCurveNeutral;
|
||||
double decayCurve_ = util::kCurveNeutral;
|
||||
bool finished_ = true;
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past the AHD),
|
||||
// advancing one frame. The voice converts it to a ratio multiply (Varispeed) or a shift-amount
|
||||
// add (Preserve). Unlike the amplitude AHD this owns its own position counter — pitch-envelope
|
||||
// time is wall-clock output frames — so the mid-stage rule applies in full.
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; }
|
||||
// `spanFrames` is the playable span the Hold fraction is taken against.
|
||||
void configure(std::int64_t spanFrames, const PitchEnvParams& params) {
|
||||
span_ = spanFrames > 0 ? spanFrames : 0;
|
||||
params_ = params;
|
||||
fit_ = fitAhd(span_, params.shape);
|
||||
pos_ = 0.0;
|
||||
}
|
||||
void noteOn() { pos_ = 0.0; smooth_.clear(); }
|
||||
|
||||
// Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice
|
||||
// that has rendered nothing takes the new times and depth outright.
|
||||
void snapLive(std::int64_t attackFrames, std::int64_t decayFrames, double peakSemitones) {
|
||||
params_.attackFrames = attackFrames;
|
||||
params_.decayFrames = decayFrames;
|
||||
params_.peakSemitones = peakSemitones;
|
||||
// that has rendered nothing takes the new shape and depth outright. `enabled` is a discrete
|
||||
// toggle travelling by reload, so the caller's copy of it is deliberately ignored.
|
||||
void snapLive(const PitchEnvParams& params) {
|
||||
params_.peakSemitones = params.peakSemitones;
|
||||
params_.shape = params.shape;
|
||||
fit_ = fitAhd(span_, params_.shape);
|
||||
smooth_.clear();
|
||||
}
|
||||
|
||||
// Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized
|
||||
// position within whichever leg the envelope is in, and absorb the depth step (peak is a
|
||||
// level, not a duration). `enabled` is a discrete toggle and travels by reload, so it is
|
||||
// deliberately not a parameter here.
|
||||
void applyLive(std::int64_t attackFrames, std::int64_t decayFrames,
|
||||
double peakSemitones) {
|
||||
const double before = offsetAt(params_);
|
||||
const double a = params_.attackFrames > 0 ? static_cast<double>(params_.attackFrames) : 0.0;
|
||||
const double d = params_.decayFrames > 0 ? static_cast<double>(params_.decayFrames) : 0.0;
|
||||
const double na = attackFrames > 0 ? static_cast<double>(attackFrames) : 0.0;
|
||||
const double nd = decayFrames > 0 ? static_cast<double>(decayFrames) : 0.0;
|
||||
if (pos_ < a) {
|
||||
pos_ = (na > 0.0) ? pos_ * (na / a) : na;
|
||||
} else if (pos_ < a + d) {
|
||||
pos_ = (nd > 0.0) ? na + (pos_ - a) * (nd / d) : na + nd;
|
||||
} else {
|
||||
pos_ = na + nd; // already past the envelope: stay past it under the new times
|
||||
}
|
||||
params_.attackFrames = attackFrames;
|
||||
params_.decayFrames = decayFrames;
|
||||
params_.peakSemitones = peakSemitones;
|
||||
const double after = offsetAt(params_);
|
||||
// level, not a duration).
|
||||
void applyLive(const PitchEnvParams& params) {
|
||||
const double before = offsetAt();
|
||||
const AhdSpan next = fitAhd(span_, params.shape);
|
||||
pos_ = holdPhase(fit_, next);
|
||||
params_.peakSemitones = params.peakSemitones;
|
||||
params_.shape = params.shape;
|
||||
fit_ = next;
|
||||
const double after = offsetAt();
|
||||
if (after != before) smooth_.absorb(before - after);
|
||||
}
|
||||
|
||||
double tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
const double offset = offsetAt(params_);
|
||||
const double offset = offsetAt();
|
||||
pos_ += 1.0;
|
||||
return smooth_.active() ? offset + smooth_.advance() : offset;
|
||||
}
|
||||
|
||||
private:
|
||||
// The semitone offset at the current position under `params` — the shared evaluator for
|
||||
// both tick() and applyLive's before/after comparison.
|
||||
double offsetAt(const PitchEnvParams& params) const {
|
||||
if (!params.enabled) return 0.0;
|
||||
const double a = params.attackFrames > 0 ? static_cast<double>(params.attackFrames) : 0.0;
|
||||
const double d = params.decayFrames > 0 ? static_cast<double>(params.decayFrames) : 0.0;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
return params.peakSemitones * (pos_ / a);
|
||||
// The semitone offset at the current position — the shared evaluator for both tick() and
|
||||
// applyLive's before/after comparison.
|
||||
double offsetAt() const {
|
||||
if (!params_.enabled) return 0.0;
|
||||
return params_.peakSemitones *
|
||||
ahdLevelAt(pos_, fit_, params_.shape.attackCurve, params_.shape.decayCurve);
|
||||
}
|
||||
|
||||
// The position under `next` holding the normalized position within whichever leg pos_ is
|
||||
// in. A leg dialled to zero completes: the position lands on that leg's new end.
|
||||
double holdPhase(const AhdSpan& old, const AhdSpan& next) const {
|
||||
const double oa = static_cast<double>(old.attack);
|
||||
const double oh = static_cast<double>(old.hold);
|
||||
const double od = static_cast<double>(old.decay);
|
||||
const double na = static_cast<double>(next.attack);
|
||||
const double nh = static_cast<double>(next.hold);
|
||||
const double nd = static_cast<double>(next.decay);
|
||||
if (pos_ < oa) return (na > 0.0) ? pos_ * (na / oa) : na;
|
||||
if (pos_ < oa + oh) return (nh > 0.0) ? na + (pos_ - oa) * (nh / oh) : na + nh;
|
||||
if (pos_ < oa + oh + od) {
|
||||
return (nd > 0.0) ? na + nh + (pos_ - oa - oh) * (nd / od) : na + nh + nd;
|
||||
}
|
||||
if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
return params.peakSemitones * (1.0 - (pos_ - a) / d);
|
||||
}
|
||||
return 0.0; // past attack+decay: at base pitch forever.
|
||||
return na + nh + nd; // already past the envelope: stay past it under the new shape
|
||||
}
|
||||
|
||||
PitchEnvParams params_;
|
||||
std::int64_t span_ = 0;
|
||||
AhdSpan fit_;
|
||||
double pos_ = 0.0;
|
||||
StepSmoother smooth_;
|
||||
};
|
||||
|
||||
@@ -11,10 +11,10 @@ LiveValues foldLive(const PlayParams& params) {
|
||||
v.filterModAmount = params.filter.modAmount;
|
||||
v.filterKeyTrack = params.filter.keyTrack;
|
||||
v.filterEnv = params.filter.env;
|
||||
v.filterAhd = params.filter.trigEnv;
|
||||
v.adsr = params.adsr;
|
||||
v.pitchEnvAttackFrames = params.pitchEnv.attackFrames;
|
||||
v.pitchEnvDecayFrames = params.pitchEnv.decayFrames;
|
||||
v.pitchEnvPeakSemitones = params.pitchEnv.peakSemitones;
|
||||
v.ampAhd = params.trigAhd;
|
||||
v.pitchEnv = params.pitchEnv;
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,19 @@ inline constexpr double kLiveRampSeconds = 0.020;
|
||||
// morphLaw rides inside filterSettings only because it is cheaper to carry the whole struct to
|
||||
// the filter's prepare() than to splice it back; it changes only across a reload, which
|
||||
// republishes this block, so the two can never disagree.
|
||||
// Each envelope carries BOTH mode shapes: which one a voice applies is fixed at note-on by
|
||||
// its play mode, so publishing both keeps the block one shape regardless of mode. The pitch
|
||||
// envelope's `enabled` rides along inside its params only because the struct is carried whole;
|
||||
// PitchEnvelope ignores it, since a toggle travels by reload.
|
||||
struct LiveValues {
|
||||
filter::FilterSettings filterSettings{};
|
||||
double filterModAmount = 0.0;
|
||||
double filterKeyTrack = 0.0;
|
||||
AdsrParams filterEnv{};
|
||||
AhdParams filterAhd{};
|
||||
AdsrParams adsr{};
|
||||
std::int64_t pitchEnvAttackFrames = 0;
|
||||
std::int64_t pitchEnvDecayFrames = 0;
|
||||
double pitchEnvPeakSemitones = 0.0;
|
||||
AhdParams ampAhd{};
|
||||
PitchEnvParams pitchEnv{};
|
||||
};
|
||||
|
||||
// The seqlock copies the block as raw bytes, which is only defensible for a plain value type.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/filter/voice_filter.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
#include "core/util/curve_law.h" // the per-segment curve exponent domain + its neutral
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
@@ -39,34 +40,44 @@ inline constexpr int kMaxVoiceCount = 32;
|
||||
inline constexpr int kDefaultVoiceCount = 16;
|
||||
|
||||
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
|
||||
// The three curve exponents shape the SLOPED stages only — Hold and Sustain are flat by
|
||||
// definition and carry none. `curve_law.h` owns what an exponent means.
|
||||
struct AdsrParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t holdFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double sustainLevel = 1.0; // 0..1
|
||||
std::int64_t releaseFrames = 0;
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
double releaseCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// Attack -> Hold -> Decay over a bounded span: the shape every SUSTAIN-LESS envelope takes
|
||||
// (the Trigger amp, the Trigger filter envelope, the pitch envelope). Hold is a FRACTION of
|
||||
// the span left after attack and decay, never a time of its own — that is what makes
|
||||
// A + H + D <= span structural rather than clamped (see fitAhd in envelopes.h).
|
||||
struct AhdParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double holdFraction = 1.0; // 0..1 of the span remaining after attack + decay
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
|
||||
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
|
||||
// honor the start point. Default Gate so an instrument with no params set plays as before.
|
||||
// note-off-immune, no sustain loop, plays a % of sample length shaped by the AHD. Both honor
|
||||
// the start point. Default Gate so an instrument with no params set plays as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Playback covers [startFrame, playEnd), playEnd = startFrame +
|
||||
// round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the
|
||||
// head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so
|
||||
// fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd.
|
||||
// Trigger's play SPAN: [startFrame, playEnd), playEnd = startFrame +
|
||||
// round(lengthFraction*(frames - startFrame)). The voice frees when the head reaches playEnd.
|
||||
// The amplitude SHAPE over that span is PlayParams::trigAhd — the fade-in/fade-out pair that
|
||||
// used to live here is retired; do not reintroduce a second amplitude mechanism.
|
||||
struct TriggerParams {
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
||||
std::int64_t fadeInFrames = 0;
|
||||
std::int64_t fadeOutFrames = 0;
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
||||
};
|
||||
|
||||
// EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
|
||||
// the build-time residual.
|
||||
enum class FadeCurve { EqualPower, Linear };
|
||||
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
|
||||
// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
|
||||
// PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
|
||||
// (an octave up keeps its length).
|
||||
@@ -83,14 +94,15 @@ inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
// of real source, so output frame 0 is source frame 0 regardless of window size.
|
||||
inline constexpr double kPreserveWindowMs = 50.0;
|
||||
|
||||
// AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
|
||||
// to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
|
||||
// then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
|
||||
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
|
||||
// bit-identical to the un-modulated engine). At note-on the offset rises to peakSemitones over
|
||||
// attack, holds there, then falls to 0 over decay; a zero attack gives a pure percussive pitch
|
||||
// drop. The hold fraction defaults to 0 so an instance predating the stage plays exactly as its
|
||||
// attack-decay predecessor did.
|
||||
struct PitchEnvParams {
|
||||
bool enabled = false;
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
bool enabled = false;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
AhdParams shape{0, 0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
};
|
||||
|
||||
// Per-voice resonant filter, off by default (enabled=false -> the render path skips it
|
||||
@@ -106,7 +118,11 @@ struct FilterParams {
|
||||
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
|
||||
double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff
|
||||
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
||||
AdsrParams env; // the same staged AHDSR the amp runs; frames
|
||||
// The filter envelope takes the same shape the amp does under the active play mode:
|
||||
// AHDSR in Gate, AHD in Trigger. Both are stored, so a mode flip cannot lose either
|
||||
// mode's dialled values (see core/instrument/CLAUDE.md).
|
||||
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
|
||||
AhdParams trigEnv; // Trigger: the same staged AHD the amp runs; frames
|
||||
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
|
||||
// because a flat curve under a depth control would make every velocity the same offset;
|
||||
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
|
||||
@@ -121,8 +137,9 @@ struct FilterParams {
|
||||
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||
struct PlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr;
|
||||
TriggerParams trigger;
|
||||
AdsrParams adsr; // Gate amp
|
||||
TriggerParams trigger; // Trigger play span
|
||||
AhdParams trigAhd; // Trigger amp
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
PitchEnvParams pitchEnv;
|
||||
FilterParams filter;
|
||||
|
||||
@@ -64,11 +64,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
startFrame_ = start; // the span-offset origin: readPos - startFrame
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
|
||||
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
|
||||
// % play length.
|
||||
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
|
||||
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t trigSpan = 0;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
@@ -79,17 +80,18 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
static_cast<double>(postStart) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
if (playLen > postStart) playLen = postStart;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
trigSpan = playLen;
|
||||
ampAhd_.configure(playLen, p.trigAhd);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
|
||||
// stages lay 1:1 over the waveform from the start point.
|
||||
pitchEnv_.configure(postStart, p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// A restart lands every live glide back on the new note's own values, at a step derived
|
||||
@@ -119,8 +121,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
|
||||
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
|
||||
rDrive_.set(static_cast<double>(p.filter.settings.driveNorm));
|
||||
filterEnv_.configure(p.filter.env);
|
||||
filterEnv_.noteOn();
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
filterEnv_.configure(p.filter.env);
|
||||
filterEnv_.noteOn();
|
||||
} else {
|
||||
filterAhd_.configure(trigSpan, p.filter.trigEnv);
|
||||
}
|
||||
filter_.reset();
|
||||
updateFilterCutoffBase(note);
|
||||
// The note's ONE full solve — Q, morph and drive are constants for its lifetime unless
|
||||
@@ -196,25 +202,31 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
||||
}
|
||||
|
||||
void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
|
||||
// Gate's amplitude envelope is the AHDSR; Trigger's fade shape is anchored to a play span
|
||||
// resolved at note-on and travels by reload instead (deck_groups.h names why).
|
||||
// Each envelope applies only the shape its play mode selected at note-on; the block
|
||||
// carries both so the mode never changes what is published.
|
||||
//
|
||||
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
|
||||
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
|
||||
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
|
||||
const bool gate = (playMode_ == PlayMode::Gate);
|
||||
if (snap) {
|
||||
if (playMode_ == PlayMode::Gate) env_.snapLive(live.adsr);
|
||||
pitchEnv_.snapLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
|
||||
live.pitchEnvPeakSemitones);
|
||||
if (gate) env_.snapLive(live.adsr);
|
||||
else ampAhd_.snapLive(live.ampAhd);
|
||||
pitchEnv_.snapLive(live.pitchEnv);
|
||||
} else {
|
||||
if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr);
|
||||
pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames,
|
||||
live.pitchEnvPeakSemitones);
|
||||
if (gate) env_.applyLive(live.adsr);
|
||||
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
|
||||
pitchEnv_.applyLive(live.pitchEnv);
|
||||
}
|
||||
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
|
||||
|
||||
if (snap) filterEnv_.snapLive(live.filterEnv);
|
||||
else filterEnv_.applyLive(live.filterEnv);
|
||||
if (snap) {
|
||||
if (gate) filterEnv_.snapLive(live.filterEnv);
|
||||
else filterAhd_.snapLive(live.filterAhd);
|
||||
} else {
|
||||
if (gate) filterEnv_.applyLive(live.filterEnv);
|
||||
else filterAhd_.applyLive(sourceOffset(), live.filterAhd);
|
||||
}
|
||||
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
|
||||
filterKeyTrack_ = live.filterKeyTrack;
|
||||
filterSettings_.morphLaw = live.filterSettings.morphLaw;
|
||||
|
||||
@@ -60,7 +60,7 @@ inline double filterNormPerOctave() {
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
|
||||
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// amplitude was instantly ~1 (a zero-attack Trigger or Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
@@ -162,25 +162,26 @@ private:
|
||||
}
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
|
||||
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
|
||||
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
|
||||
// the voice.
|
||||
double tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Anchored to the source offset so fades land on the same source frames under
|
||||
// either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
|
||||
// advanceFrame; finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
amp = ampAhd_.amplitudeAt(sourceOffset());
|
||||
if (ampAhd_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// Frames into the Trigger play span at the current read head — the domain both
|
||||
// sustain-less envelopes are evaluated over.
|
||||
double sourceOffset() const { return readPos_ - static_cast<double>(startFrame_); }
|
||||
|
||||
// Advances the filter envelope and re-solves the corner from the modulated cutoff. The
|
||||
// solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
|
||||
// rather than staircasing. State preservation across the solve is voice_filter's own
|
||||
@@ -195,8 +196,13 @@ private:
|
||||
// through both so a moved base always re-solves.
|
||||
void tickFilterCutoff() {
|
||||
if (filterModAmount_ == 0.0 && filterSolved_) return;
|
||||
double cut = static_cast<double>(filterBaseCutoff_) +
|
||||
filterModAmount_ * filterEnv_.tick();
|
||||
// The filter envelope takes the amp's shape under the active mode — AHDSR in Gate,
|
||||
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
|
||||
// branch is perfectly predicted.
|
||||
const double envOut = (playMode_ == PlayMode::Gate)
|
||||
? filterEnv_.tick()
|
||||
: filterAhd_.amplitudeAt(sourceOffset());
|
||||
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
|
||||
if (cut < 0.0) cut = 0.0;
|
||||
if (cut > 1.0) cut = 1.0;
|
||||
const float cutNorm = static_cast<float>(cut);
|
||||
@@ -284,6 +290,22 @@ private:
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
// Rings the voice's last rendered output out instead of hard-cutting it when the read head
|
||||
// reaches the end of its span, on the PRESERVE path only. Varispeed's final sample is real
|
||||
// source content at its natural end and its stop is left byte-identical; Preserve's is
|
||||
// recycled synthetic tail (freezeTail stops the writer a full window before the read head
|
||||
// arrives), whose level bears no relation to the source's own ending — cutting it at
|
||||
// whatever amplitude the splice machinery happens to be at is the end-of-sample click.
|
||||
// Reuses the takeover blend so the boundary frame reproduces the last level exactly.
|
||||
void seedTerminalDeclick() {
|
||||
if (pitchEngine_ != PitchEngine::Preserve) return;
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickWeight_ = 1.0;
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
@@ -327,6 +349,7 @@ private:
|
||||
// byte-identical to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick();
|
||||
if (!declickActive_) seedTerminalDeclick();
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is
|
||||
// w*(ref − 0) == w*ref. The weight decays by kDeclickDecay each frame,
|
||||
@@ -350,6 +373,15 @@ private:
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
// Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE
|
||||
// the play span (a zero decay, which the shape deliberately keeps expressible) cuts the
|
||||
// same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which
|
||||
// still holds the PREVIOUS frame — this one is already silent. Gate is left out on
|
||||
// purpose: its amplitude reaches zero through a release, so there is no cut to ring out.
|
||||
if (amplitudeDone_ && amp == 0.0 && !declickActive_ &&
|
||||
playMode_ == PlayMode::Trigger) {
|
||||
seedTerminalDeclick();
|
||||
}
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
@@ -518,13 +550,13 @@ private:
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// Gate uses env_ (AHDSR); Trigger uses ampAhd_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
AhdEnvelope ampAhd_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; the span-offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
@@ -534,7 +566,8 @@ private:
|
||||
// solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
|
||||
// filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
|
||||
instrument::engine::filter::VoiceFilter filter_;
|
||||
AdsrEnvelope filterEnv_;
|
||||
AdsrEnvelope filterEnv_; // Gate
|
||||
AhdEnvelope filterAhd_; // Trigger
|
||||
bool filterOn_ = false;
|
||||
double filterRate_ = 0.0;
|
||||
double filterCutoffNorm_ = 1.0;
|
||||
|
||||
@@ -17,8 +17,10 @@ reasampler_test(bank_sync LINK bank_sync)
|
||||
# the voice engine: velocity_curve (the curve field) and master_gain (the wire gain cap) only.
|
||||
# play_params.h also pulls in filter/'s headers (FilterSettings, MorphLaw) for the v9 filter
|
||||
# tail -- plain value types, so no filter symbol is linked and this stays true.
|
||||
# Two TUs on the format's OWN seam: the envelope's version ladder and the payload's, which
|
||||
# the format already keeps on independent version axes (see component_state_io.h).
|
||||
reasampler_pure_library(component_state_io
|
||||
SOURCES component_state_io.cpp
|
||||
SOURCES component_state_io.cpp params_payload.cpp
|
||||
LINK PUBLIC velocity_curve master_gain)
|
||||
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
|
||||
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// component_state_io — the ComponentState envelope + params-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v9).
|
||||
// Every wire format is FROZEN — byte-identical across revisions.
|
||||
// component_state_io — the ComponentState ENVELOPE codec. See component_state_io.h for both
|
||||
// format ladders (envelope v1..v11, params payload v1..v10); the payload half lives in
|
||||
// params_payload, which grows on its own version axis. Every wire format is FROZEN —
|
||||
// byte-identical across revisions.
|
||||
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
|
||||
#include <algorithm> // std::min (bounded curve-point reserve)
|
||||
#include <cassert> // assert (v3-lift projectRate guard)
|
||||
#include <cmath> // std::isfinite (v8 master-gain validation)
|
||||
#include <cstring> // std::memcpy (serializeSelection)
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||
#include "core/instrument/map/params_payload.h" // the payload half of this codec
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
@@ -26,267 +26,6 @@ namespace {
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
|
||||
// payload that carried at least one zone: the first zone's capture, which supersedes the
|
||||
// envelope's selection id (see the adoption rule in the header).
|
||||
struct PayloadRead {
|
||||
InstrumentParams params;
|
||||
std::string adoptedSampleId;
|
||||
};
|
||||
|
||||
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||
// the two shapes cannot drift byte-for-byte.
|
||||
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
out.push_back(p.rootOverride ? 1 : 0);
|
||||
if (p.rootOverride) {
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
|
||||
}
|
||||
out.push_back(p.loopOverride ? 1 : 0);
|
||||
if (p.loopOverride) {
|
||||
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(p.loopOverride->start));
|
||||
putLE(out, asU64(p.loopOverride->end));
|
||||
}
|
||||
out.push_back(p.startPoint ? 1 : 0);
|
||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||
}
|
||||
|
||||
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
|
||||
// The amp curve (v7) and the filter's own curve (v9) share this shape.
|
||||
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||
const std::vector<VelocityPoint>& pts = curve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) {
|
||||
putLE(out, doubleToBits(pt.velocity));
|
||||
putLE(out, doubleToBits(pt.amp));
|
||||
}
|
||||
}
|
||||
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version (see component_state_io.h).
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
putLE(out, kParamsFormatMarker);
|
||||
putLE(out, kParamsPayloadVersion);
|
||||
putOverrides(out, p);
|
||||
|
||||
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
|
||||
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
|
||||
const PlaySeconds& pp = p.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// Key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(p.keyTrack));
|
||||
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
|
||||
// velocity + amp as doubles (endpoints included, so N >= 2).
|
||||
putCurve(out, p.velocityCurve);
|
||||
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
|
||||
// whole payload stays one numeric shape.
|
||||
const FilterSeconds& f = pp.filter;
|
||||
out.push_back(f.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
||||
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
||||
putLE(out, doubleToBits(f.modAmount));
|
||||
putLE(out, doubleToBits(f.velAmount));
|
||||
putLE(out, doubleToBits(f.keyTrack));
|
||||
putLE(out, doubleToBits(f.env.attackSeconds));
|
||||
putLE(out, doubleToBits(f.env.holdSeconds));
|
||||
putLE(out, doubleToBits(f.env.decaySeconds));
|
||||
putLE(out, doubleToBits(f.env.sustainLevel));
|
||||
putLE(out, doubleToBits(f.env.releaseSeconds));
|
||||
putCurve(out, f.velocityCurve);
|
||||
}
|
||||
|
||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||
// v8 single-record reader so the two can never disagree about field order.
|
||||
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
|
||||
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
|
||||
// defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||
// can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) {
|
||||
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
}
|
||||
|
||||
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
||||
// which is what makes a v8 blob play bit-identically under the new codec.
|
||||
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||
FilterSeconds& f = p.play.filter;
|
||||
f.enabled = (r.u8() != 0);
|
||||
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
||||
: engine::filter::MorphLaw::HighBandLow;
|
||||
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
|
||||
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
|
||||
double modAmount = bitsToDouble(r.u64());
|
||||
double velAmount = bitsToDouble(r.u64());
|
||||
double keyTrack = bitsToDouble(r.u64());
|
||||
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
||||
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
|
||||
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
||||
f.env.attackSeconds = bitsToDouble(r.u64());
|
||||
f.env.holdSeconds = bitsToDouble(r.u64());
|
||||
f.env.decaySeconds = bitsToDouble(r.u64());
|
||||
f.env.sustainLevel = bitsToDouble(r.u64());
|
||||
f.env.releaseSeconds = bitsToDouble(r.u64());
|
||||
readCurveTail(r, f.velocityCurve);
|
||||
}
|
||||
|
||||
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||
// no file and no bank entry.
|
||||
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
|
||||
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
|
||||
// blobs carry seconds directly and need no rate.
|
||||
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
|
||||
PayloadRead out;
|
||||
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
bool adopted = false;
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
|
||||
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
|
||||
InstrumentParams p;
|
||||
std::string sampleId;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
sampleId = r.str(idLen);
|
||||
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||
r.i32(); // highNote
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) p.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
|
||||
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
|
||||
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
readSecondsPlayTail(r, p);
|
||||
}
|
||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||
if (curveTail) readCurveTail(r, p.velocityCurve);
|
||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||
// leave the seconds product defaults on p.play.
|
||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||
if (!adopted) {
|
||||
out.params = std::move(p);
|
||||
out.adoptedSampleId = std::move(sampleId);
|
||||
adopted = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||
// v1 (a plain small zone count).
|
||||
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||
if (r.peekU32() == kParamsFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
}
|
||||
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||
|
||||
PayloadRead out;
|
||||
InstrumentParams& p = out.params;
|
||||
const std::uint8_t hasRoot = r.u8();
|
||||
if (hasRoot) p.rootOverride = r.i32();
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
readSecondsPlayTail(r, p);
|
||||
p.keyTrack = bitsToDouble(r.u64());
|
||||
readCurveTail(r, p.velocityCurve);
|
||||
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||
// the same degrade-don't-throw contract the zone ladder always had.
|
||||
if (!r.ok) return PayloadRead{};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
|
||||
// supersedes the envelope's selection id) lives here, once.
|
||||
void applyPayload(ComponentState& out, PayloadRead read) {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// own links are velocity_curve + master_gain (wire value validation), never the engine.
|
||||
//
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
|
||||
// payload v1..v8) must be preserved exactly.
|
||||
// payload v1..v10) must be preserved exactly. This header is the ONE home for both ladders
|
||||
// and every version constant; the payload half is IMPLEMENTED in params_payload.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
@@ -70,13 +71,25 @@ namespace reasampler::instrument::map {
|
||||
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
|
||||
// velocity curve (count + points) as in v7.
|
||||
//
|
||||
// v9 (CURRENT WRITE FORMAT) is v8 PLUS the per-voice filter tail, appended after the velocity
|
||||
// curve: 1 byte enabled; 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles,
|
||||
// widened from the module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte
|
||||
// LE modAmount, velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release
|
||||
// SECONDS; then the filter's OWN velocity curve (count + points, same shape as v7's). A v8
|
||||
// blob is a strict prefix, so it lifts to the off/neutral filter default and plays
|
||||
// bit-identically.
|
||||
// v9 is v8 PLUS the per-voice filter tail, appended after the velocity curve: 1 byte enabled;
|
||||
// 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles, widened from the
|
||||
// module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte LE modAmount,
|
||||
// velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release SECONDS; then
|
||||
// the filter's OWN velocity curve (count + points, same shape as v7's). A v8 blob is a strict
|
||||
// prefix, so it lifts to the off/neutral filter default and plays bit-identically.
|
||||
//
|
||||
// v10 (CURRENT WRITE FORMAT) is v9 PLUS the staged-curve tail, appended after the filter's
|
||||
// velocity curve, all 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve
|
||||
// exponents; the Trigger amp AHD (attack SECONDS, decay SECONDS, hold FRACTION, attack curve,
|
||||
// decay curve); the pitch envelope's hold FRACTION + attack/decay curve exponents; the filter
|
||||
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
|
||||
// the amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
|
||||
//
|
||||
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
|
||||
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
|
||||
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
|
||||
// seconds at the project rate the reader is handed. v10 writes ZERO into both — the values
|
||||
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
|
||||
//
|
||||
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
||||
|
||||
@@ -85,7 +98,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||
// a reader detects record shape independent of the envelope version.
|
||||
inline constexpr std::uint32_t kParamsPayloadVersion = 9; // v8 + the per-voice filter tail
|
||||
inline constexpr std::uint32_t kParamsPayloadVersion = 10; // v9 + the staged-curve tail
|
||||
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
|
||||
@@ -98,6 +111,9 @@ inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;
|
||||
// self-describing, mirroring the envelope's version constants.
|
||||
inline constexpr std::uint32_t kParamsFilterVersion = 9;
|
||||
|
||||
// v9 + the staged-curve tail (curve exponents, the Trigger AHDs, the pitch Hold fraction).
|
||||
inline constexpr std::uint32_t kParamsCurveVersion = 10;
|
||||
|
||||
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
|
||||
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
|
||||
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
// params_payload.cpp — see params_payload.h. The format ladder it implements is documented
|
||||
// in component_state_io.h; every wire format below is FROZEN.
|
||||
|
||||
#include "core/instrument/map/params_payload.h"
|
||||
|
||||
#include <algorithm> // std::min (bounded curve-point reserve)
|
||||
#include <cassert> // assert (v3-lift projectRate guard)
|
||||
#include <cmath> // std::isfinite (wire-value validation)
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/util/curve_law.h" // clampCurve / kCurveNeutral (wire validation)
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using reasampler::wire::ByteReader;
|
||||
using reasampler::wire::bitsToDouble;
|
||||
using reasampler::wire::doubleToBits;
|
||||
using reasampler::wire::putLE;
|
||||
|
||||
namespace {
|
||||
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||
// the two shapes cannot drift byte-for-byte.
|
||||
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
out.push_back(p.rootOverride ? 1 : 0);
|
||||
if (p.rootOverride) {
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
|
||||
}
|
||||
out.push_back(p.loopOverride ? 1 : 0);
|
||||
if (p.loopOverride) {
|
||||
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(p.loopOverride->start));
|
||||
putLE(out, asU64(p.loopOverride->end));
|
||||
}
|
||||
out.push_back(p.startPoint ? 1 : 0);
|
||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||
}
|
||||
|
||||
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
|
||||
// The amp curve (v7) and the filter's own curve (v9) share this shape.
|
||||
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||
const std::vector<VelocityPoint>& pts = curve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) {
|
||||
putLE(out, doubleToBits(pt.velocity));
|
||||
putLE(out, doubleToBits(pt.amp));
|
||||
}
|
||||
}
|
||||
|
||||
// A stored AHD's five doubles, in one order shared by every AHD on the wire.
|
||||
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
|
||||
putLE(out, doubleToBits(a.attackSeconds));
|
||||
putLE(out, doubleToBits(a.decaySeconds));
|
||||
putLE(out, doubleToBits(a.holdFraction));
|
||||
putLE(out, doubleToBits(a.attackCurve));
|
||||
putLE(out, doubleToBits(a.decayCurve));
|
||||
}
|
||||
// THE lift of the retired Trigger fade pair onto the AHD that replaced it: Attack takes the
|
||||
// fade-in, Decay the fade-out, Hold the whole remainder — so a zero fade-out lands Decay = 0
|
||||
// and the abrupt end an old instance could express stays representable. The fades were SOURCE
|
||||
// frames and the AHD stores wall-clock seconds, so the conversion goes through the same
|
||||
// project rate the v3 lift already uses. A v10-or-newer blob overwrites this from its own tail.
|
||||
void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, double projectRate,
|
||||
AhdSeconds& out) {
|
||||
const double rate = projectRate > 0.0 ? projectRate : 1.0;
|
||||
out.attackSeconds = static_cast<double>(fadeInFrames > 0 ? fadeInFrames : 0) / rate;
|
||||
out.decaySeconds = static_cast<double>(fadeOutFrames > 0 ? fadeOutFrames : 0) / rate;
|
||||
out.holdFraction = 1.0;
|
||||
}
|
||||
|
||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||
// v8 single-record reader so the two can never disagree about field order.
|
||||
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate) {
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
const std::int64_t fadeIn = r.i64();
|
||||
const std::int64_t fadeOut = r.i64();
|
||||
liftTriggerFades(fadeIn, fadeOut, projectRate, p.play.trigAhd);
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.shape.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.shape.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
|
||||
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
|
||||
// defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||
// can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) {
|
||||
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
}
|
||||
|
||||
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
||||
// which is what makes a v8 blob play bit-identically under the new codec.
|
||||
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||
FilterSeconds& f = p.play.filter;
|
||||
f.enabled = (r.u8() != 0);
|
||||
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
||||
: engine::filter::MorphLaw::HighBandLow;
|
||||
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
|
||||
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
|
||||
double modAmount = bitsToDouble(r.u64());
|
||||
double velAmount = bitsToDouble(r.u64());
|
||||
double keyTrack = bitsToDouble(r.u64());
|
||||
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
||||
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
|
||||
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
||||
f.env.attackSeconds = bitsToDouble(r.u64());
|
||||
f.env.holdSeconds = bitsToDouble(r.u64());
|
||||
f.env.decaySeconds = bitsToDouble(r.u64());
|
||||
f.env.sustainLevel = bitsToDouble(r.u64());
|
||||
f.env.releaseSeconds = bitsToDouble(r.u64());
|
||||
readCurveTail(r, f.velocityCurve);
|
||||
}
|
||||
|
||||
// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
|
||||
// rather than to an endpoint: neutral is the one exponent that cannot change how a stage
|
||||
// sounds, so a damaged blob loses the shaping instead of inventing one.
|
||||
double readCurveExponent(ByteReader& r) {
|
||||
const double v = bitsToDouble(r.u64());
|
||||
return std::isfinite(v) ? reasampler::util::clampCurve(v) : reasampler::util::kCurveNeutral;
|
||||
}
|
||||
|
||||
void readAhd(ByteReader& r, AhdSeconds& a) {
|
||||
a.attackSeconds = bitsToDouble(r.u64());
|
||||
a.decaySeconds = bitsToDouble(r.u64());
|
||||
const double frac = bitsToDouble(r.u64());
|
||||
a.holdFraction = std::isfinite(frac) ? frac : 0.0;
|
||||
a.attackCurve = readCurveExponent(r);
|
||||
a.decayCurve = readCurveExponent(r);
|
||||
}
|
||||
|
||||
// Read the v10 staged-curve tail into `p`. A blob that stops short leaves the neutral
|
||||
// exponents and the fade-lifted Trigger AHD, which is what makes a v9 blob play as before.
|
||||
void readCurveStageTail(ByteReader& r, InstrumentParams& p) {
|
||||
PlaySeconds& pp = p.play;
|
||||
pp.adsr.attackCurve = readCurveExponent(r);
|
||||
pp.adsr.decayCurve = readCurveExponent(r);
|
||||
pp.adsr.releaseCurve = readCurveExponent(r);
|
||||
readAhd(r, pp.trigAhd);
|
||||
const double pitchHold = bitsToDouble(r.u64());
|
||||
pp.pitchEnv.shape.holdFraction = std::isfinite(pitchHold) ? pitchHold : 0.0;
|
||||
pp.pitchEnv.shape.attackCurve = readCurveExponent(r);
|
||||
pp.pitchEnv.shape.decayCurve = readCurveExponent(r);
|
||||
pp.filter.env.attackCurve = readCurveExponent(r);
|
||||
pp.filter.env.decayCurve = readCurveExponent(r);
|
||||
pp.filter.env.releaseCurve = readCurveExponent(r);
|
||||
readAhd(r, pp.filter.trigEnv);
|
||||
}
|
||||
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||
// no file and no bank entry.
|
||||
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
|
||||
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
|
||||
// blobs carry seconds directly and need no rate.
|
||||
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
|
||||
PayloadRead out;
|
||||
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
bool adopted = false;
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
|
||||
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
|
||||
InstrumentParams p;
|
||||
std::string sampleId;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
sampleId = r.str(idLen);
|
||||
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||
r.i32(); // highNote
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) p.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
|
||||
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
|
||||
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
const std::int64_t fadeIn = r.i64();
|
||||
const std::int64_t fadeOut = r.i64();
|
||||
liftTriggerFades(fadeIn, fadeOut, liftRate, p.play.trigAhd);
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.shape.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.shape.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
readSecondsPlayTail(r, p, projectRate);
|
||||
}
|
||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||
if (curveTail) readCurveTail(r, p.velocityCurve);
|
||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||
// leave the seconds product defaults on p.play.
|
||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||
if (!adopted) {
|
||||
out.params = std::move(p);
|
||||
out.adoptedSampleId = std::move(sampleId);
|
||||
adopted = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version (see component_state_io.h).
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
putLE(out, kParamsFormatMarker);
|
||||
putLE(out, kParamsPayloadVersion);
|
||||
putOverrides(out, p);
|
||||
|
||||
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
|
||||
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
|
||||
const PlaySeconds& pp = p.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
// The retired fade pair's two frozen slots (see the header): the shape stays, the values
|
||||
// moved into the Trigger AHD tail below.
|
||||
putLE(out, asU64(std::int64_t{0}));
|
||||
putLE(out, asU64(std::int64_t{0}));
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// Key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(p.keyTrack));
|
||||
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
|
||||
// velocity + amp as doubles (endpoints included, so N >= 2).
|
||||
putCurve(out, p.velocityCurve);
|
||||
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
|
||||
// whole payload stays one numeric shape.
|
||||
const FilterSeconds& f = pp.filter;
|
||||
out.push_back(f.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
|
||||
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
||||
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
||||
putLE(out, doubleToBits(f.modAmount));
|
||||
putLE(out, doubleToBits(f.velAmount));
|
||||
putLE(out, doubleToBits(f.keyTrack));
|
||||
putLE(out, doubleToBits(f.env.attackSeconds));
|
||||
putLE(out, doubleToBits(f.env.holdSeconds));
|
||||
putLE(out, doubleToBits(f.env.decaySeconds));
|
||||
putLE(out, doubleToBits(f.env.sustainLevel));
|
||||
putLE(out, doubleToBits(f.env.releaseSeconds));
|
||||
putCurve(out, f.velocityCurve);
|
||||
// v10: the staged-curve tail.
|
||||
putLE(out, doubleToBits(pp.adsr.attackCurve));
|
||||
putLE(out, doubleToBits(pp.adsr.decayCurve));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseCurve));
|
||||
putAhd(out, pp.trigAhd);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.holdFraction));
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.attackCurve));
|
||||
putLE(out, doubleToBits(pp.pitchEnv.shape.decayCurve));
|
||||
putLE(out, doubleToBits(f.env.attackCurve));
|
||||
putLE(out, doubleToBits(f.env.decayCurve));
|
||||
putLE(out, doubleToBits(f.env.releaseCurve));
|
||||
putAhd(out, f.trigEnv);
|
||||
}
|
||||
|
||||
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||
// v1 (a plain small zone count).
|
||||
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||
if (r.peekU32() == kParamsFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
}
|
||||
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||
|
||||
PayloadRead out;
|
||||
InstrumentParams& p = out.params;
|
||||
const std::uint8_t hasRoot = r.u8();
|
||||
if (hasRoot) p.rootOverride = r.i32();
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
readSecondsPlayTail(r, p, projectRate);
|
||||
p.keyTrack = bitsToDouble(r.u64());
|
||||
readCurveTail(r, p.velocityCurve);
|
||||
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
|
||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||
// the same degrade-don't-throw contract the zone ladder always had.
|
||||
if (!r.ok) return PayloadRead{};
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
// params_payload — the params-payload half of the ComponentState codec, split from the
|
||||
// ENVELOPE half on the axis the format itself already has: the payload carries its OWN
|
||||
// version and grows independently of the envelope's, so the two version ladders are two
|
||||
// responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay
|
||||
// serialize/deserializeComponentState; nothing outside the codec calls these.
|
||||
//
|
||||
// The format ladder (payload v1..v10) is documented in component_state_io.h, which stays its
|
||||
// one home. EVERY wire format is FROZEN.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// The payload's version constants and the prose ladder stay in component_state_io.h, their
|
||||
// one home — this half implements them rather than re-declaring them.
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
#include "core/wire/bytes.h" // ByteReader
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
|
||||
// payload that carried at least one zone: the first zone's capture, which supersedes the
|
||||
// envelope's selection id (see the adoption rule in component_state_io.h).
|
||||
struct PayloadRead {
|
||||
InstrumentParams params;
|
||||
std::string adoptedSampleId;
|
||||
};
|
||||
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version.
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p);
|
||||
|
||||
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||
// v1 (a plain small zone count). `projectRate` converts the LEGACY v3 wall-clock frame counts
|
||||
// and the retired Trigger fade pair to the seconds domain at the read boundary.
|
||||
PayloadRead readParamsPayload(reasampler::wire::ByteReader& r, double projectRate);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -212,6 +212,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
|
||||
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
|
||||
AhdParams a;
|
||||
a.attackFrames = secToFrames(s.attackSeconds);
|
||||
a.decayFrames = secToFrames(s.decaySeconds);
|
||||
a.holdFraction = s.holdFraction;
|
||||
a.attackCurve = s.attackCurve;
|
||||
a.decayCurve = s.decayCurve;
|
||||
return a;
|
||||
};
|
||||
PlayParams out;
|
||||
out.playMode = stored.playMode;
|
||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||
@@ -219,12 +229,15 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
|
||||
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
|
||||
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
|
||||
out.trigger = stored.trigger; // source-frame / fraction, unchanged
|
||||
out.adsr.attackCurve = stored.adsr.attackCurve; // dimensionless
|
||||
out.adsr.decayCurve = stored.adsr.decayCurve;
|
||||
out.adsr.releaseCurve = stored.adsr.releaseCurve;
|
||||
out.trigger = stored.trigger; // fraction, unchanged
|
||||
out.trigAhd = resolveAhd(stored.trigAhd);
|
||||
out.pitchEngine = stored.pitchEngine;
|
||||
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
||||
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
|
||||
// Filter: the control positions are already rate-free and carry through untouched; only
|
||||
// its envelope resolves to frames.
|
||||
out.filter.enabled = stored.filter.enabled;
|
||||
@@ -238,6 +251,10 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds);
|
||||
out.filter.env.sustainLevel = stored.filter.env.sustainLevel;
|
||||
out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds);
|
||||
out.filter.env.attackCurve = stored.filter.env.attackCurve;
|
||||
out.filter.env.decayCurve = stored.filter.env.decayCurve;
|
||||
out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
|
||||
out.filter.trigEnv = resolveAhd(stored.filter.trigEnv);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -143,21 +143,35 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
|
||||
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
|
||||
// (TriggerParams reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time; the
|
||||
// three curve exponents are dimensionless too (curve_law.h owns their domain).
|
||||
struct AdsrSeconds {
|
||||
double attackSeconds = 0.003; // tier-0 default
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060; // tier-0 default
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
double releaseCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
|
||||
struct PitchEnvSeconds {
|
||||
bool enabled = false;
|
||||
// The stored sustain-less AHD: wall-clock stage times in SECONDS, Hold as a FRACTION of the
|
||||
// span left after them (AhdParams owns why a fraction, not a time).
|
||||
struct AhdSeconds {
|
||||
double attackSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
double holdFraction = 1.0;
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// The stored AHD pitch envelope. enabled + peakSemitones are dimensionless. The hold fraction
|
||||
// defaults to 0 so an instance predating the stage plays as its attack-decay predecessor did.
|
||||
struct PitchEnvSeconds {
|
||||
bool enabled = false;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
AhdSeconds shape{0.0, 0.0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
};
|
||||
|
||||
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field
|
||||
@@ -171,7 +185,8 @@ struct FilterSeconds {
|
||||
double modAmount = 0.0;
|
||||
double velAmount = 0.0;
|
||||
double keyTrack = 0.0;
|
||||
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0};
|
||||
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
|
||||
AhdSeconds trigEnv; // Trigger
|
||||
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||
};
|
||||
|
||||
@@ -180,10 +195,11 @@ struct FilterSeconds {
|
||||
// from the engine-facing PlayParams (frames).
|
||||
struct PlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||
AdsrSeconds adsr; // Gate amp: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger play span (%-length)
|
||||
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
|
||||
FilterSeconds filter; // per-voice filter, off by default
|
||||
};
|
||||
|
||||
|
||||
@@ -14,14 +14,4 @@ std::int64_t triggerPlayLength(double lengthFraction,
|
||||
return static_cast<std::int64_t>(lengthFraction * static_cast<double>(postStart) + 0.5);
|
||||
}
|
||||
|
||||
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) {
|
||||
if (playLength <= 0) return 0.0;
|
||||
return static_cast<double>(fadeFrames) / static_cast<double>(playLength);
|
||||
}
|
||||
|
||||
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) {
|
||||
if (playLength <= 0) return 0;
|
||||
return static_cast<std::int64_t>(fadeFraction * static_cast<double>(playLength) + 0.5);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
|
||||
// SOURCE FRAMES, anchored to the source-timeline read pointer) and the overlay domain
|
||||
// (AmpEnvelope: FRACTIONS in [0,1] of the played span, so the drawn shape stays invariant
|
||||
// across sample-rate changes). Owns the one shared pack/unpack formula so both directions
|
||||
// stay consistent; reasampler_editor calls these from packEnvelope/unpackEnvelope.
|
||||
// trigger_seam — the shared Trigger play-span formula: how the stored %-length becomes the
|
||||
// source-frame span the voice plays and the overlay draws over. One home so the engine's
|
||||
// note-on resolve and the editor's overlay pack cannot disagree about where a Trigger note
|
||||
// ends.
|
||||
//
|
||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
||||
|
||||
@@ -19,11 +18,4 @@ std::int64_t triggerPlayLength(double lengthFraction,
|
||||
std::int64_t frameCount,
|
||||
std::int64_t startFrame);
|
||||
|
||||
// PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
|
||||
// caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
|
||||
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
|
||||
|
||||
// UNPACK direction (commit path): fraction -> nearest source frame.
|
||||
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -15,6 +15,7 @@ double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1
|
||||
double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; }
|
||||
|
||||
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
const bool trigger = (playMode == PlayMode::Trigger);
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc pitch;
|
||||
@@ -28,8 +29,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
DeckGroupDesc penv;
|
||||
penv.id = kGroupPitchEnv;
|
||||
penv.captionWidth = 58;
|
||||
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
|
||||
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
|
||||
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
|
||||
id(DeckParam::kPitchEnvHold),
|
||||
id(DeckParam::kPitchEnvDecay),
|
||||
id(DeckParam::kPitchEnvDepth)};
|
||||
out.push_back(std::move(penv));
|
||||
@@ -54,27 +57,35 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
DeckGroupDesc fenv;
|
||||
fenv.id = kGroupFilterEnv;
|
||||
fenv.captionWidth = 66;
|
||||
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
|
||||
id(DeckParam::kFilterEnvHold),
|
||||
id(DeckParam::kFilterEnvDecay),
|
||||
id(DeckParam::kFilterEnvSustain),
|
||||
id(DeckParam::kFilterEnvRelease)};
|
||||
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
|
||||
if (trigger) {
|
||||
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
|
||||
id(DeckParam::kFilterTrigDecay), -1, -1};
|
||||
} else {
|
||||
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
|
||||
id(DeckParam::kFilterEnvHold),
|
||||
id(DeckParam::kFilterEnvDecay),
|
||||
id(DeckParam::kFilterEnvSustain),
|
||||
id(DeckParam::kFilterEnvRelease)};
|
||||
}
|
||||
out.push_back(std::move(fenv));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
amp.id = kGroupAmpEnv;
|
||||
amp.captionWidth = 78;
|
||||
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
|
||||
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
|
||||
if (playMode == PlayMode::Gate) {
|
||||
if (trigger) {
|
||||
// The play span first, then the AHD that shapes it, time-ordered left-to-right so
|
||||
// the row reads like the drawn envelope. One blank keeps the group's width — and
|
||||
// therefore its neighbours' placement — identical across a mode flip.
|
||||
amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack),
|
||||
id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1};
|
||||
} else {
|
||||
amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold),
|
||||
id(DeckParam::kDecay), id(DeckParam::kSustain),
|
||||
id(DeckParam::kRelease)};
|
||||
} else {
|
||||
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
||||
// the drawn envelope), plus two blanks (see knob_deck.h's blank-cell contract).
|
||||
amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength),
|
||||
id(DeckParam::kTrigFadeOut), -1, -1};
|
||||
}
|
||||
out.push_back(std::move(amp));
|
||||
}
|
||||
@@ -97,6 +108,24 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||
return out;
|
||||
}
|
||||
|
||||
DeckParam curveParamFor(DeckParam knob) {
|
||||
switch (knob) {
|
||||
case DeckParam::kAttack: return DeckParam::kAttackCurve;
|
||||
case DeckParam::kDecay: return DeckParam::kDecayCurve;
|
||||
case DeckParam::kRelease: return DeckParam::kReleaseCurve;
|
||||
case DeckParam::kTrigAttack: return DeckParam::kTrigAttackCurve;
|
||||
case DeckParam::kTrigDecay: return DeckParam::kTrigDecayCurve;
|
||||
case DeckParam::kPitchEnvAttack: return DeckParam::kPitchEnvAttackCurve;
|
||||
case DeckParam::kPitchEnvDecay: return DeckParam::kPitchEnvDecayCurve;
|
||||
case DeckParam::kFilterEnvAttack: return DeckParam::kFilterEnvAttackCurve;
|
||||
case DeckParam::kFilterEnvDecay: return DeckParam::kFilterEnvDecayCurve;
|
||||
case DeckParam::kFilterEnvRelease: return DeckParam::kFilterEnvReleaseCurve;
|
||||
case DeckParam::kFilterTrigAttack: return DeckParam::kFilterTrigAttackCurve;
|
||||
case DeckParam::kFilterTrigDecay: return DeckParam::kFilterTrigDecayCurve;
|
||||
default: return DeckParam::kCount;
|
||||
}
|
||||
}
|
||||
|
||||
bool isLiveDeckParam(DeckParam id) {
|
||||
switch (id) {
|
||||
case DeckParam::kAttack:
|
||||
@@ -104,7 +133,11 @@ bool isLiveDeckParam(DeckParam id) {
|
||||
case DeckParam::kDecay:
|
||||
case DeckParam::kSustain:
|
||||
case DeckParam::kRelease:
|
||||
case DeckParam::kTrigAttack:
|
||||
case DeckParam::kTrigHold:
|
||||
case DeckParam::kTrigDecay:
|
||||
case DeckParam::kPitchEnvAttack:
|
||||
case DeckParam::kPitchEnvHold:
|
||||
case DeckParam::kPitchEnvDecay:
|
||||
case DeckParam::kPitchEnvDepth:
|
||||
case DeckParam::kFilterMorph:
|
||||
@@ -118,6 +151,21 @@ bool isLiveDeckParam(DeckParam id) {
|
||||
case DeckParam::kFilterEnvDecay:
|
||||
case DeckParam::kFilterEnvSustain:
|
||||
case DeckParam::kFilterEnvRelease:
|
||||
case DeckParam::kFilterTrigAttack:
|
||||
case DeckParam::kFilterTrigHold:
|
||||
case DeckParam::kFilterTrigDecay:
|
||||
case DeckParam::kAttackCurve:
|
||||
case DeckParam::kDecayCurve:
|
||||
case DeckParam::kReleaseCurve:
|
||||
case DeckParam::kTrigAttackCurve:
|
||||
case DeckParam::kTrigDecayCurve:
|
||||
case DeckParam::kPitchEnvAttackCurve:
|
||||
case DeckParam::kPitchEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvAttackCurve:
|
||||
case DeckParam::kFilterEnvDecayCurve:
|
||||
case DeckParam::kFilterEnvReleaseCurve:
|
||||
case DeckParam::kFilterTrigAttackCurve:
|
||||
case DeckParam::kFilterTrigDecayCurve:
|
||||
return true;
|
||||
// Listed rather than defaulted so a newly added control is a COMPILE error here (the
|
||||
// -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level)
|
||||
@@ -125,13 +173,14 @@ bool isLiveDeckParam(DeckParam id) {
|
||||
case DeckParam::kPlayMode:
|
||||
case DeckParam::kPitchEngine:
|
||||
case DeckParam::kTrigLength:
|
||||
case DeckParam::kTrigFadeIn:
|
||||
case DeckParam::kTrigFadeOut:
|
||||
case DeckParam::kPitchEnvEnable:
|
||||
case DeckParam::kKeyTrack:
|
||||
case DeckParam::kFilterEnable:
|
||||
case DeckParam::kFilterVel:
|
||||
case DeckParam::kFilterLaw:
|
||||
case DeckParam::kAmpEnvSelect:
|
||||
case DeckParam::kPitchEnvSelect:
|
||||
case DeckParam::kFilterEnvSelect:
|
||||
case DeckParam::kVoiceCount:
|
||||
case DeckParam::kVoiceMode:
|
||||
case DeckParam::kMonoTrigger:
|
||||
@@ -142,13 +191,13 @@ bool isLiveDeckParam(DeckParam id) {
|
||||
return false; // unreachable for a valid enumerator; silences a warning.
|
||||
}
|
||||
|
||||
bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode) {
|
||||
bool liveCommitFor(LiveDragKind kind, int paramId) {
|
||||
switch (kind) {
|
||||
case LiveDragKind::kDeckKnob:
|
||||
return paramId >= 0 && paramId < static_cast<int>(DeckParam::kCount) &&
|
||||
isLiveDeckParam(static_cast<DeckParam>(paramId));
|
||||
case LiveDragKind::kEnvNode:
|
||||
return playMode == PlayMode::Gate;
|
||||
return true;
|
||||
case LiveDragKind::kOther:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/engine/play_params.h" // PlayMode (the AMP group's Gate/Trigger face)
|
||||
#include "core/instrument/engine/play_params.h" // PlayMode (the mode-dependent group faces)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
@@ -17,18 +17,20 @@ namespace reasampler::instrument::ui {
|
||||
enum class DeckParam {
|
||||
kPlayMode = 0, // Gate | Trigger toggle
|
||||
kPitchEngine, // Varispeed | Preserve toggle
|
||||
kAttack, // AHDSR attack (Gate) / —
|
||||
kHold, // AHDSR hold (Gate)
|
||||
kDecay, // AHDSR decay (Gate)
|
||||
kSustain, // AHDSR sustain (Gate)
|
||||
kRelease, // AHDSR release (Gate)
|
||||
kTrigLength, // Trigger %-length
|
||||
kTrigFadeIn, // Trigger fade-in
|
||||
kTrigFadeOut, // Trigger fade-out
|
||||
kPitchEnvEnable, // AD pitch envelope on|off
|
||||
kPitchEnvAttack, // AD pitch attack
|
||||
kPitchEnvDecay, // AD pitch decay
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||
kAttack, // amp AHDSR attack (Gate)
|
||||
kHold, // amp AHDSR hold (Gate)
|
||||
kDecay, // amp AHDSR decay (Gate)
|
||||
kSustain, // amp AHDSR sustain (Gate)
|
||||
kRelease, // amp AHDSR release (Gate)
|
||||
kTrigLength, // Trigger play span, % of the post-start length
|
||||
kTrigAttack, // amp AHD attack (Trigger)
|
||||
kTrigHold, // amp AHD hold, % of the span left after attack + decay
|
||||
kTrigDecay, // amp AHD decay (Trigger)
|
||||
kPitchEnvEnable, // AHD pitch envelope on|off
|
||||
kPitchEnvAttack,
|
||||
kPitchEnvHold, // % of the span left after attack + decay
|
||||
kPitchEnvDecay,
|
||||
kPitchEnvDepth, // AHD pitch depth in +/- semitones
|
||||
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
|
||||
// Filter. The four control positions map through filter_params' own laws; the three
|
||||
// depths are bipolar and centred at zero.
|
||||
@@ -41,11 +43,32 @@ enum class DeckParam {
|
||||
kFilterVel, // velocity -> cutoff, +/-100%
|
||||
kFilterKeyTrack, // note -> cutoff, 0..200%
|
||||
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
|
||||
kFilterEnvAttack,
|
||||
kFilterEnvAttack, // filter AHDSR (Gate)
|
||||
kFilterEnvHold,
|
||||
kFilterEnvDecay,
|
||||
kFilterEnvSustain,
|
||||
kFilterEnvRelease,
|
||||
kFilterTrigAttack, // filter AHD (Trigger)
|
||||
kFilterTrigHold,
|
||||
kFilterTrigDecay,
|
||||
// Curve exponents. These never get a cell of their own — each is the INNER DIAL of the
|
||||
// stage knob it shapes (see curveParamFor), which is why only sloped stages have one.
|
||||
kAttackCurve,
|
||||
kDecayCurve,
|
||||
kReleaseCurve,
|
||||
kTrigAttackCurve,
|
||||
kTrigDecayCurve,
|
||||
kPitchEnvAttackCurve,
|
||||
kPitchEnvDecayCurve,
|
||||
kFilterEnvAttackCurve,
|
||||
kFilterEnvDecayCurve,
|
||||
kFilterEnvReleaseCurve,
|
||||
kFilterTrigAttackCurve,
|
||||
kFilterTrigDecayCurve,
|
||||
// Overlay selection radios — transient view state, not parameters.
|
||||
kAmpEnvSelect,
|
||||
kPitchEnvSelect,
|
||||
kFilterEnvSelect,
|
||||
// Deck-only controls: processor-side per-instance params — routed to the processor
|
||||
// setters, never through the parameter set.
|
||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
@@ -68,17 +91,24 @@ enum DeckGroupId {
|
||||
};
|
||||
|
||||
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
|
||||
// two instance-wide groups. `playMode` picks the AMP group's face, via knob_deck's blank-cell
|
||||
// reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups.
|
||||
// two instance-wide groups. `playMode` picks the AMP and FILTER ENV groups' faces — AHDSR in
|
||||
// Gate, AHD in Trigger — via knob_deck's blank-cell reservation (knob_deck.h) so a mode flip
|
||||
// never reflows the neighbouring groups.
|
||||
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
|
||||
|
||||
// The curve-exponent control a stage knob's INNER DIAL edits, or kCount when the knob shapes
|
||||
// no curve. THE one place the "every stage except Hold and Sustain is sloped" rule is written
|
||||
// down: a knob with no entry here draws no inner dial and its inner region resolves as an
|
||||
// ordinary knob grab.
|
||||
DeckParam curveParamFor(DeckParam knob);
|
||||
|
||||
// Whether control `id` is delivered LIVE — straight to the voices that are already sounding —
|
||||
// rather than through an instrument reload. The line is drawn at continuously-valued playback
|
||||
// controls, so this is a routing decision at the editor's commit site rather than a property
|
||||
// of any one knob; moving a control across the line is a change here and nowhere else.
|
||||
//
|
||||
// THE home for why each excluded control is excluded. Five continuous controls are outside the
|
||||
// live set, plus every discrete toggle:
|
||||
// THE home for why each excluded control is excluded. Three continuous controls are outside
|
||||
// the live set, plus every discrete toggle and the overlay radios:
|
||||
// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable)
|
||||
// name a different sound rather than a different setting of one;
|
||||
// - the three capture-anchored overrides (root, loop span, start frame) name positions in
|
||||
@@ -86,13 +116,10 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
|
||||
// - kKeyTrack and kFilterVel feed values a voice latches at note-on by design (the pitch
|
||||
// ratio and the velocity-curve result), so live delivery would retune or re-gain a note
|
||||
// already struck;
|
||||
// - kTrigLength resolves playEnd_, a fact about the note. kTrigFadeIn/kTrigFadeOut are pure
|
||||
// amplitude shape and would be live-able in principle, but they live in `sample.play` and
|
||||
// are baked into SampleData at build time — the engine rebuild copies that verbatim, so
|
||||
// only a reload can deliver them without widening LiveValues. They fold into the AHD
|
||||
// alongside Gate's, at which point they inherit its routing; until then they reload.
|
||||
// Consequence, stated plainly: a Trigger-mode instance gets NO live delivery on its amplitude
|
||||
// controls. Only the filter and pitch-envelope knobs move a sounding Trigger one-shot.
|
||||
// - kTrigLength resolves playEnd_, a fact about the note, not a setting of it;
|
||||
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
|
||||
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
|
||||
// inherited its routing, so a Trigger-mode instance now tracks its amplitude knobs too.
|
||||
bool isLiveDeckParam(DeckParam id);
|
||||
|
||||
// The editor drag kinds that can commit live, in this pure module's own vocabulary (the
|
||||
@@ -102,9 +129,9 @@ enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
|
||||
|
||||
// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids
|
||||
// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither
|
||||
// reaches the enum); an envelope-node drag is live only in Gate, where it edits the AHDSR —
|
||||
// in Trigger the same drag rewrites the play span, which is not a live control.
|
||||
bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode);
|
||||
// reaches the enum); an envelope-node drag is live in either mode, since every stage value it
|
||||
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
|
||||
bool liveCommitFor(LiveDragKind kind, int paramId);
|
||||
|
||||
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
|
||||
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
|
||||
#include "core/instrument/ui/envelope_edit.h"
|
||||
|
||||
#include "core/util/clamp01.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib> // std::abs
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01;
|
||||
using util::curveFromMidLevel;
|
||||
using util::curveMidLevel;
|
||||
|
||||
namespace {
|
||||
|
||||
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
|
||||
@@ -30,49 +36,84 @@ double levelPerPixel(const Rect& area) {
|
||||
return 1.0 / static_cast<double>(h - 1);
|
||||
}
|
||||
|
||||
// Origin + ReleaseStart are draw-only anchors, not grabbable.
|
||||
// Origin is a draw-only anchor; so is an AHDSR's ReleaseEnd, which is pinned to the right edge
|
||||
// (release is dragged from ReleaseStart instead).
|
||||
bool isDraggable(EnvNode n) {
|
||||
switch (n) {
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
case EnvNode::ReleaseEnd:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
|
||||
// Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
|
||||
bool nodeInMode(EnvNode n, EnvMode m) {
|
||||
// Guards the degenerate baseline's cross-kind vertices, and keeps the sustain-only nodes off an
|
||||
// AHD. Applied by both the hit-test and the drag resolver.
|
||||
bool nodeInKind(EnvNode n, EnvKind k) {
|
||||
switch (n) {
|
||||
case EnvNode::AttackEnd:
|
||||
case EnvNode::HoldEnd:
|
||||
case EnvNode::DecayEnd:
|
||||
case EnvNode::ReleaseEnd:
|
||||
return m == EnvMode::Gate;
|
||||
case EnvNode::FadeInEnd:
|
||||
case EnvNode::FadeOutStart:
|
||||
case EnvNode::LengthEnd:
|
||||
return m == EnvMode::Trigger;
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::AttackCurve:
|
||||
case EnvNode::DecayCurve:
|
||||
return true;
|
||||
case EnvNode::ReleaseStart:
|
||||
case EnvNode::ReleaseCurve:
|
||||
return k == EnvKind::Ahdsr;
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseEnd:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The two endpoint levels of the segment a curve knot shapes. `ok` is false when the segment
|
||||
// is level (nothing a curve could express), so the drag is a no-op rather than a division.
|
||||
struct SegmentLevels {
|
||||
double start = 0.0;
|
||||
double end = 0.0;
|
||||
bool ok = false;
|
||||
};
|
||||
SegmentLevels segmentLevels(const StageEnvelope& env, EnvNode knot) {
|
||||
const double sus = clamp01(env.sustainLevel);
|
||||
SegmentLevels s;
|
||||
switch (knot) {
|
||||
case EnvNode::AttackCurve: s = {0.0, 1.0, true}; break;
|
||||
case EnvNode::DecayCurve:
|
||||
s = {1.0, env.kind == EnvKind::Ahdsr ? sus : 0.0, true};
|
||||
break;
|
||||
case EnvNode::ReleaseCurve: s = {sus, 0.0, true}; break;
|
||||
default: return s;
|
||||
}
|
||||
if (s.start == s.end) s.ok = false;
|
||||
return s;
|
||||
}
|
||||
|
||||
// A knot drag: the grab-time mid-level shifted by the pixel delta, read back through
|
||||
// curve_law's inverse. Both directions go through the ONE law, which is why the knot and the
|
||||
// inner dial cannot express different exponents.
|
||||
double curveFromKnotDrag(const StageEnvelope& grabEnv, EnvNode knot, double grabExponent,
|
||||
const Rect& area, int dyPixels) {
|
||||
const SegmentLevels seg = segmentLevels(grabEnv, knot);
|
||||
if (!seg.ok) return grabExponent;
|
||||
const double grabLevel = seg.start + (seg.end - seg.start) * curveMidLevel(grabExponent);
|
||||
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
|
||||
return curveFromMidLevel((newLevel - seg.start) / (seg.end - seg.start));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
|
||||
int y) {
|
||||
NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds,
|
||||
int x, int y) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
|
||||
// Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
|
||||
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
|
||||
// coincidence (FadeOutStart overlaps LengthEnd and wins).
|
||||
// Nearest draggable, kind-matching node within the pick radius wins (Chebyshev distance);
|
||||
// ties go to the earlier draw-order node. Knots are appended last, so a knot coincident
|
||||
// with an endpoint handle loses — a drag there stays a time edit.
|
||||
NodeHit best;
|
||||
int bestDist = kNodeGrabRadius + 1;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
|
||||
if (!isDraggable(v.node) || !nodeInKind(v.node, env.kind)) continue;
|
||||
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
|
||||
if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order
|
||||
bestDist = dist;
|
||||
@@ -82,11 +123,11 @@ NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double tota
|
||||
return best;
|
||||
}
|
||||
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
AmpEnvelope out = grabEnv;
|
||||
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
|
||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
StageEnvelope out = grabEnv;
|
||||
if (!isDraggable(node) || !nodeInKind(node, grabEnv.kind)) return out;
|
||||
|
||||
const Rect& rect = area.rect;
|
||||
const double secPerPx = secondsPerPixel(rect, totalSeconds);
|
||||
@@ -94,63 +135,80 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Over
|
||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
||||
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(rect);
|
||||
|
||||
if (grabEnv.kind == EnvKind::Ahdsr) {
|
||||
switch (node) {
|
||||
// Each cumulative-time node edits its own segment duration. Non-negative durations
|
||||
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
|
||||
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd:
|
||||
out.holdSeconds =
|
||||
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
|
||||
break;
|
||||
case EnvNode::DecayEnd: {
|
||||
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
|
||||
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
break;
|
||||
}
|
||||
case EnvNode::ReleaseStart:
|
||||
// The release runs from this node to the anchored right edge, so dragging LEFT
|
||||
// (negative dx) lengthens it — the delta enters with the opposite sign.
|
||||
out.releaseSeconds =
|
||||
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
break;
|
||||
case EnvNode::AttackCurve:
|
||||
out.attackCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
||||
break;
|
||||
case EnvNode::DecayCurve:
|
||||
out.decayCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
||||
break;
|
||||
case EnvNode::ReleaseCurve:
|
||||
out.releaseCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// AHD: the x-axis is the waveform's own, so a stage node moves at 1:1 wall-clock scale.
|
||||
const AhdSplit s = splitAhdSeconds(grabEnv);
|
||||
switch (node) {
|
||||
// Gate: each cumulative-time node edits its own segment duration. Non-negative durations
|
||||
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
|
||||
// never cross a neighbour) — the [0, max] clamp is the whole constraint.
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
||||
std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd:
|
||||
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
|
||||
break;
|
||||
case EnvNode::DecayEnd: {
|
||||
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
||||
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double lvlPerPx = levelPerPixel(rect);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
|
||||
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
case EnvNode::HoldEnd: {
|
||||
// Hold is a fraction of what attack and decay left, so the node's pixel motion
|
||||
// converts through that remainder. A zero remainder leaves nothing to divide by and
|
||||
// nothing the drag could express.
|
||||
const double rem = std::max(0.0, grabEnv.spanSeconds) - s.attack - s.decay;
|
||||
if (rem <= 0.0) break;
|
||||
out.holdFraction = clamp01((s.hold + dSec) / rem);
|
||||
break;
|
||||
}
|
||||
case EnvNode::ReleaseEnd:
|
||||
out.releaseSeconds =
|
||||
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
case EnvNode::DecayEnd:
|
||||
out.decaySeconds =
|
||||
std::clamp(grabEnv.decaySeconds + dSec, 0.0, bounds.maxDecaySeconds);
|
||||
break;
|
||||
|
||||
// Trigger: fades + length are fractions. X pixels convert to a fraction of the played
|
||||
// span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
|
||||
// nodes from crossing (each clamps against the other).
|
||||
case EnvNode::FadeInEnd: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
|
||||
const double hi = std::min(bounds.maxFadeInFraction,
|
||||
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
|
||||
out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
|
||||
case EnvNode::AttackCurve:
|
||||
out.attackCurve =
|
||||
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
|
||||
break;
|
||||
}
|
||||
case EnvNode::FadeOutStart: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
// FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx)
|
||||
// lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta.
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
|
||||
const double hi = std::min(bounds.maxFadeOutFraction,
|
||||
1.0 - std::max(0.0, grabEnv.fadeInFraction));
|
||||
out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
|
||||
case EnvNode::DecayCurve:
|
||||
out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
|
||||
break;
|
||||
}
|
||||
case EnvNode::LengthEnd: {
|
||||
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
|
||||
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
|
||||
out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
|
||||
// envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
|
||||
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
|
||||
// envelope nodes and their mid-segment curve knots. Mirror of card_drag/waveform_view: drag
|
||||
// arithmetic lives here, unit-tested outside the DAW; the shell draws handles, captures the
|
||||
// grab, and feeds pixel deltas back in.
|
||||
//
|
||||
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
|
||||
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one
|
||||
// parameter set every paint), so a node drag and a slider edit are two views on one source of
|
||||
// truth.
|
||||
// (edit) map + hit-test. Both read/write the same StageEnvelope fields (the shell re-reads the
|
||||
// one parameter set every paint), so a node drag, a knot drag, and a knob edit are three views
|
||||
// on one source of truth — structurally, not through a listener chain.
|
||||
//
|
||||
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
|
||||
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
|
||||
// slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
|
||||
// A drag can never produce a param a knob couldn't: time nodes are range-clamped to the same
|
||||
// per-param [min,max] the knobs enforce (EnvClampBounds, caller-supplied since those maxima
|
||||
// live shell-side), and a knot resolves through curve_law's own exponent domain.
|
||||
//
|
||||
// Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
|
||||
// Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
|
||||
// editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
|
||||
// Time-only nodes drag on X; DecayEnd in an AHDSR drags on both axes (X = decay time, Y =
|
||||
// sustain level); a curve knot drags on Y alone. Origin is never draggable, and neither is an
|
||||
// AHDSR's ReleaseEnd — it is anchored to the right edge, and release is dragged from
|
||||
// ReleaseStart instead. A node is only editable in its own kind.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -21,7 +23,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
|
||||
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvKind, StageEnvelope, EnvVertex
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
@@ -29,46 +31,41 @@ namespace reasampler::instrument::ui {
|
||||
// kMarkerGrabWidth.
|
||||
inline constexpr int kNodeGrabRadius = 6;
|
||||
|
||||
// Per-param clamp bounds the shell supplies — the same maxima its sliders map [0,1] onto.
|
||||
// Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
|
||||
// Defaults are placeholders; the shell overrides with its live slider domain.
|
||||
// Per-param clamp bounds the shell supplies — the same maxima its knobs map [0,1] onto.
|
||||
// Lower bound is always 0. Defaults are placeholders; the shell overrides with its live domain.
|
||||
struct EnvClampBounds {
|
||||
double maxAttackSeconds = 4.0;
|
||||
double maxHoldSeconds = 4.0;
|
||||
double maxDecaySeconds = 4.0;
|
||||
double maxReleaseSeconds = 4.0;
|
||||
double maxFadeInFraction = 1.0;
|
||||
double maxFadeOutFraction = 1.0;
|
||||
double maxLengthFraction = 1.0;
|
||||
// sustainLevel is always [0,1] — no shell knob needed.
|
||||
// sustainLevel is always [0,1] and the hold FRACTION is always [0,1] — no shell knob needed.
|
||||
};
|
||||
|
||||
// Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
|
||||
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
|
||||
// Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
|
||||
// wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
|
||||
// for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
|
||||
// can be dragged open from zero). Gate nodes never coincide (forward map enforces
|
||||
// kGateNodeSepPx), so every Gate handle is independently grabbable.
|
||||
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node.
|
||||
// Nearest node within the radius wins (Chebyshev distance); an exact tie goes to the earlier
|
||||
// draw-order node, and since knots are appended last, a coincident endpoint handle wins over a
|
||||
// knot rather than the drag silently becoming a curve edit.
|
||||
struct NodeHit {
|
||||
bool hit = false;
|
||||
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
|
||||
};
|
||||
// Takes the waveform overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const OverlayArea& area, double totalSeconds, int x,
|
||||
int y);
|
||||
NodeHit nodeAtPoint(const StageEnvelope& env, const OverlayArea& area, double totalSeconds,
|
||||
int x, int y);
|
||||
|
||||
// Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
|
||||
// shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
|
||||
// `dyPixels` is the pixel delta since grab.
|
||||
// * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
|
||||
// [0, per-param max] and to its monotonic-in-time neighbours.
|
||||
// * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
|
||||
// * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
|
||||
// Resolves a drag of `node` to a new StageEnvelope. `grabEnv` is the envelope as of grab time
|
||||
// (the shell snapshots it on button-down so the delta is absolute, not accumulated);
|
||||
// `dxPixels`/`dyPixels` is the pixel delta since grab.
|
||||
// * X delta -> the node's time param, at the same scale the forward map drew it, clamped to
|
||||
// [0, per-param max].
|
||||
// * Y delta -> the level param (AHDSR DecayEnd's sustain) or, on a knot, the segment's curve
|
||||
// exponent. Ignored for time-only nodes.
|
||||
// * A non-draggable node, an other-kind node, a zero-size area, or totalSeconds <= 0 returns
|
||||
// `grabEnv` unchanged.
|
||||
// Only the dragged node's param(s) change. Pure.
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
StageEnvelope resolveNodeDrag(const StageEnvelope& grabEnv, EnvNode node, const OverlayArea& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01;
|
||||
using util::curveMap;
|
||||
using util::curveMidLevel;
|
||||
|
||||
int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
const int w = std::max(0, area.width);
|
||||
@@ -21,20 +23,14 @@ int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
return area.x + static_cast<int>(px + 0.5);
|
||||
}
|
||||
|
||||
int gateTimedWidth(const Rect& area) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0) return 0;
|
||||
const int sustainPx =
|
||||
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
|
||||
return std::max(1, w - sustainPx);
|
||||
}
|
||||
|
||||
double gatePxPerSecond(const Rect& area) {
|
||||
const int timedW = gateTimedWidth(area);
|
||||
if (timedW <= 0) return 0.0;
|
||||
// Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
|
||||
const double usable =
|
||||
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0) return 0.0;
|
||||
// The four timed stages share the canvas minus their four separation bases and the last
|
||||
// in-bounds column; whatever they leave IS the sustain plateau, which is why a zero release
|
||||
// puts the plateau's end one separation short of the right edge rather than a fixed
|
||||
// fraction of the way across.
|
||||
const double usable = std::max(1.0, static_cast<double>(w - 1 - 4 * kGateNodeSepPx));
|
||||
return usable / (4.0 * kGateStageMaxSeconds);
|
||||
}
|
||||
|
||||
@@ -50,20 +46,37 @@ int levelToY(const Rect& area, double level) {
|
||||
return area.y + static_cast<int>(dy);
|
||||
}
|
||||
|
||||
AhdSplit splitAhdSeconds(const StageEnvelope& env) {
|
||||
AhdSplit out;
|
||||
const double span = std::max(0.0, env.spanSeconds);
|
||||
double a = std::max(0.0, env.attackSeconds);
|
||||
if (a > span) a = span;
|
||||
double d = std::max(0.0, env.decaySeconds);
|
||||
if (d > span - a) d = span - a;
|
||||
const double remaining = span - a - d;
|
||||
out.attack = a;
|
||||
out.decay = d;
|
||||
out.hold = remaining * clamp01(env.holdFraction);
|
||||
out.total = out.attack + out.hold + out.decay;
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
|
||||
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level,
|
||||
bool knot = false) {
|
||||
EnvVertex v;
|
||||
v.node = node;
|
||||
v.x = timeToX(area, totalSeconds, t);
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
v.knot = knot;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
|
||||
// timeToX map; clamps in double space before the int cast for the same overflow reason as above.
|
||||
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
|
||||
// The AHDSR schematic works in px space rather than the plain timeToX map; clamps in double
|
||||
// space before the int cast for the same overflow reason as timeToX.
|
||||
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level, bool knot = false) {
|
||||
const int w = std::max(1, area.width);
|
||||
if (px < 0.0) px = 0.0;
|
||||
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
|
||||
@@ -72,10 +85,27 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
|
||||
v.x = area.x + static_cast<int>(px + 0.5);
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
v.knot = knot;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
|
||||
// The knot for a segment running from `startLevel` to `endLevel`, placed at the segment's
|
||||
// pixel midpoint. Its level is the curve's own value at the segment midpoint, which is what
|
||||
// makes the knot's height and the inner dial two readings of one exponent.
|
||||
EnvVertex knotVtx(EnvNode node, const Rect& area, int x0, int x1, double startLevel,
|
||||
double endLevel, double exponent) {
|
||||
const double u = curveMidLevel(exponent);
|
||||
const double level = startLevel + (endLevel - startLevel) * u;
|
||||
EnvVertex v;
|
||||
v.node = node;
|
||||
v.x = (x0 + x1) / 2;
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
v.knot = true;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> gatePolyline(const StageEnvelope& env, const Rect& area) {
|
||||
// Clamp defensively — a stored negative duration would be an upstream bug.
|
||||
const double a = std::max(0.0, env.attackSeconds);
|
||||
const double h = std::max(0.0, env.holdSeconds);
|
||||
@@ -83,73 +113,87 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
|
||||
const double r = std::max(0.0, env.releaseSeconds);
|
||||
const double sus = clamp01(env.sustainLevel);
|
||||
|
||||
// A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
|
||||
// kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay
|
||||
// defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart.
|
||||
const int W = std::max(1, area.width);
|
||||
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
|
||||
const double sep = static_cast<double>(kGateNodeSepPx);
|
||||
const double pps = gatePxPerSecond(area);
|
||||
|
||||
double xAttack = sep + a * pps; // AttackEnd
|
||||
double xHold = xAttack + sep + h * pps; // HoldEnd
|
||||
double xDecay = xHold + sep + d * pps; // DecayEnd (sustain node)
|
||||
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
|
||||
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
|
||||
|
||||
// Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
|
||||
// trailing nodes stay separated instead of piling on the last column. This re-floor only
|
||||
// bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
|
||||
const double xMax = static_cast<double>(W - 1);
|
||||
if (xRelease > xMax) {
|
||||
xRelease = xMax;
|
||||
xPlateau = std::min(xPlateau, xRelease - sep);
|
||||
xDecay = std::min(xDecay, xPlateau - sustainPx);
|
||||
xHold = std::min(xHold, xDecay - sep);
|
||||
xAttack = std::min(xAttack, xHold - sep);
|
||||
xAttack = std::max(xAttack, sep);
|
||||
xHold = std::max(xHold, xAttack + sep);
|
||||
xDecay = std::max(xDecay, xHold + sep);
|
||||
xPlateau = std::max(xPlateau, xDecay + sustainPx);
|
||||
xRelease = std::max(xRelease, xPlateau + sep);
|
||||
|
||||
// The release ANCHORS to the right edge: ReleaseEnd is the canvas edge and ReleaseStart —
|
||||
// the sustain->release join, and the node the user drags — sits a release-length to its
|
||||
// left. Everything the release does not take is the sustain plateau, so a zero release
|
||||
// leaves the plateau running to within one separation of the edge.
|
||||
double xAttack = sep + a * pps;
|
||||
double xHold = xAttack + sep + h * pps;
|
||||
double xDecay = xHold + sep + d * pps;
|
||||
double xPlateau = xMax - sep - r * pps;
|
||||
const double xRelease = xMax;
|
||||
|
||||
// Keep every node separated when the four stages together would overrun the canvas: the
|
||||
// plateau holds its minimum gap from the edge, then the A/H/D chain compresses from the
|
||||
// right and re-floors from the left. This only bites at the domain's extremes; gateVtx's
|
||||
// own clamp wins on a canvas too narrow to hold the gaps at all.
|
||||
if (xPlateau < xDecay + sep) {
|
||||
if (xPlateau < 4.0 * sep) xPlateau = 4.0 * sep;
|
||||
xDecay = std::min(xDecay, xPlateau - sep);
|
||||
xHold = std::min(xHold, xDecay - sep);
|
||||
xAttack = std::min(xAttack, xHold - sep);
|
||||
xAttack = std::max(xAttack, sep);
|
||||
xHold = std::max(xHold, xAttack + sep);
|
||||
xDecay = std::max(xDecay, xHold + sep);
|
||||
xPlateau = std::max(xPlateau, xDecay + sep);
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> pts;
|
||||
pts.reserve(6);
|
||||
pts.reserve(9);
|
||||
pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0));
|
||||
pts.push_back(gateVtx(EnvNode::AttackEnd, area, xAttack, 1.0));
|
||||
pts.push_back(gateVtx(EnvNode::HoldEnd, area, xHold, 1.0));
|
||||
pts.push_back(gateVtx(EnvNode::DecayEnd, area, xDecay, sus)); // sustain node
|
||||
pts.push_back(gateVtx(EnvNode::ReleaseStart, area, xPlateau, sus)); // plateau end
|
||||
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0));
|
||||
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0)); // anchored
|
||||
|
||||
// Knots ride only SLOPED stages that actually have a duration — a zero-length stage has no
|
||||
// interior to place a handle in, and one there would collide with its own endpoints.
|
||||
if (a > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
|
||||
env.attackCurve));
|
||||
}
|
||||
if (d > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, sus,
|
||||
env.decayCurve));
|
||||
}
|
||||
if (r > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::ReleaseCurve, area, pts[4].x, pts[5].x, sus, 0.0,
|
||||
env.releaseCurve));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
// Played span is lengthFraction of the whole sample; fades are fractions of that span.
|
||||
const double len = clamp01(env.lengthFraction);
|
||||
double fadeIn = clamp01(env.fadeInFraction);
|
||||
double fadeOut = clamp01(env.fadeOutFraction);
|
||||
// Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp.
|
||||
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
|
||||
|
||||
const double playSeconds = len * totalSeconds;
|
||||
const double tFadeInEnd = fadeIn * playSeconds;
|
||||
const double tFadeOutStart = playSeconds - fadeOut * playSeconds; // where fade-out begins
|
||||
std::vector<EnvVertex> ahdPolyline(const StageEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
const AhdSplit s = splitAhdSeconds(env);
|
||||
const double t0 = std::max(0.0, env.originSeconds);
|
||||
|
||||
std::vector<EnvVertex> pts;
|
||||
pts.reserve(4);
|
||||
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
|
||||
pts.push_back(vtx(EnvNode::FadeInEnd, area, totalSeconds, tFadeInEnd, 1.0));
|
||||
pts.push_back(vtx(EnvNode::FadeOutStart, area, totalSeconds, tFadeOutStart, 1.0)); // unity end
|
||||
pts.push_back(vtx(EnvNode::LengthEnd, area, totalSeconds, playSeconds, 0.0)); // playEnd
|
||||
pts.reserve(6);
|
||||
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, t0, 0.0));
|
||||
pts.push_back(vtx(EnvNode::AttackEnd, area, totalSeconds, t0 + s.attack, 1.0));
|
||||
pts.push_back(vtx(EnvNode::HoldEnd, area, totalSeconds, t0 + s.attack + s.hold, 1.0));
|
||||
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0));
|
||||
if (s.attack > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
|
||||
env.attackCurve));
|
||||
}
|
||||
if (s.decay > 0.0) {
|
||||
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, 0.0,
|
||||
env.decayCurve));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
|
||||
double totalSeconds) {
|
||||
const Rect& rect = area.rect;
|
||||
if (rect.width <= 0 || rect.height <= 0 || totalSeconds <= 0.0) {
|
||||
@@ -157,8 +201,8 @@ std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Overl
|
||||
return {vtx(EnvNode::Origin, rect, 1.0, 0.0, 0.0),
|
||||
vtx(EnvNode::ReleaseEnd, rect, 1.0, 1.0, 0.0)};
|
||||
}
|
||||
return env.mode == EnvMode::Gate ? gatePolyline(env, rect)
|
||||
: triggerPolyline(env, rect, totalSeconds);
|
||||
return env.kind == EnvKind::Ahdsr ? gatePolyline(env, rect)
|
||||
: ahdPolyline(env, rect, totalSeconds);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
|
||||
// envelope_overlay.h — staged-envelope -> polyline geometry for the Sample-view overlay.
|
||||
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
|
||||
// param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
|
||||
// AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
|
||||
// hit-test).
|
||||
// param_slider. The shell packs whichever envelope is overlay-active into StageEnvelope and
|
||||
// draws the polyline plus a handle at each node (envelope_edit does the hit-test).
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -10,92 +9,98 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
|
||||
#include "core/util/curve_law.h" // the ONE per-segment curve law
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
|
||||
enum class EnvMode { Gate, Trigger };
|
||||
// Which LAYOUT POLICY an envelope takes, decided by whether it has a sustain stage rather
|
||||
// than by which processor it modulates. A gated (AHDSR) envelope right-anchors its release so
|
||||
// the sustain plateau reads full-width; a sustain-less (AHD) one maps 1:1 onto the waveform's
|
||||
// own time axis, which only means anything for a trigger shape. The two policies coexist.
|
||||
enum class EnvKind { Ahdsr, Ahd };
|
||||
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
|
||||
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
|
||||
// Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
|
||||
// AHD nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd.
|
||||
// The three *Curve nodes are the round mid-segment knots whose vertical drag sets that
|
||||
// segment's curve exponent. Shared by envelope_overlay (forward/draw) and envelope_edit
|
||||
// (inverse/edit).
|
||||
enum class EnvNode {
|
||||
Origin, // t=0, level 0 — not draggable
|
||||
AttackEnd, // Gate: attack ramp top — sets attackSeconds
|
||||
HoldEnd, // Gate: hold plateau end — sets holdSeconds
|
||||
DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
|
||||
ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
|
||||
ReleaseEnd, // Gate: release tail end — sets releaseSeconds
|
||||
FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
|
||||
FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
|
||||
LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
|
||||
AttackEnd, // attack ramp top — sets attackSeconds
|
||||
HoldEnd, // hold plateau end — AHDSR: holdSeconds; AHD: holdFraction
|
||||
DecayEnd, // AHDSR: decay settles to sustain (X = decay, Y = sustain); AHD: decay end
|
||||
ReleaseStart, // AHDSR: sustain plateau end — sets releaseSeconds (drags on X, inverted)
|
||||
ReleaseEnd, // AHDSR: the envelope's end point — ANCHORED to the right edge, not draggable
|
||||
AttackCurve, // mid-attack knot — sets attackCurve
|
||||
DecayCurve, // mid-decay knot — sets decayCurve
|
||||
ReleaseCurve, // mid-release knot — sets releaseCurve (AHDSR only)
|
||||
};
|
||||
|
||||
// Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
|
||||
// TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
|
||||
// core/instrument/CLAUDE.md.
|
||||
struct AmpEnvelope {
|
||||
EnvMode mode = EnvMode::Gate;
|
||||
// The envelope the overlay draws. One struct for both policies: `kind` selects which fields
|
||||
// are read, so a single pack/unpack pair serves the amp, pitch, and filter envelopes.
|
||||
struct StageEnvelope {
|
||||
EnvKind kind = EnvKind::Ahdsr;
|
||||
|
||||
// Gate (AHDSR): seconds, plus a dimensionless sustain level.
|
||||
// AHDSR: seconds at the schematic param-domain scale, plus a dimensionless sustain level.
|
||||
double attackSeconds = 0.003;
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060;
|
||||
|
||||
// Trigger: fractions of the played span.
|
||||
double lengthFraction = 1.0;
|
||||
double fadeInFraction = 0.0;
|
||||
double fadeOutFraction = 0.0;
|
||||
// AHD: attack/decay seconds plus the Hold FRACTION of the span left after them, laid over
|
||||
// [originSeconds, originSeconds + spanSeconds) of the waveform's own time axis.
|
||||
double holdFraction = 1.0;
|
||||
double originSeconds = 0.0;
|
||||
double spanSeconds = 0.0;
|
||||
|
||||
// Per-segment curve exponents (release is AHDSR-only). curve_law.h owns the domain.
|
||||
double attackCurve = util::kCurveNeutral;
|
||||
double decayCurve = util::kCurveNeutral;
|
||||
double releaseCurve = util::kCurveNeutral;
|
||||
};
|
||||
|
||||
// One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
|
||||
// inspection.
|
||||
// One polyline vertex: pixel point plus which node it is. `level` is redundant with y, carried
|
||||
// for inspection. `knot` marks the round mid-segment curve handles, which draw differently and
|
||||
// are not part of the traced line.
|
||||
struct EnvVertex {
|
||||
EnvNode node = EnvNode::Origin;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
double level = 0.0;
|
||||
bool knot = false;
|
||||
|
||||
bool operator==(const EnvVertex& o) const {
|
||||
return node == o.node && x == o.x && y == o.y && level == o.level;
|
||||
return node == o.node && x == o.x && y == o.y && level == o.level && knot == o.knot;
|
||||
}
|
||||
};
|
||||
|
||||
// Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
|
||||
// carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
|
||||
inline constexpr double kGateSustainDisplayFraction = 0.15;
|
||||
|
||||
// Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
|
||||
// Minimum pixel separation between consecutive AHDSR nodes, so zero-duration stages (tier-0
|
||||
// defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
|
||||
// radius (6) so a click can never tie between neighbours.
|
||||
inline constexpr int kGateNodeSepPx = 8;
|
||||
|
||||
// Gate schematic's per-stage time domain (seconds) — the timed region represents four stages
|
||||
// end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
|
||||
// lands exactly at the canvas edge.
|
||||
// The AHDSR schematic's per-stage time domain (seconds) — the four timed stages A/H/D/R each
|
||||
// span at most this. Must match the shell's stage-knob ceiling so a maxed knob lands exactly at
|
||||
// the canvas edge (at which point the sustain plateau has shrunk to nothing).
|
||||
inline constexpr double kGateStageMaxSeconds = 2.0;
|
||||
|
||||
// Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
|
||||
// a non-empty area; 0 for a zero/negative-width area.
|
||||
int gateTimedWidth(const Rect& area);
|
||||
|
||||
// Pixels per second of the Gate timed region, independent of the sample's actual duration.
|
||||
// Pixels per second of the AHDSR schematic, independent of the sample's actual duration.
|
||||
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
|
||||
// the cursor 1:1.
|
||||
double gatePxPerSecond(const Rect& area);
|
||||
|
||||
// Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
|
||||
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
|
||||
// are in draw order, Origin first.
|
||||
// Maps a staged envelope to polyline vertices inside `area` over a sample of `totalSeconds`
|
||||
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); the
|
||||
// traced vertices come first in draw order (Origin first), then the curve knots.
|
||||
//
|
||||
// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
|
||||
// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
|
||||
// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
|
||||
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
|
||||
// The AHDSR x-axis is a bounded schematic independent of totalSeconds (it does NOT line up with
|
||||
// the waveform under it) with its ReleaseEnd anchored to the right edge; the AHD x-axis is
|
||||
// wall-clock, 1:1 with the waveform. Every vertex is clamped inside the canvas: x in
|
||||
// [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area or
|
||||
// totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0]. Takes the
|
||||
// waveform overlay (not a lane) — see waveform_view.h's overlay contract.
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const OverlayArea& area,
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const StageEnvelope& env, const OverlayArea& area,
|
||||
double totalSeconds);
|
||||
|
||||
// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
|
||||
@@ -106,4 +111,16 @@ int timeToX(const Rect& area, double totalSeconds, double t);
|
||||
// clamped. Shared with envelope_edit's node hit-test.
|
||||
int levelToY(const Rect& area, double level);
|
||||
|
||||
// The A/H/D split of an AHD's span, in seconds — the pure-UI mirror of the engine's fitAhd, so
|
||||
// the drawn stage boundaries land where the voice actually puts them. Attack takes at most the
|
||||
// span and Decay at most what Attack left, so Hold's fraction of the remainder can never push
|
||||
// the sum past the span; there is no clamp on the sum because none is possible.
|
||||
struct AhdSplit {
|
||||
double attack = 0.0;
|
||||
double hold = 0.0;
|
||||
double decay = 0.0;
|
||||
double total = 0.0;
|
||||
};
|
||||
AhdSplit splitAhdSeconds(const StageEnvelope& env);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -20,10 +20,11 @@ int knobRowWidth(const DeckGroupDesc& g) {
|
||||
return w;
|
||||
}
|
||||
|
||||
// The caption-row width: the caption reserve plus the optional caption toggle.
|
||||
// The caption-row width: the caption reserve plus the optional caption toggle and radio.
|
||||
int captionRowWidth(const DeckGroupDesc& g) {
|
||||
int w = g.captionWidth;
|
||||
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
|
||||
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
|
||||
return w;
|
||||
}
|
||||
|
||||
@@ -37,12 +38,22 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
const int innerLeft = box.x + kDeckGroupPadX;
|
||||
const int innerRight = box.right() - kDeckGroupPadX;
|
||||
|
||||
// Caption row: text left, compact toggle right-anchored.
|
||||
// Caption row: text left, then the compact toggle, then the corner radio at the far edge.
|
||||
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
|
||||
int captionRight = innerRight;
|
||||
if (g.captionRadio.id >= 0) {
|
||||
const int radioTop = captionTop + (kDeckCaptionH - kDeckRadioSize) / 2;
|
||||
out.captionRadio = DeckRadioLayout{
|
||||
g.captionRadio.id, Rect::ltrb(innerRight - kDeckRadioSize, radioTop, innerRight,
|
||||
radioTop + kDeckRadioSize)};
|
||||
captionRight = out.captionRadio.box.x - kDeckToggleGap;
|
||||
out.caption.width = captionRight - out.caption.x;
|
||||
}
|
||||
if (g.captionToggle.id >= 0) {
|
||||
const int segW = g.captionToggle.segWidth;
|
||||
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
||||
const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH);
|
||||
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
|
||||
togTop + kDeckToggleH);
|
||||
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
|
||||
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
|
||||
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
|
||||
@@ -59,6 +70,10 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
|
||||
const int knobTop = cellTop + 4;
|
||||
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
|
||||
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
const int innerTopPx = knobTop + (kDeckKnobSize - kDeckInnerDialSize) / 2;
|
||||
c.inner = Rect::ltrb(innerLeftPx, innerTopPx, innerLeftPx + kDeckInnerDialSize,
|
||||
innerTopPx + kDeckInnerDialSize);
|
||||
const int labelTop = knobTop + kDeckKnobSize + 4;
|
||||
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
|
||||
out.cells.push_back(c);
|
||||
@@ -133,6 +148,9 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
for (const DeckGroupLayout& g : layout.groups) {
|
||||
if (!contains(g.box, x, y)) continue;
|
||||
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
|
||||
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
|
||||
}
|
||||
if (g.captionToggle.id >= 0) {
|
||||
if (contains(g.captionToggle.seg0, x, y))
|
||||
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
|
||||
@@ -146,7 +164,9 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
|
||||
}
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id >= 0 && contains(c.cell, x, y)) return {DeckHitKind::Knob, c.id, -1};
|
||||
if (c.id >= 0 && contains(c.cell, x, y)) {
|
||||
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
|
||||
}
|
||||
}
|
||||
return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
|
||||
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
|
||||
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
|
||||
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
|
||||
inline constexpr int kDeckRadioSize = 12; // the caption-row corner radio square
|
||||
// The knob cell's INNER dial: a concentric sub-disc that edits a second, related value while
|
||||
// the outer ring keeps editing the cell's own. Geometry only — WHICH cells carry one is
|
||||
// deck_groups' call, so a cell without an inner value simply resolves an inner hit as a knob.
|
||||
inline constexpr int kDeckInnerDialSize = 14;
|
||||
// One group box: padding + caption + gap + cell row + padding.
|
||||
inline constexpr int kDeckGroupH =
|
||||
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
|
||||
@@ -45,13 +50,20 @@ struct DeckToggleDesc {
|
||||
int segWidth = 44; // px per segment
|
||||
};
|
||||
|
||||
// A single-square corner radio (an exclusive selector across groups, so the group itself
|
||||
// carries no state). id -1 = absent.
|
||||
struct DeckRadioDesc {
|
||||
int id = -1;
|
||||
};
|
||||
|
||||
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
|
||||
// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
|
||||
// reserves for the caption text (this module does not measure text).
|
||||
struct DeckGroupDesc {
|
||||
int id = 0; // shell group id (opaque here)
|
||||
int captionWidth = 60;
|
||||
DeckToggleDesc captionToggle; // right-anchored in the caption row; id -1 = none
|
||||
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
|
||||
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
|
||||
std::vector<int> cellIds; // knob cells; -1 = blank reserve
|
||||
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
|
||||
};
|
||||
@@ -64,10 +76,16 @@ struct DeckToggleLayout {
|
||||
Rect seg1; // right segment
|
||||
};
|
||||
|
||||
struct DeckRadioLayout {
|
||||
int id = -1;
|
||||
Rect box;
|
||||
};
|
||||
|
||||
struct DeckCellLayout {
|
||||
int id = -1;
|
||||
Rect cell; // the full 48x58 cell
|
||||
Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it)
|
||||
Rect inner; // the concentric kDeckInnerDialSize square inside `knob`
|
||||
Rect label; // the 12px label band beneath the knob
|
||||
};
|
||||
|
||||
@@ -75,6 +93,7 @@ struct DeckGroupLayout {
|
||||
int id = 0;
|
||||
Rect box; // the fenced group box
|
||||
Rect caption; // caption text rect (left part of the caption row)
|
||||
DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
|
||||
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
|
||||
std::vector<DeckCellLayout> cells;
|
||||
DeckToggleLayout rowToggle; // id -1 when absent
|
||||
@@ -105,17 +124,19 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
|
||||
|
||||
// --- Hit-test --------------------------------------------------------------------------
|
||||
|
||||
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle };
|
||||
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle, CaptionRadio };
|
||||
|
||||
struct DeckHit {
|
||||
DeckHitKind kind = DeckHitKind::None;
|
||||
int id = -1; // the control id of the hit element (cell id / toggle id)
|
||||
int id = -1; // the control id of the hit element (cell id / toggle id / radio id)
|
||||
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
|
||||
bool inner = false; // Knob hits only: the grab landed on the cell's inner dial
|
||||
};
|
||||
|
||||
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
|
||||
// circle — the shell anchors the vertical drag wherever the grab lands), a caption-toggle
|
||||
// segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
|
||||
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
|
||||
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
|
||||
// the caption-row corner radio. Blank cells (id -1) and everything else miss.
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
## Scope
|
||||
|
||||
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
|
||||
loading, unit-interval clamping, and the absolute-path rejection test.
|
||||
loading, unit-interval clamping, the absolute-path rejection test, and the
|
||||
per-segment envelope curve law.
|
||||
|
||||
## Modules
|
||||
|
||||
- `file_bytes` (`core/util`) — the ONE whole-file byte loader (Q-W1), linked by both artifacts; blocking I/O, off-audio-thread only.
|
||||
- `clamp01` (`core/util`, header-only) — the ONE unit-interval clamp (Q-W1), replacing four per-module static copies; NaN passes through unchanged rather than collapsing to a bound.
|
||||
- `curve_law` (`core/util`, header-only) — the ONE per-segment envelope curve law: the
|
||||
exponent domain (0.1..10, neutral 1.0), the normalized-position -> normalized-level map, and
|
||||
the mid-segment inverse an overlay knot drags through. Header-only and dependency-free so
|
||||
the engine's evaluator, the overlay's forward map, its inverse, and the deck's inner dial all
|
||||
read one law instead of four copies. **The neutral exponent is the IDENTITY, bit for bit** —
|
||||
that is what makes an instance saved before curves existed play unchanged.
|
||||
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
reasampler_pure_library(file_bytes SOURCES file_bytes.cpp)
|
||||
reasampler_test(file_bytes LINK file_bytes)
|
||||
|
||||
# The per-segment envelope curve law is header-only, hence INTERFACE.
|
||||
add_library(curve_law INTERFACE)
|
||||
target_include_directories(curve_law INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
reasampler_test(curve_law LINK curve_law)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
// curve_law — the ONE per-segment envelope curve law: the exponent domain, the map from a
|
||||
// stage's normalized position to its normalized level, and the mid-segment inverse the
|
||||
// overlay knot drags through. Header-only and dependency-free so the engine evaluator, the
|
||||
// overlay's forward map, and its inverse all read the same law rather than three copies.
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::util {
|
||||
|
||||
// The per-segment curve is exponential: level = phi^exponent over the stage's normalized
|
||||
// position phi. 1.0 is the LINEAR neutral (phi^1 == phi), which is why a pre-existing
|
||||
// instance loading at 1.0 plays exactly as it did.
|
||||
inline constexpr double kCurveNeutral = 1.0;
|
||||
inline constexpr double kCurveMin = 0.1;
|
||||
inline constexpr double kCurveMax = 10.0;
|
||||
|
||||
// Normalized position -> normalized level. The neutral exponent is compared EXACTLY so the
|
||||
// at-rest per-sample path pays one predicted branch instead of a transcendental; every
|
||||
// positive exponent maps 0 -> 0 and 1 -> 1, so a curved stage can never overshoot its own
|
||||
// endpoint levels.
|
||||
inline double curveMap(double phi, double exponent) {
|
||||
if (exponent == kCurveNeutral) return phi;
|
||||
return std::pow(phi, exponent);
|
||||
}
|
||||
|
||||
inline double clampCurve(double exponent) {
|
||||
if (!(exponent >= kCurveMin)) return kCurveMin; // also catches NaN
|
||||
return exponent > kCurveMax ? kCurveMax : exponent;
|
||||
}
|
||||
|
||||
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
|
||||
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
|
||||
// one value: both resolve through this law, not through each other.
|
||||
inline double curveMidLevel(double exponent) { return curveMap(0.5, clampCurve(exponent)); }
|
||||
|
||||
// Mid-level -> exponent: u = 0.5^p, so p = ln(u)/ln(0.5). Out-of-domain u clamps to the
|
||||
// exponent endpoints rather than producing a non-finite exponent.
|
||||
inline double curveFromMidLevel(double midLevel) {
|
||||
const double lo = curveMidLevel(kCurveMax); // smallest reachable mid-level
|
||||
const double hi = curveMidLevel(kCurveMin); // largest
|
||||
if (!(midLevel > lo)) return kCurveMax; // also catches NaN
|
||||
if (midLevel >= hi) return kCurveMin;
|
||||
return clampCurve(std::log(midLevel) / std::log(0.5));
|
||||
}
|
||||
|
||||
} // namespace reasampler::util
|
||||
Reference in New Issue
Block a user