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

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