Merge Θ-W3-T2: one staged-envelope system — per-segment curves, the sustain-less AHD, and one overlay shared by all three envelopes

This commit is contained in:
2026-07-31 13:11:12 -04:00
55 changed files with 4257 additions and 1839 deletions
+55 -36
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,9 +253,9 @@ 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`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
- `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.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved). - `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
@@ -246,10 +264,11 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
### `map/` ### `map/`
- `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…v10), 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/`
@@ -264,15 +283,15 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `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), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled group makes inert); 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.
+6 -2
View File
@@ -20,7 +20,7 @@ add_subdirectory(filter)
# engine — the block is a plain value the voice observes, not a thing the engine owns. # engine — the block is a plain value the voice observes, not a thing the engine owns.
reasampler_pure_library(live_params reasampler_pure_library(live_params
SOURCES live_params.cpp SOURCES live_params.cpp
LINK PUBLIC peaks velocity_curve filter) LINK PUBLIC peaks velocity_curve filter curve_law)
reasampler_test(live_params LINK live_params) reasampler_test(live_params LINK live_params)
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and # Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
@@ -28,7 +28,7 @@ reasampler_test(live_params LINK live_params)
# boundary costs the hot path nothing. # boundary costs the hot path nothing.
reasampler_pure_library(sampler_core reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp SOURCES voice.cpp voice_engine.cpp
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params) LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law)
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3 # Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
# or REAPER type reaching the core would fail to compile or link here. # or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core) reasampler_test(sampler_core LINK sampler_core)
@@ -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)
+174 -111
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,153 @@ 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, /*latchFinished=*/false); // a fresh note starts from a clean read
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, /*latchFinished=*/false);
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_);
// LATCHED: a voice already read past its fitted total must never resurge because a
// later live move reopened the total. Reachable on any active() voice, including one
// ringing out past its own end (voice.h) where tickAmplitude() still runs.
fit(params, /*latchFinished=*/true);
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; // `latchFinished`: once true, a re-fit can only ever KEEP finished_ true, never clear it —
std::int64_t fadeIn_ = 0; // see applyLive above for why. configure()/snapLive() pass false: those are a fresh read
std::int64_t fadeOut_ = 0; // (new note or a not-yet-rendered voice), which must compute finished_ from scratch.
FadeCurve curve_ = kDefaultFadeCurve; void fit(const AhdParams& p, bool latchFinished) {
bool finished_ = false; fit_ = fitAhd(span_, p);
attackCurve_ = p.attackCurve;
decayCurve_ = p.decayCurve;
const bool empty = (fit_.total <= 0);
finished_ = latchFinished ? (finished_ || empty) : empty;
}
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); // The position under `next` holding the normalized position within whichever leg pos_ is
// in. A leg dialled to zero completes: the position lands on that leg's new end.
double holdPhase(const AhdSpan& old, const AhdSpan& next) const {
const double oa = static_cast<double>(old.attack);
const double oh = static_cast<double>(old.hold);
const double od = static_cast<double>(old.decay);
const double na = static_cast<double>(next.attack);
const double nh = static_cast<double>(next.hold);
const double nd = static_cast<double>(next.decay);
if (pos_ < oa) return (na > 0.0) ? pos_ * (na / oa) : na;
if (pos_ < oa + oh) return (nh > 0.0) ? na + (pos_ - oa) * (nh / oh) : na + nh;
if (pos_ < oa + oh + od) {
return (nd > 0.0) ? na + nh + (pos_ - oa - oh) * (nd / od) : na + nh + nd;
} }
if (pos_ < a + d) { return na + nh + nd; // already past the envelope: stay past it under the new shape
// Decay: peak -> 0 over decayFrames (settle to base pitch).
return params.peakSemitones * (1.0 - (pos_ - a) / d);
}
return 0.0; // past attack+decay: at base pitch forever.
} }
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.
+41 -24
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 — fitAhd (envelopes.h) owns
// why a fraction, not a time.
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; double peakSemitones = 0.0; // signed depth at the peak
std::int64_t decayFrames = 0; AhdParams shape{0, 0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
double peakSemitones = 0.0; // signed depth at the peak
}; };
// 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;
+47 -21
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,29 @@ 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. postStart is a SOURCE-frame count
// and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
// rate, so its two domains already coincide.
// Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x
// envFactor — a deep pitch envelope makes this a first-order approximation, not exact.
// Strictly better than the un-converted source-frame span it replaced.
const double pitchSpan =
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
? static_cast<double>(postStart)
: static_cast<double>(postStart) / baseRatio_;
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), 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 +132,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));
filterEnv_.configure(p.filter.env); if (playMode_ == PlayMode::Gate) {
filterEnv_.noteOn(); filterEnv_.configure(p.filter.env);
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 +213,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;
@@ -248,6 +271,9 @@ void Voice::retune(int note) {
// legato phrase is one gesture, one strike (classic mono-synth behavior). // legato phrase is one gesture, one strike (classic mono-synth behavior).
if (!active_ || sample_ == nullptr) return; if (!active_ || sample_ == nullptr) return;
note_ = note; note_ = note;
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the
// first note's domain — consistent with "touch nothing else," but the drift lives here.
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack); baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it // Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
// too. The velocity offset deliberately stays the first note's, matching velocityGain_. // too. The velocity offset deliberately stays the first note's, matching velocityGain_.
+58 -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
@@ -326,7 +348,14 @@ private:
// play span ends within the ramp). With no declick (the common case) this is // play span ends within the ramp). With no declick (the common case) this is
// 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)) {
// The NOTE is over the moment the read head leaves its span, whether or not a ramp
// still rings: no later frame can carry envelope output. Latching here is what keeps
// a ringing-out voice out of soundingNote() — the Preserve cap would otherwise
// refuse a new onset, and mono legato would retune a voice already past its end
// (silencing the new note) for the whole ~4 ms ramp.
amplitudeDone_ = true;
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 +379,18 @@ 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 of THIS
// site only: its amplitude reaches zero through a release, so nothing here is cut
// mid-level. The exhaustion path above deliberately does NOT exclude Gate — a held Gate
// note whose source runs out with no loop is cut at its sustain level, and under
// Preserve that cut lands on the same recycled synthetic tail.
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 +559,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 +575,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;
+5 -3
View File
@@ -17,9 +17,11 @@ 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 curve_law)
# 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.
reasampler_test(component_state_io LINK component_state_io) reasampler_test(component_state_io LINK component_state_io)
@@ -27,7 +29,7 @@ reasampler_test(component_state_io LINK component_state_io)
# The mapping's product is plain SampleData, so the voice engine is not a dependency. # The mapping's product is plain SampleData, so the voice engine is not a dependency.
reasampler_pure_library(sample_map reasampler_pure_library(sample_map
SOURCES sample_map.cpp SOURCES sample_map.cpp
LINK PUBLIC bank_book wav_codec velocity_curve peaks) LINK PUBLIC bank_book wav_codec velocity_curve peaks curve_law)
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning # Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
# both halves of the mapping/codec split where the frozen-format assertions live. # both halves of the mapping/codec split where the frozen-format assertions live.
reasampler_test(sample_map LINK sample_map component_state_io) reasampler_test(sample_map LINK sample_map component_state_io)
+6 -269
View File
@@ -1,292 +1,29 @@
// 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 {
using engine::masterGainMaxLinear; using engine::masterGainMaxLinear;
using reasampler::wire::ByteReader; using reasampler::wire::ByteReader;
using reasampler::wire::asU64;
using reasampler::wire::bitsToDouble; using reasampler::wire::bitsToDouble;
using reasampler::wire::doubleToBits; using reasampler::wire::doubleToBits;
using reasampler::wire::putLE; using reasampler::wire::putLE;
namespace { namespace {
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
// payload that carried at least one zone: the first zone's capture, which supersedes the
// envelope's selection id (see the adoption rule in the header).
struct PayloadRead {
InstrumentParams params;
std::string adoptedSampleId;
};
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
// the two shapes cannot drift byte-for-byte.
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
out.push_back(p.rootOverride ? 1 : 0);
if (p.rootOverride) {
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
}
out.push_back(p.loopOverride ? 1 : 0);
if (p.loopOverride) {
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
putLE(out, asU64(p.loopOverride->start));
putLE(out, asU64(p.loopOverride->end));
}
out.push_back(p.startPoint ? 1 : 0);
if (p.startPoint) putLE(out, asU64(*p.startPoint));
}
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
// The amp curve (v7) and the filter's own curve (v9) share this shape.
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.amp));
}
}
// Append the params payload: marker + version + the single parameter record. Always emits
// the CURRENT payload version; the marker precedes the record so any reader detects the
// shape independent of the envelope version (see component_state_io.h).
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
putLE(out, kParamsFormatMarker);
putLE(out, kParamsPayloadVersion);
putOverrides(out, p);
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
const PlaySeconds& pp = p.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
putLE(out, doubleToBits(pp.adsr.attackSeconds));
putLE(out, doubleToBits(pp.adsr.decaySeconds));
putLE(out, doubleToBits(pp.adsr.sustainLevel));
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
// Key-tracking scalar (1.0 = 100% ET).
putLE(out, doubleToBits(p.keyTrack));
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
// velocity + amp as doubles (endpoints included, so N >= 2).
putCurve(out, p.velocityCurve);
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
// whole payload stays one numeric shape.
const FilterSeconds& f = pp.filter;
out.push_back(f.enabled ? 1 : 0);
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
putLE(out, doubleToBits(f.modAmount));
putLE(out, doubleToBits(f.velAmount));
putLE(out, doubleToBits(f.keyTrack));
putLE(out, doubleToBits(f.env.attackSeconds));
putLE(out, doubleToBits(f.env.holdSeconds));
putLE(out, doubleToBits(f.env.decaySeconds));
putLE(out, doubleToBits(f.env.sustainLevel));
putLE(out, doubleToBits(f.env.releaseSeconds));
putCurve(out, f.velocityCurve);
}
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
// v8 single-record reader so the two can never disagree about field order.
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
p.play.trigger.fadeInFrames = r.i64();
p.play.trigger.fadeOutFrames = r.i64();
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
p.play.pitchEnv.enabled = (r.u8() != 0);
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
// defensively; a truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
// can't trigger a giant allocation before the bounded reads fail.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, amp});
}
if (r.ok) {
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
}
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec.
void readFilterTail(ByteReader& r, InstrumentParams& p) {
FilterSeconds& f = p.play.filter;
f.enabled = (r.u8() != 0);
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
double modAmount = bitsToDouble(r.u64());
double velAmount = bitsToDouble(r.u64());
double keyTrack = bitsToDouble(r.u64());
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
f.env.attackSeconds = bitsToDouble(r.u64());
f.env.holdSeconds = bitsToDouble(r.u64());
f.env.decaySeconds = bitsToDouble(r.u64());
f.env.sustainLevel = bitsToDouble(r.u64());
f.env.releaseSeconds = bitsToDouble(r.u64());
readCurveTail(r, f.velocityCurve);
}
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
// no file and no bank entry.
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
// blobs carry seconds directly and need no rate.
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
PayloadRead out;
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
const std::uint32_t count = r.u32();
bool adopted = false;
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
InstrumentParams p;
std::string sampleId;
const std::uint32_t idLen = r.u32();
sampleId = r.str(idLen);
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
r.i32(); // highNote
const std::uint8_t hasOverride = r.u8();
if (hasOverride) p.rootOverride = r.i32();
if (extended) {
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
p.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) p.startPoint = r.i64();
}
if (legacyV3Play) {
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
p.play.trigger.fadeInFrames = r.i64();
p.play.trigger.fadeOutFrames = r.i64();
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
p.play.pitchEnv.enabled = (r.u8() != 0);
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
} else if (secondsPlay) {
readSecondsPlayTail(r, p);
}
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (curveTail) readCurveTail(r, p.velocityCurve);
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// leave the seconds product defaults on p.play.
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
if (!adopted) {
out.params = std::move(p);
out.adoptedSampleId = std::move(sampleId);
adopted = true;
}
}
return out;
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
// v1 (a plain small zone count).
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
std::uint32_t pv = 0; // 0 = v1, no marker
if (r.peekU32() == kParamsFormatMarker) {
r.u32(); // consume the marker
pv = r.u32(); // payload version
}
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
PayloadRead out;
InstrumentParams& p = out.params;
const std::uint8_t hasRoot = r.u8();
if (hasRoot) p.rootOverride = r.i32();
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
p.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) p.startPoint = r.i64();
readSecondsPlayTail(r, p);
p.keyTrack = bitsToDouble(r.u64());
readCurveTail(r, p.velocityCurve);
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
return out;
}
// Apply a payload read to the state: the adoption rule (a retired payload's first zone // 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) {
+43 -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,22 +71,52 @@ 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.
//
// LOSSY UNDER A RATE MISMATCH. The fades were SOURCE frames and the AHD stores wall-clock
// seconds, so the lift divides by the PROJECT rate while the build later multiplies by the
// DECODE rate: a file whose own rate differs from the project's comes back scaled by that
// ratio (a 441-frame fade on a 44.1 kHz file in a 48 kHz project resolves to 405 source
// frames, ~8% short). The codec cannot close this — it never sees the file — and deferring the
// lift to build time would mean carrying the retired fade pair through the parameter set,
// reintroducing the mechanism the AHD replaced.
// //
// A truncated/unknown/empty payload yields the DEFAULT parameter set. // A truncated/unknown/empty payload yields the DEFAULT parameter set.
// The exponents the lifted fades take. The AHD's curve law is phi^p (core/util/curve_law.h),
// which cannot reproduce the retired pair's equal-power sin/cos exactly at ANY exponent — so
// the lift takes the MINIMAX fit rather than the linear neutral, which is free (one constant,
// written once here) and several times closer. The two differ because the two stages fit
// different forms: attack fits phi^p to sin(pi*phi/2), decay fits 1 - t^q to cos(pi*t/2).
// The measured bounds are asserted in tests/test_component_state_io.cpp. Every OTHER curve on
// a migrated blob still lifts to the neutral — only the fades had a prior shape to reproduce.
inline constexpr double kTriggerFadeLiftAttackCurve = 0.6133;
inline constexpr double kTriggerFadeLiftDecayCurve = 1.7437;
inline constexpr std::uint32_t kPerformanceStateVersion = 2; 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 +129,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
+360
View File
@@ -0,0 +1,360 @@
// 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::asU64;
using reasampler::wire::bitsToDouble;
using reasampler::wire::doubleToBits;
using reasampler::wire::putLE;
namespace {
// 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 seconds conversion
// and its rate-mismatch bound, and the two fitted exponents, are documented in
// component_state_io.h. A v10-or-newer blob overwrites all five fields 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;
out.attackCurve = kTriggerFadeLiftAttackCurve;
out.decayCurve = kTriggerFadeLiftDecayCurve;
}
// 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) {
// attackSeconds/decaySeconds reach resolvePlay's static_cast<std::int64_t> (sample_map.cpp)
// unguarded — UB on NaN, and on a large-enough finite value — so a corrupt/non-finite wire
// value degrades to 0 seconds rather than reaching that cast, the same guard readSecondsPlayTail
// and the v9 filter tail already apply to their own wall-clock fields.
const double attack = bitsToDouble(r.u64());
const double decay = bitsToDouble(r.u64());
a.attackSeconds = std::isfinite(attack) ? attack : 0.0;
a.decaySeconds = std::isfinite(decay) ? decay : 0.0;
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
+22 -5
View File
@@ -203,8 +203,8 @@ DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
} }
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) { PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length + // seconds -> frames at the LIVE rate; the source-timeline quantity (trigger %-length)
// fades) carry through untouched, already frames/fractions. // carries through untouched, already a fraction.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) { const auto secToFrames = [sr](double sec) {
@@ -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;
} }
+25 -9
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 peakSemitones = 0.0; // signed depth at the peak double holdFraction = 1.0;
double attackCurve = util::kCurveNeutral;
double decayCurve = util::kCurveNeutral;
};
// The stored AHD pitch envelope. enabled + peakSemitones are dimensionless. The hold fraction
// defaults to 0 so an instance predating the stage plays as its attack-decay predecessor did.
struct PitchEnvSeconds {
bool enabled = false;
double peakSemitones = 0.0; // signed depth at the peak
AhdSeconds shape{0.0, 0.0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral};
}; };
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field // 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
+2 -2
View File
@@ -35,7 +35,7 @@ reasampler_test(browser_scroll LINK browser_scroll)
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry) reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
reasampler_test(param_slider LINK param_slider) reasampler_test(param_slider LINK param_slider)
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry) reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry curve_law)
reasampler_test(envelope_overlay LINK envelope_overlay) reasampler_test(envelope_overlay LINK envelope_overlay)
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay) reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
@@ -51,7 +51,7 @@ reasampler_test(knob_deck LINK knob_deck)
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked. # MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
reasampler_pure_library(deck_groups reasampler_pure_library(deck_groups
SOURCES deck_groups.cpp SOURCES deck_groups.cpp
LINK PUBLIC knob_deck velocity_curve peaks) LINK PUBLIC knob_deck velocity_curve peaks curve_law)
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion # sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
# needs the band allocator deck_groups itself has no reason to depend on. # needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands) reasampler_test(deck_groups LINK deck_groups sample_bands)
+90 -15
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.cellIds = {id(DeckParam::kFilterEnvAttack), fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
id(DeckParam::kFilterEnvHold), if (trigger) {
id(DeckParam::kFilterEnvDecay), fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
id(DeckParam::kFilterEnvSustain), id(DeckParam::kFilterTrigDecay), -1, -1};
id(DeckParam::kFilterEnvRelease)}; } else {
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
id(DeckParam::kFilterEnvHold),
id(DeckParam::kFilterEnvDecay),
id(DeckParam::kFilterEnvSustain),
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,39 @@ 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) { OverlayEnv overlayEnvForRadio(int radioId) {
switch (static_cast<DeckParam>(radioId)) {
case DeckParam::kAmpEnvSelect: return OverlayEnv::kAmp;
case DeckParam::kPitchEnvSelect: return OverlayEnv::kPitch;
case DeckParam::kFilterEnvSelect: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
const OverlayEnv picked = overlayEnvForRadio(radioId);
if (picked == OverlayEnv::kNone) return current; // not a radio: nothing selects
return (current == picked) ? OverlayEnv::kNone : picked;
}
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) {
switch (env) {
case OverlayEnv::kPitch: return !pitchEnvEnabled;
case OverlayEnv::kFilter: return !filterEnabled;
case OverlayEnv::kAmp:
case OverlayEnv::kNone:
return false;
}
return false; // unreachable for a valid enumerator; silences a warning.
}
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;
} }
+74 -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,28 @@ 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);
// Which envelope the waveform overlay draws and edits. Exclusive across the three envelope
// decks, 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 };
// The envelope a deck's overlay-select radio picks; kNone for any other control id.
OverlayEnv overlayEnvForRadio(int radioId);
// The selection a click on `radioId` produces from `current`. Two rules, provable here rather
// than in the shell: picking another deck's radio switches to it (exclusivity), and clicking
// the ACTIVE one clears back to kNone — "no envelope shown" is a state the user can get back
// to, not an error. A non-radio id leaves the selection alone.
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
// Whether the overlay for `env` is INERT: its deck group's enable toggle is off, so its knobs
// are drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches
// a param a knob couldn't (envelope_edit.h). Amp has no enable toggle and is never inert.
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled);
// 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
+163 -69
View File
@@ -2,11 +2,18 @@
#include "core/instrument/ui/envelope_edit.h" #include "core/instrument/ui/envelope_edit.h"
#include "core/util/clamp01.h"
#include <algorithm> #include <algorithm>
#include <cmath> // std::fabs
#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 +37,108 @@ 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 (curve_law.h owns why the knot and the inner dial share this one law).
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 span = seg.end - seg.start;
// segmentLevels only rejects an EXACTLY level segment; a near-level one (e.g. sustain
// 0.99) still passes with a tiny divisor here, so one pixel of drag can swing `u` by
// ~1.0 and saturate the exponent. Floor the magnitude at a couple of pixels' worth of
// level travel — a segment thinner than that is visually a no-op drag anyway.
if (std::fabs(span) < 2.0 * levelPerPixel(area)) return grabExponent;
const double grabLevel = seg.start + span * curveMidLevel(grabExponent);
const double newLevel = grabLevel - static_cast<double>(dyPixels) * levelPerPixel(area);
return curveFromMidLevel((newLevel - seg.start) / span);
}
// An AHD's DecayEnd moves decaySeconds via X, scaled by 1/(1 - holdFraction) — see
// resolveNodeDrag's DecayEnd case. At holdFraction == 1.0 that derivative is exactly 0, so a
// drag there can never change anything; when it ALSO coincides with HoldEnd (decay ~ 0) it is a
// dead handle sitting on top of a live one. Excluded from the grabbable set in that exact case
// only — a functional DecayEnd (holdFraction < 1) stays grabbable even when it coincides.
bool ahdDecayEndIsDead(const StageEnvelope& env, const std::vector<EnvVertex>& poly) {
if (env.kind != EnvKind::Ahd) return false;
if (1.0 - clamp01(env.holdFraction) > 1e-9) return false;
EnvVertex hold, decay;
bool haveHold = false, haveDecay = false;
for (const EnvVertex& v : poly) {
if (v.node == EnvNode::HoldEnd) { hold = v; haveHold = true; }
else if (v.node == EnvNode::DecayEnd) { decay = v; haveDecay = true; }
}
return haveHold && haveDecay && hold.x == decay.x;
}
} // 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); const bool dropDeadDecayEnd = ahdDecayEndIsDead(env, poly);
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out // Nearest draggable, kind-matching node within the pick radius wins (Chebyshev distance);
// coincidence (FadeOutStart overlaps LengthEnd and wins). // ties go to the earlier draw-order node. Knots are appended last, so a knot coincident
// with an endpoint handle loses — a drag there stays a time edit.
NodeHit best; 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;
if (dropDeadDecayEnd && v.node == EnvNode::DecayEnd) 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 +148,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 +160,91 @@ 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) {
// Each cumulative-time node edits its own segment duration. Non-negative durations
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node
// can never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds =
std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double dLevel = -static_cast<double>(dyPixels) * levelPerPixel(rect);
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
break;
}
case EnvNode::ReleaseStart:
// The release runs from this node to the anchored right edge, so dragging LEFT
// (negative dx) lengthens it — the delta enters with the opposite sign.
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds - gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
case EnvNode::AttackCurve:
out.attackCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
break;
case EnvNode::DecayCurve:
out.decayCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
break;
case EnvNode::ReleaseCurve:
out.releaseCurve =
curveFromKnotDrag(grabEnv, node, grabEnv.releaseCurve, rect, dyPixels);
break;
default:
break;
}
return out;
}
// AHD: the x-axis is the waveform's own, so a stage node moves at 1:1 wall-clock scale.
const AhdSplit s = splitAhdSeconds(grabEnv);
switch (node) { switch (node) {
// Gate: each cumulative-time node edits its own segment duration. Non-negative durations
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
// never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd: case EnvNode::AttackEnd:
out.attackSeconds = out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); std::clamp(grabEnv.attackSeconds + dSec, 0.0, bounds.maxAttackSeconds);
break; break;
case EnvNode::HoldEnd: case EnvNode::HoldEnd: {
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds); // 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; break;
}
case EnvNode::DecayEnd: { case EnvNode::DecayEnd: {
// X sets decay time, Y sets sustain level (drag down = higher y = lower level). // DecayEnd is DRAWN at t0 + total, and total = attack + decay + (span-attack-decay)
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); // * holdFraction, so d(total)/d(decay) = 1 - holdFraction: Hold eats a holdFraction
const double lvlPerPx = levelPerPixel(rect); // share of whatever decay gives up. Scaling by 1/(1-frac) makes the drawn endpoint
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx; // track the cursor 1:1, matching every other node. At frac == 1.0 (the Trigger
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0); // default) Hold consumes the WHOLE remainder regardless of decay's value, so the
// derivative is exactly 0 — no scale recovers motion there, and decaySeconds is left
// unchanged rather than divided by zero.
const double denom = 1.0 - clamp01(grabEnv.holdFraction);
if (denom > 1e-9) {
out.decaySeconds =
std::clamp(grabEnv.decaySeconds + dSec / denom, 0.0, bounds.maxDecaySeconds);
}
break; break;
} }
case EnvNode::ReleaseEnd: case EnvNode::AttackCurve:
out.releaseSeconds = out.attackCurve =
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds); curveFromKnotDrag(grabEnv, node, grabEnv.attackCurve, rect, dyPixels);
break; break;
case EnvNode::DecayCurve:
// Trigger: fades + length are fractions. X pixels convert to a fraction of the played out.decayCurve = curveFromKnotDrag(grabEnv, node, grabEnv.decayCurve, rect, dyPixels);
// 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; break;
} default:
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; break;
}
case EnvNode::LengthEnd: {
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
break;
}
case EnvNode::Origin:
case EnvNode::ReleaseStart:
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
} }
return out; return out;
} }
+31 -40
View File
@@ -1,19 +1,13 @@
// 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. The inverse of envelope_overlay's forward
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in. // (draw) map; both read/write the same StageEnvelope fields, so node drag / knot drag / knob
// edit are one model — see core/instrument/CLAUDE.md's "envelope overlay" section for why.
// Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested outside the DAW.
// //
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse // Time-only nodes drag on X; DecayEnd in an AHDSR drags on both axes (X = decay time, Y =
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one // sustain level); a curve knot drags on Y alone (curve_law.h owns the exponent domain). Origin
// parameter set every paint), so a node drag and a slider edit are two views on one source of // is never draggable, and neither is an AHDSR's ReleaseEnd — anchored to the right edge, with
// truth. // release dragged from ReleaseStart instead. A node is only editable in its own kind.
//
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
// slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
//
// Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
// Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
// editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
#pragma once #pragma once
@@ -21,7 +15,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,46 +23,43 @@ 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. An AHD's DecayEnd is excluded
// can be dragged open from zero). Gate nodes never coincide (forward map enforces // entirely when it coincides with HoldEnd AND holdFraction == 1.0 — there it cannot move
// kGateNodeSepPx), so every Gate handle is independently grabbable. // (resolveNodeDrag's derivative is 0), so it is dropped rather than won by draw order.
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);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+114 -65
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,26 @@ 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 read through curve_law.h's own law (the knot/dial pairing's home).
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 +112,93 @@ 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
xHold = std::min(xHold, xDecay - sep); // leaves the plateau running to within one separation of the edge.
xAttack = std::min(xAttack, xHold - sep); double xAttack = sep + a * pps;
xAttack = std::max(xAttack, sep); double xHold = xAttack + sep + h * pps;
xHold = std::max(xHold, xAttack + sep); double xDecay = xHold + sep + d * pps;
xDecay = std::max(xDecay, xHold + sep); double xPlateau = xMax - sep - r * pps;
xPlateau = std::max(xPlateau, xDecay + sustainPx); const double xRelease = xMax;
xRelease = std::max(xRelease, xPlateau + sep);
// Keep every node separated when the four stages together would overrun the canvas: the
// plateau holds its minimum gap from the edge, then the A/H/D chain compresses from the
// right and re-floors from the left. This only bites at the domain's extremes; gateVtx's
// own clamp wins on a canvas too narrow to hold the gaps at all.
if (xPlateau < xDecay + sep) {
if (xPlateau < 4.0 * sep) xPlateau = 4.0 * sep;
xDecay = std::min(xDecay, xPlateau - sep);
xHold = std::min(xHold, xDecay - sep);
xAttack = std::min(xAttack, xHold - sep);
xAttack = std::max(xAttack, sep);
xHold = std::max(xHold, xAttack + sep);
xDecay = std::max(xDecay, xHold + sep);
xPlateau = std::max(xPlateau, xDecay + sep);
} }
std::vector<EnvVertex> pts; 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 // DecayEnd is drawn at t0 + total, which coincides with HoldEnd exactly when decay ~ 0 —
// independent of holdFraction (total = attack + hold + decay always). Left at its true
// instant rather than nudged: the 1:1 axis this policy exists to keep honest must hold even
// at a shared instant, including the Trigger default's abrupt (zero-decay) cutoff.
// envelope_edit's nodeAtPoint handles the coincidence instead, by dropping DecayEnd from the
// grabbable set when it also cannot move (holdFraction == 1.0).
pts.push_back(vtx(EnvNode::DecayEnd, area, totalSeconds, t0 + s.total, 0.0));
if (s.attack > 0.0) {
pts.push_back(knotVtx(EnvNode::AttackCurve, area, pts[0].x, pts[1].x, 0.0, 1.0,
env.attackCurve));
}
if (s.decay > 0.0) {
pts.push_back(knotVtx(EnvNode::DecayCurve, area, pts[2].x, pts[3].x, 1.0, 0.0,
env.decayCurve));
}
return pts; 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 +206,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 -51
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,97 @@
#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 — see core/instrument/CLAUDE.md's "envelope overlay"
enum class EnvMode { Gate, Trigger }; // section for why (decided by sustain-stage presence, not by which processor it modulates).
// Ahdsr right-anchors its release; Ahd maps 1:1 onto the waveform's own time axis.
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 +110,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
+10 -1
View File
@@ -3,12 +3,21 @@
## 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, the
mid-segment inverse an overlay knot drags through, and the inner dial's own norm<->exponent
travel. 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, and what the knob law's centre detent exists to keep
reachable from the dial.
- `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)
+76
View File
@@ -0,0 +1,76 @@
#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.
//
// MEASURED (Release, MSVC 19.44 x64, one dev machine): a non-neutral exponent costs ~4.7 ns per
// evaluation. At the 16-voice default with all three envelopes in a sloped stage — three
// evaluations per voice per frame, the worst case — that is +224 ns per output frame: 3.1% of
// one core at 44.1 kHz becomes 4.1%. Affordable at the shipped voice ceiling; re-measure before
// putting a fourth per-voice curve on the frame.
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 exponent's KNOB travel: logarithmic, so the two halves of the throw are the reciprocal
// shaping directions. Written as kCurveMax^(2t-1) rather than as an interpolation between
// log(kCurveMin) and log(kCurveMax) so t == 0.5 evaluates exp(0) == 1.0 EXACTLY — an inexact
// centre would put std::pow on the per-sample path for a stage the user believes is parked at
// the identity. Requires kCurveMin == 1/kCurveMax, which the domain above satisfies.
//
// The detent is what makes the identity REACHABLE: a knob drag delivers start - dy/128, a grid
// that lands on 0.5 only by luck, so a band wider than one drag step (1/128) snaps to neutral
// and a dial swept through the centre cannot skip over it.
inline constexpr double kCurveKnobDetent = 0.01;
inline double curveFromKnobNorm(double norm) {
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
const double off = t - 0.5;
if (off < kCurveKnobDetent && off > -kCurveKnobDetent) return kCurveNeutral;
return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax)));
}
inline double knobNormFromCurve(double exponent) {
const double t = 0.5 + std::log(clampCurve(exponent)) / (2.0 * std::log(kCurveMax));
return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
}
// 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
+5
View File
@@ -30,6 +30,11 @@ inline void putLE(std::vector<std::uint8_t>& out, T v) {
} }
} }
// Signed 64-bit values ride the wire as their two's-complement unsigned image (the putLE
// call site's u64 cast). The one home for that cast — component_state_io and params_payload
// both need it and must not carry their own copies.
inline std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined // IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined
// type-pun in C++17). Doubles ride the wire as their u64 bit image via putLE. // type-pun in C++17). Doubles ride the wire as their u64 bit image via putLE.
inline std::uint64_t doubleToBits(double d) { inline std::uint64_t doubleToBits(double d) {
+1 -1
View File
@@ -85,7 +85,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
capture_browser keyboard_strip sample_bands sample_chrome capture_browser keyboard_strip sample_bands sample_chrome
waveform_view bank_sync browser_scroll param_slider tooltip waveform_view bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups curve_popup master_gain sample_usage file_bytes) knob_deck deck_groups curve_popup master_gain sample_usage file_bytes curve_law)
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives # SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC. # LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC}) target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
+224 -99
View File
@@ -1,8 +1,9 @@
// 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"
@@ -14,17 +15,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 +45,38 @@ 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 — //
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in // The ceiling is READ from the overlay's schematic scale rather than restated: the AHDSR
// rate constant, per the no-hardcoded-rate ruling. // schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) constexpr double kEnvTimeMaxSeconds = instrument::ui::kGateStageMaxSeconds;
constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) constexpr double kPitchDepthMaxSemis = 24.0; // pitch depth throw: +/-24 st, centered
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%)
// The raw stored curve exponent for a curve-dial control id, read DIRECTLY off the field —
// never round-tripped through curveFromKnobNorm(knobNormFromCurve(x)): the knob-norm law's
// centre detent (curve_law.h) snaps anything near-neutral back to exactly 1.0, so a round trip
// can misreport a stored exponent that isn't neutral as "^1.00".
double curveExponentFor(int id, const PlaySeconds& play) {
using DeckParam = instrument::ui::DeckParam; // ReaSamplerEditor::ParamControl is an alias
switch (static_cast<DeckParam>(id)) {
case DeckParam::kAttackCurve: return play.adsr.attackCurve;
case DeckParam::kDecayCurve: return play.adsr.decayCurve;
case DeckParam::kReleaseCurve: return play.adsr.releaseCurve;
case DeckParam::kTrigAttackCurve: return play.trigAhd.attackCurve;
case DeckParam::kTrigDecayCurve: return play.trigAhd.decayCurve;
case DeckParam::kPitchEnvAttackCurve: return play.pitchEnv.shape.attackCurve;
case DeckParam::kPitchEnvDecayCurve: return play.pitchEnv.shape.decayCurve;
case DeckParam::kFilterEnvAttackCurve: return play.filter.env.attackCurve;
case DeckParam::kFilterEnvDecayCurve: return play.filter.env.decayCurve;
case DeckParam::kFilterEnvReleaseCurve: return play.filter.env.releaseCurve;
case DeckParam::kFilterTrigAttackCurve: return play.filter.trigEnv.attackCurve;
case DeckParam::kFilterTrigDecayCurve: return play.filter.trigEnv.decayCurve;
default: return util::kCurveNeutral;
}
}
} // namespace } // namespace
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const { ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
@@ -69,16 +91,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 +102,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 util::knobNormFromCurve(play.adsr.attackCurve);
case ParamControl::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
case ParamControl::kReleaseCurve: return util::knobNormFromCurve(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 util::knobNormFromCurve(play.trigAhd.attackCurve);
case ParamControl::kTrigDecayCurve: return util::knobNormFromCurve(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 util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
case ParamControl::kPitchEnvDecayCurve:
return util::knobNormFromCurve(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 +139,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 util::knobNormFromCurve(play.filter.env.attackCurve);
case ParamControl::kFilterEnvDecayCurve:
return util::knobNormFromCurve(play.filter.env.decayCurve);
case ParamControl::kFilterEnvReleaseCurve:
return util::knobNormFromCurve(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 util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
case ParamControl::kFilterTrigDecayCurve:
return util::knobNormFromCurve(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 +171,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 = util::curveFromKnobNorm(value); break;
case ParamControl::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(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 = util::curveFromKnobNorm(value); break;
case ParamControl::kTrigDecayCurve:
play.trigAhd.decayCurve = util::curveFromKnobNorm(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 = util::curveFromKnobNorm(value); break;
case ParamControl::kPitchEnvDecayCurve:
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(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 +228,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 = util::curveFromKnobNorm(value); break;
case ParamControl::kFilterEnvDecayCurve:
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
case ParamControl::kFilterEnvReleaseCurve:
play.filter.env.releaseCurve = util::curveFromKnobNorm(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 = util::curveFromKnobNorm(value); break;
case ParamControl::kFilterTrigDecayCurve:
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
default: break; default: break;
} }
} }
@@ -187,18 +252,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 +320,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 +377,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", curveExponentFor(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 +406,110 @@ 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, namespace {
std::int64_t startFrame) const { // The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
AmpEnvelope env; // one place rather than two.
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) {
// AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). env.kind = instrument::ui::EnvKind::Ahdsr;
env.attackSeconds = play.adsr.attackSeconds; env.attackSeconds = a.attackSeconds;
env.holdSeconds = play.adsr.holdSeconds; env.holdSeconds = a.holdSeconds;
env.decaySeconds = play.adsr.decaySeconds; env.decaySeconds = a.decaySeconds;
env.sustainLevel = play.adsr.sustainLevel; env.sustainLevel = a.sustainLevel;
env.releaseSeconds = play.adsr.releaseSeconds; env.releaseSeconds = a.releaseSeconds;
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played env.attackCurve = a.attackCurve;
// span (the trigger-seam converter, pack direction). startFrame is the effective start env.decayCurve = a.decayCurve;
// point so the fraction denominator matches the voice's actual post-start span. A zero play env.releaseCurve = a.releaseCurve;
// length yields 0 fractions. }
env.lengthFraction = play.trigger.lengthFraction; void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.holdSeconds = env.holdSeconds;
a.decaySeconds = env.decaySeconds;
a.sustainLevel = env.sustainLevel;
a.releaseSeconds = env.releaseSeconds;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
a.releaseCurve = env.releaseCurve;
}
void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahd;
env.attackSeconds = a.attackSeconds;
env.decaySeconds = a.decaySeconds;
env.holdFraction = a.holdFraction;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.originSeconds = originSeconds;
env.spanSeconds = spanSeconds;
}
void unpackAhd(const StageEnvelope& env, AhdSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.decaySeconds = env.decaySeconds;
a.holdFraction = env.holdFraction;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
}
} // namespace
StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play,
std::int64_t frames,
std::int64_t startFrame) const {
StageEnvelope env;
const double rate = liveSampleRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
const std::int64_t playLen = 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:
if (trigger) packAhd(play.trigAhd, t0, playSpan, env);
else packAhdsr(play.adsr, env);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active; a default-constructed StageEnvelope, not amp
}
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
@@ -44,6 +44,7 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
if (contains(cr.velCell, x, y)) { if (contains(cr.velCell, x, y)) {
drag_ = DragKind::kDeckKnob; drag_ = DragKind::kDeckKnob;
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param) dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
dragInnerCellId_ = -1; // peer of mouseDownDeck's own grab-time init, for symmetry
dragKnobStartValue_ = previewVelocity01(); dragKnobStartValue_ = previewVelocity01();
dragStartX_ = x; dragStartX_ = x;
dragStartY_ = y; dragStartY_ = y;
+22 -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,11 @@ 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) {
overlayEnv_ = nextOverlaySelection(overlayEnv_, hit.id);
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 +96,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 +134,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};
} }
+15 -12
View File
@@ -28,11 +28,16 @@ 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 — or with its deck group's enable toggle off, which makes the
// same params' knobs inert — there are no grabbable nodes and the markers take every grab.
const double rate = liveSampleRate(); const double rate = liveSampleRate();
if (rate > 0.0) { const bool nodesLive =
overlayEnv_ != OverlayEnv::kNone &&
!overlayEnvInert(overlayEnv_, params_.play.pitchEnv.enabled, params_.play.filter.enabled);
if (rate > 0.0 && nodesLive) {
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 +47,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 +72,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 (curve_law.h owns the knot/dial pairing this
// ties into). 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
+50 -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,26 @@ 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) {
// overlayEnvForRadio returns kNone for BOTH "not a radio id" and "no selection" —
// a non-radio id must never read as lit just because nothing is selected, so the
// picked env has to be checked against kNone itself, not just matched by equality.
const OverlayEnv picked = overlayEnvForRadio(g.captionRadio.id);
const bool on = picked != OverlayEnv::kNone && overlayEnv_ == picked;
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 +167,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 +176,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);
} }
} }
+35 -19
View File
@@ -24,10 +24,14 @@ using namespace reasampler::instrument::ui; // lanes + waveform geometry
using audio::computeEnvelope; using audio::computeEnvelope;
namespace { namespace {
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop // Marker roles — semantic, drawn through the kit's palette: start AND loop start/end both
// start/end = purple (tertiary). The loop-span fill is a faint purple. // = teal (secondary). Loop markers moved off tertiary/purple so the loop span + bars no
// longer share a role with the envelope overlay trace, which owns tertiary exclusively in
// this region (measured RGB was identical: #C2AAE8 for both). Secondary-vs-primary contrast
// here measures 1.12:1, below tertiary's 1.37:1 — accepted deliberately: markers are bars
// and a span fill, not the 1px trace that pairing was moved off of.
constexpr Role kRoleStartMarker = Role::AccentSecondary; constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentTertiary; constexpr Role kRoleLoopMarker = Role::AccentSecondary;
} // namespace } // namespace
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) { void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
@@ -100,37 +104,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);
}
prev = &v;
} }
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are // Handles: a square per draggable stage node, a ROUND knot per curvable segment. Lit
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is // accent-hot when this node is the grabbed one. Every vertex is guaranteed in-bounds; the
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles); // handle is additionally clamped inside the band so one on an edge node never overhangs
// the handle square is additionally clamped inside the band so a 6px box on an edge // into the neighbouring bands.
// node never overhangs into the neighbouring bands. const LICE_pixel handle = toLice(roleColor(Role::AccentTertiary));
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
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) {
+29 -21
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. The selection type and its
// whole state machine are the pure deck_groups module's; this alias keeps the shell's
// spelling.
using OverlayEnv = instrument::ui::OverlayEnv;
// 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,18 @@ 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 std::int64_t startFrame) const;
// seconds are rate-free and copy 1-to-1.
// PACK (draw): play params -> AmpEnvelope. `startFrame` is the effective start point. // UNPACK (commit): an edited StageEnvelope -> the play params, in place.
AmpEnvelope packEnvelope(const PlaySeconds& play, std::int64_t frames, void unpackEnvelope(OverlayEnv which, const StageEnvelope& env, PlaySeconds& play) const;
std::int64_t startFrame) const;
// UNPACK (commit): an edited AmpEnvelope -> the play params, in place.
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
PlaySeconds& play) const;
// 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 +420,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.
+216 -28
View File
@@ -7,8 +7,12 @@
// link is a regression. // link is a regression.
#include "../src/core/instrument/map/component_state_io.h" #include "../src/core/instrument/map/component_state_io.h"
#include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec
// links no engine, and this adds none)
#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 <cmath>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <limits> #include <limits>
@@ -285,12 +289,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 +340,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 +429,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 +449,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 +483,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 +551,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) --------------------------------------------
@@ -633,6 +681,26 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
CHECK(g.enabled); CHECK(g.enabled);
} }
// A non-finite attackSeconds/decaySeconds on a stored AHD (a corrupt blob) must lift to 0
// seconds rather than reach resolvePlay's static_cast<std::int64_t> (sample_map.cpp) — UB on
// NaN, and on a large-enough finite value. Mirrors
// testNonFiniteFilterFieldsLiftToTheNeutralDefault's per-field precedent, on the v10 AHD tail.
static void testNonFiniteAhdSecondsLiftToZero() {
ComponentState in;
in.selectionId = "pad";
in.params.play.trigAhd.attackSeconds = std::numeric_limits<double>::quiet_NaN();
in.params.play.trigAhd.decaySeconds = std::numeric_limits<double>::infinity();
in.params.play.filter.trigEnv.attackSeconds = -std::numeric_limits<double>::infinity();
in.params.play.filter.trigEnv.decaySeconds = std::numeric_limits<double>::quiet_NaN();
const ComponentState out =
deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.play.trigAhd.attackSeconds == 0.0);
CHECK(out.params.play.trigAhd.decaySeconds == 0.0);
CHECK(out.params.play.filter.trigEnv.attackSeconds == 0.0);
CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0);
}
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of // The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
// the payload — the self-describing property every legacy branch depends on. Asserted // the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals. // against the semantic constants, not literals.
@@ -707,13 +775,130 @@ 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.pitchEnv.shape.holdFraction == 0.0);
CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
// The ONE exception, and the reason it is one: the fades had a prior SHAPE to reproduce,
// so they lift to the fitted exponents rather than to the neutral (see the contour test).
CHECK(p.play.trigAhd.attackCurve == kTriggerFadeLiftAttackCurve);
CHECK(p.play.trigAhd.decayCurve == kTriggerFadeLiftDecayCurve);
}
// The migrated Trigger amp shape against the retired EQUAL-POWER fade pair it replaced. The
// AHD's law is phi^p and cannot reproduce sin/cos at any exponent, so the claim is a bound —
// and the bound the fitted exponents reach is several times tighter than the linear neutral's,
// which is what makes the fit worth a constant.
static void testMigratedFadeContourTracksTheRetiredEqualPowerShape() {
const double rate = 48000.0;
const std::int64_t fadeIn = 200;
const std::int64_t fadeOut = 300;
const std::int64_t span = 1000;
legacy::Zone z;
z.sampleId = "kick";
z.trigger = true;
z.lengthFraction = 1.0;
z.fadeIn = fadeIn;
z.fadeOut = fadeOut;
const ComponentState st =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), rate);
const AhdSeconds& lifted = st.params.play.trigAhd;
// Resolve the lifted seconds back to frames at the SAME rate the lift used, which is the
// matched-rate case (the mismatched one is asserted in sample_map_tests).
const auto toFrames = [rate](double sec) {
return static_cast<std::int64_t>(sec * rate + 0.5);
};
AhdParams migrated;
migrated.attackFrames = toFrames(lifted.attackSeconds);
migrated.decayFrames = toFrames(lifted.decaySeconds);
migrated.holdFraction = lifted.holdFraction;
migrated.attackCurve = lifted.attackCurve;
migrated.decayCurve = lifted.decayCurve;
// Stage LENGTHS are exact: the fades land on the same frames they always did.
AhdEnvelope ahd;
ahd.configure(span, migrated);
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 double pi = 3.14159265358979323846;
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) * (pi / 2.0));
}
const double foStart = static_cast<double>(span - fadeOut);
if (off >= foStart) {
return std::cos((off - foStart) / static_cast<double>(fadeOut) * (pi / 2.0));
}
return 1.0;
};
const auto worstAgainstRetired = [&](AhdEnvelope& env) {
double worst = 0.0;
for (std::int64_t i = 0; i < span; ++i) {
const double d = env.amplitudeAt(static_cast<double>(i)) -
retired(static_cast<double>(i));
worst = worst > std::fabs(d) ? worst : std::fabs(d);
}
return worst;
};
const double fitted = worstAgainstRetired(ahd);
CHECK(fitted <= 0.0876); // the measured minimax bound of phi^p against sin(pi*phi/2)
// The rejected alternative, evaluated rather than asserted about: the same lift at the
// linear neutral. If the fitted exponents were ever dropped this comparison inverts.
AhdParams neutralLift = migrated;
neutralLift.attackCurve = util::kCurveNeutral;
neutralLift.decayCurve = util::kCurveNeutral;
AhdEnvelope neutral;
neutral.configure(span, neutralLift);
const double neutralWorst = worstAgainstRetired(neutral);
CHECK(neutralWorst > 0.21);
CHECK(fitted < neutralWorst * 0.5);
// 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 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 +1057,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 +1186,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 +1272,8 @@ int main() {
testEnvelopePrefixBytesFrozen(); testEnvelopePrefixBytesFrozen();
testWriterEmitsCurrentPayloadVersion(); testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless(); testSingleZoneMigrationIsLossless();
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
testZeroFadeOutMigratesToZeroDecay();
testSingleZoneMigrationLiftsLoopDisablingOverride(); testSingleZoneMigrationLiftsLoopDisablingOverride();
testLiftedStateReSavesInCurrentFormat(); testLiftedStateReSavesInCurrentFormat();
testMultiZoneMigrationAdoptsFirstZone(); testMultiZoneMigrationAdoptsFirstZone();
@@ -1106,6 +1293,7 @@ int main() {
testV8RecordLiftsToTheOffNeutralFilter(); testV8RecordLiftsToTheOffNeutralFilter();
testFilterTailRoundTripsLosslessly(); testFilterTailRoundTripsLosslessly();
testNonFiniteFilterFieldsLiftToTheNeutralDefault(); testNonFiniteFilterFieldsLiftToTheNeutralDefault();
testNonFiniteAhdSecondsLiftToZero();
if (failures == 0) { if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n"); std::printf("component_state_io_tests: all tests passed\n");
return 0; return 0;
+193
View File
@@ -0,0 +1,193 @@
// 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; and the inner dial's own travel — exact at
// the neutral centre, and reachable there from a real drag grid.
#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);
}
// --- The inner dial's travel ---------------------------------------------------
// The knob drag delivers `start - dy/kKnobDragRangePixels`. param_slider owns that constant and
// this module deliberately does not link it, so the step is restated here; the structural
// assertion below is what keeps the detent wide enough for whatever it is.
static constexpr double kKnobStep = 1.0 / 128.0;
// The dial's centre must reach the neutral EXACTLY, in both directions — an exponent a hair off
// 1.0 costs a std::pow per sample per voice forever on a stage the user believes is at rest.
static void testKnobLawIsExactAtTheNeutralCentre() {
CHECK(knobNormFromCurve(kCurveNeutral) == 0.5);
CHECK(curveFromKnobNorm(0.5) == kCurveNeutral);
// And the identity that exactness buys: curveMap takes its bit-identical fast path.
for (int i = 0; i <= 100; ++i) {
const double phi = static_cast<double>(i) / 100.0;
CHECK(curveMap(phi, curveFromKnobNorm(0.5)) == phi);
}
}
// A dial swept THROUGH the centre has to land on the identity. The raw logarithmic travel does
// not — the drag grid steps by 1/128 and only touches 0.5 by luck — so this is the detent's own
// property, asserted against that raw travel as the reference.
static void testADialSweptThroughNeutralLandsOnTheIdentity() {
const auto rawTravel = [](double t) {
return std::exp((2.0 * t - 1.0) * std::log(kCurveMax));
};
// A real drag: grabbed at a shaped value, dragged 40 steps down through the centre.
const double grab = 0.5 + 17.0 * kKnobStep + 0.003; // deliberately off the grid
int detented = 0;
int rawHits = 0;
for (int step = 0; step <= 40; ++step) {
const double t = grab - step * kKnobStep;
if (curveFromKnobNorm(t) == kCurveNeutral) ++detented;
if (rawTravel(t) == kCurveNeutral) ++rawHits;
}
CHECK(detented >= 1); // the sweep reaches the identity
CHECK(rawHits == 0); // and would not have without the detent
// The structural reason it cannot be skipped: the band is wider than one drag step.
CHECK(kCurveKnobDetent > kKnobStep);
}
// Outside the detent the pair are inverses, so the dial reads back what it wrote and the
// endpoints saturate on the domain rather than past it.
static void testKnobLawRoundTripsOutsideTheDetent() {
const double exps[] = {kCurveMin, 0.2, 0.5, 0.8, 1.3, 2.0, 5.0, kCurveMax};
for (double e : exps) {
const double back = curveFromKnobNorm(knobNormFromCurve(e));
CHECK(std::fabs(back - e) < 1e-9);
}
// exp(-log(10)) is not guaranteed bit-exact to kCurveMin's literal 0.1 (1-2 ulp either way);
// both norms below collapse to the same t == 0.0 computation, so both get the same
// tolerance rather than leaning on clampCurve's floor to land on it by luck.
CHECK(std::fabs(curveFromKnobNorm(0.0) - kCurveMin) < 1e-12);
CHECK(std::fabs(curveFromKnobNorm(-3.0) - kCurveMin) < 1e-12); // out-of-range norm saturates
CHECK(std::fabs(curveFromKnobNorm(1.0) - kCurveMax) < 1e-12);
// The ENDS need only land on the domain, not on an exact norm — 0.1 is not exactly 1/10 in
// binary, so log(kCurveMin) is a hair off -log(kCurveMax). Only the centre carries an
// exactness requirement, and only because the neutral is a bit-identity.
CHECK(std::fabs(knobNormFromCurve(kCurveMin)) < 1e-12);
CHECK(knobNormFromCurve(kCurveMax) == 1.0);
// Monotone rising across the whole travel, so the dial has one unambiguous direction.
double prev = 0.0;
for (int i = 0; i <= 500; ++i) {
const double v = curveFromKnobNorm(static_cast<double>(i) / 500.0);
CHECK(v >= prev);
prev = v;
}
}
int main() {
testNeutralExponentIsTheIdentity();
testEndpointsAreExactAtEveryExponent();
testSweepIsFiniteMonotoneAndInRange();
testExponentDirectionShapesTheSegment();
testClampCurveHoldsTheDomain();
testMidLevelRoundTripsAgainstTheExponent();
testMidLevelInverseSaturates();
testKnobLawIsExactAtTheNeutralCentre();
testADialSweptThroughNeutralLandsOnTheIdentity();
testKnobLawRoundTripsOutsideTheDetent();
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;
}
+179 -27
View File
@@ -3,8 +3,9 @@
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), // descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit // the Filter group's contents, the wrapped deck height at the editor's floor width and its fit
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob // inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
// law's inverse pair, and the commit-tier routing — which controls are live, and which drags // law's inverse pair, the commit-tier routing — which controls are live, and which drags take
// take the live tier. // the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
// and which selections are inert).
#include "../src/core/instrument/ui/deck_groups.h" #include "../src/core/instrument/ui/deck_groups.h"
#include "../src/core/instrument/ui/sample_bands.h" #include "../src/core/instrument/ui/sample_bands.h"
@@ -78,6 +79,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 +170,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 +272,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 +299,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,35 +322,93 @@ 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)); }
// --- The overlay selection state machine ---------------------------------------
static int radio(DeckParam p) { return static_cast<int>(p); }
// EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never
// be overlay-active at once, whatever the previous selection was.
static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() {
const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch,
OverlayEnv::kFilter};
for (OverlayEnv from : states) {
if (from != OverlayEnv::kAmp) {
CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp);
}
if (from != OverlayEnv::kPitch) {
CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) ==
OverlayEnv::kPitch);
}
if (from != OverlayEnv::kFilter) {
CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) ==
OverlayEnv::kFilter);
}
}
}
// kNone is a RESTING STATE the user can get back to: clicking the active radio clears it.
static void testClickingTheActiveOverlayRadioClearsToNone() {
CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) ==
OverlayEnv::kNone);
CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) ==
OverlayEnv::kNone);
CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) ==
OverlayEnv::kNone);
}
// A control that is not one of the three radios selects nothing and clears nothing.
static void testANonRadioIdLeavesTheOverlaySelectionAlone() {
CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone);
CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone);
CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) ==
OverlayEnv::kFilter);
CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp);
}
// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on
// the same params: a node drag must not reach a value the knob refuses.
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
CHECK(overlayEnvInert(OverlayEnv::kPitch, /*pitchEnv=*/false, /*filter=*/true));
CHECK(!overlayEnvInert(OverlayEnv::kPitch, true, true));
CHECK(overlayEnvInert(OverlayEnv::kFilter, true, /*filter=*/false));
CHECK(!overlayEnvInert(OverlayEnv::kFilter, true, true));
// Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
CHECK(!overlayEnvInert(OverlayEnv::kAmp, false, false));
CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false));
} }
int main() { int main() {
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
testClickingTheActiveOverlayRadioClearsToNone();
testANonRadioIdLeavesTheOverlaySelectionAlone();
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
testEveryDeckControlIsClassifiedLiveOrReloading(); testEveryDeckControlIsClassifiedLiveOrReloading();
testOnlyALiveControlsDragTakesTheLiveTier(); testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight(); testDeckReadsPitchThenFilterThenAmpLeftToRight();
testFilterGroupCarriesItsFiveToneControlsPlusModulation(); testFilterGroupCarriesItsFiveToneControlsPlusModulation();
testOnlyTheThreeEnvelopeDecksCarryARadio();
testGateAndTriggerFacesCarryTheirOwnShapes();
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
testAmpGroupWidthSurvivesAGateTriggerFlip(); testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth(); testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow(); testDeckFitsInsideTheEnforcedMinimumWindow();
+322 -341
View File
@@ -1,24 +1,20 @@
// 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, a dead coincident AHD
// draw-order tie-break; EVERY Gate node individually grabbable at the tier-0 defaults — FA2); // DecayEnd excluded while a functional one stays grabbable); resolveNodeDrag
// resolveNodeDrag Gate (each cumulative node edits its OWN segment at the PARAM-DOMAIN px scale; // (AHDSR stage times at the schematic scale, the sustain level on Y, the release dragged from
// X->time, sustain node's Y->level; lower clamp at 0; upper clamp at the caller's max; only the // its START with the inverted sign, the caller's clamp domain, AHD stage times at the 1:1
// dragged param changes; ReleaseEnd grabbable + draggable; per-node drag round-trip tracks the // scale, the hold FRACTION); curve-knot drags (the exponent domain, its endpoints, and the
// cursor ~1:1 — FA2); resolveNodeDrag Trigger (fades as fractions of the played span; // round trip through the shared law that keeps knot and dial on one value); degenerate no-ops.
// 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 +24,53 @@ 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;
}
// F1's coincidence cases need attack/decay/fraction/span combinations ahdEnv() doesn't cover.
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;
}
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 +78,289 @@ 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() { // F1: at the Trigger default (decay 0, holdFraction 1.0) DecayEnd sits on HoldEnd's own instant
const AmpEnvelope e = gateEnv(); // AND cannot move there (resolveNodeDrag's decay branch has derivative 0 — see the denom guard).
// A grab at its true (now un-nudged) position must miss rather than resolve to a dead handle;
// HoldEnd, the live node underneath, stays fully grabbable.
static void testDeadCoincidentDecayEndIsNotGrabbable() {
const StageEnvelope e = ahd(0.5, 0.0, 1.0, 0.0, 3.0);
CHECK(!grabAt(e, EnvNode::DecayEnd).hit);
CHECK(grabAt(e, EnvNode::HoldEnd).hit);
CHECK(grabAt(e, EnvNode::HoldEnd).node == EnvNode::HoldEnd);
}
// Coincidence alone does not drop DecayEnd — only holdFraction == 1.0 makes it truly dead. With
// holdFraction < 1 the X-drag still moves decaySeconds (denom > 0), so it stays grabbable even
// when decay is 0 and it starts out coincident with HoldEnd.
static void testFunctionalCoincidentDecayEndStaysGrabbable() {
const StageEnvelope e = ahd(0.5, 0.0, 0.5, 0.0, 3.0);
CHECK(grabAt(e, EnvNode::DecayEnd).hit);
CHECK(grabAt(e, EnvNode::DecayEnd).node == EnvNode::DecayEnd);
}
static void testMissOutsideTheRadius() {
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);
// DecayEnd's underlying param (decaySeconds) does NOT move 1:1 with the cursor: the drawn
// endpoint is t0 + total, and Hold eats a holdFraction share of whatever decay gives up
// (d(total)/d(decay) = 1 - holdFraction), so decaySeconds itself has to move faster than
// the cursor to make the DRAWN node track it. Assert on the RENDERED position, not the
// raw param — that is the property a drag actually has to deliver, and asserting the old
// 1:1 param delta here is exactly what let the node-tracking defect through undetected.
EnvVertex before;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::DecayEnd, before));
const StageEnvelope decay =
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 100, 0);
EnvVertex after;
CHECK(findNode(buildEnvelopePolyline(decay, overlayOf(a), kTotal), EnvNode::DecayEnd, after));
CHECK(std::abs((after.x - before.x) - 100) <= 1); // 1:1 with the cursor, to rounding
}
// 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);
}
// A NEAR-level segment (sustain 0.99) is not caught by the exact-equality guard above, but its
// tiny divisor turns a one-pixel drag into a saturating swing of the exponent — the drag must
// still be a no-op rather than slam to a domain endpoint.
static void testKnotOnANearLevelSegmentIsANoOp() {
const Rect a = wideArea();
StageEnvelope e = ahdsrEnv();
e.sustainLevel = 0.99;
e.decayCurve = 2.5;
const StageEnvelope out =
resolveNodeDrag(e, EnvNode::DecayCurve, overlayOf(a), kTotal, bounds(), 0, -1);
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(); testDeadCoincidentDecayEndIsNotGrabbable();
testGateDefaultsEveryNodeGrabbable(); testFunctionalCoincidentDecayEndStaysGrabbable();
testMissOutsideTheRadius();
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();
testKnotOnANearLevelSegmentIsANoOp();
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);
+337 -298
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,364 +30,405 @@ 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 -------------------------------------------------------
static void testTimeToXEndpoints() { static void testTimeToXEndpoints() {
const Rect a = wideArea(); const Rect a = wideArea();
CHECK(timeToX(a, 2.0, 0.0) == a.x); // t=0 -> left CHECK(timeToX(a, 2.0, 0.0) == a.x); // t=0 -> left
CHECK(timeToX(a, 2.0, 2.0) == a.right() - 1); // t=total -> last in-bounds column CHECK(timeToX(a, 2.0, 2.0) == a.right() - 1); // t=total -> last in-bounds column
CHECK(timeToX(a, 2.0, 1.0) == a.x + 500); // midpoint CHECK(timeToX(a, 2.0, 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. const Rect a = wideArea();
CHECK(gateTimedWidth(wideArea()) == 850); CHECK(levelToY(a, 1.0) == a.y); // level 1 -> top row
// Zero-width area -> 0; a tiny area still yields >= 1 so the px<->s scale never degenerates. CHECK(levelToY(a, 0.0) == a.bottom() - 1); // level 0 -> bottom row
CHECK(gateTimedWidth(Rect::ltrb(5, 5, 5, 45)) == 0); CHECK(levelToY(a, 0.5) == a.y + 50); // 99-row span, rounded
CHECK(gateTimedWidth(Rect::ltrb(0, 0, 1, 10)) == 1); CHECK(levelToY(a, 5.0) == a.y); // clamps
CHECK(levelToY(a, -5.0) == a.bottom() - 1);
} }
static void testDegenerateAreaAndDuration() {
CHECK(timeToX(Rect{}, 2.0, 1.0) == 0);
CHECK(timeToX(wideArea(), 0.0, 1.0) == wideArea().x);
CHECK(levelToY(Rect{}, 0.5) == 0);
CHECK(gatePxPerSecond(Rect{}) == 0.0);
}
// The literal PARAM-DOMAIN scale, independent of any sample duration: usable px = canvas width
// minus the last column minus 4 node-separation bases, spread over 4 x kGateStageMaxSeconds.
// This is what makes a dragged handle track the cursor 1:1 (envelope_edit's own inverse reads
// this same function) — a scale regression here is exactly what a relational-only check misses.
static void testGatePxPerSecond() { static void testGatePxPerSecond() {
// PARAM-DOMAIN scale: (timedW - 1 - 4*sep) px spread over 4 x kGateStageMaxSeconds. For the // 967 / 8 px/s, pinned as a literal — restating the formula with the same named constants
// 1000px canvas: (850 - 1 - 32) / 8.0s = 817/8 px/s. Independent of any sample duration. // would let a change to kGateNodeSepPx or kGateStageMaxSeconds move both sides and pass
const double expected = 817.0 / (4.0 * kGateStageMaxSeconds); // silently.
CHECK(gatePxPerSecond(wideArea()) == expected); CHECK(gatePxPerSecond(wideArea()) == 120.875);
CHECK(gatePxPerSecond(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0 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 CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0
} }
static void testTimeToXDegenerate() { // --- the AHDSR schematic ------------------------------------------------------
static void testAhdsrNodeOrderAndLevels() {
const Rect a = wideArea(); const Rect a = wideArea();
CHECK(timeToX(a, 0.0, 1.0) == a.x); // no duration -> left const std::vector<EnvVertex> poly =
const Rect z = Rect::ltrb(5, 5, 5, 45); // zero width buildEnvelopePolyline(ahdsr(0.2, 0.1, 0.3, 0.5, 0.4), overlayOf(a), 4.0);
CHECK(timeToX(z, 2.0, 1.0) == z.x);
}
static void testLevelToYEndpoints() {
const Rect a = wideArea();
CHECK(levelToY(a, 1.0) == a.y); // level 1 -> top row
CHECK(levelToY(a, 0.0) == a.bottom() - 1); // level 0 -> bottom row
CHECK(levelToY(a, 0.5) == a.y + 50); // mid: round((1-0.5)*99)=round(49.5)=50
}
static void testLevelToYClamps() {
const Rect a = wideArea();
CHECK(levelToY(a, 2.0) == a.y); // >1 clamps to top
CHECK(levelToY(a, -1.0) == a.bottom() - 1); // <0 clamps to bottom
const Rect z = Rect::ltrb(5, 5, 45, 5); // zero height
CHECK(levelToY(z, 0.5) == z.y);
}
// --- Gate polyline ------------------------------------------------------------
static void testGateNodeOrderAndLevels() {
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.4;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
// Six vertices, in draw order.
CHECK(poly.size() == 6);
CHECK(poly[0].node == EnvNode::Origin);
CHECK(poly[1].node == EnvNode::AttackEnd);
CHECK(poly[2].node == EnvNode::HoldEnd);
CHECK(poly[3].node == EnvNode::DecayEnd);
CHECK(poly[4].node == EnvNode::ReleaseStart);
CHECK(poly[5].node == EnvNode::ReleaseEnd);
// Levels: origin 0, attack/hold peak 1, decay settles to sustain, plateau holds sustain,
// release ends at 0.
CHECK(poly[0].level == 0.0);
CHECK(poly[1].level == 1.0);
CHECK(poly[2].level == 1.0);
CHECK(poly[3].level == 0.5); // sustain
CHECK(poly[4].level == 0.5); // plateau end holds sustain
CHECK(poly[5].level == 0.0);
}
static void testGateSchematicPlacement() {
// FA2 bounded schematic at the PARAM-DOMAIN scale: timed region = 850px (150px reserved
// plateau), pps = (850-1-32)/8s = 102.125 px/s, each segment prefixed by the 8px separation
// base. attack .2 -> x@round(8+20.425)=28; hold .1 -> x@round(28.425+8+10.2125)=47; decay
// .3 -> x@round(46.6375+8+30.6375)=85; plateau is the FIXED 150px reserve -> ReleaseStart
// x@235; release .4 -> x@round(235.275+8+40.85)=284, well inside the canvas.
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.4;
const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
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);
// Monotone in x across the traced line.
for (std::size_t i = 1; i < 6; ++i) CHECK(poly[i].x >= poly[i - 1].x);
// Every vertex in-bounds.
for (const EnvVertex& p : poly) {
CHECK(p.x >= a.x && p.x <= a.right() - 1);
CHECK(p.y >= a.y && p.y <= a.bottom() - 1);
}
CHECK(findNode(poly, EnvNode::ReleaseEnd, v));
CHECK(v.x == a.right() - 1); // ANCHORED, whatever the release is
} }
// The literal per-node x placement, hand-derived from the documented formula (pps = 120.875
// px/s per testGatePxPerSecond; each timed stage is prefixed by the kGateNodeSepPx=8 base):
// attack .2s -> raw 8+24.175=32.175 -> px 32; hold .1s -> raw 32.175+8+12.0875=52.2625 -> px 52;
// decay .3s -> raw 52.2625+8+36.2625=96.525 -> px 97; plateau -> raw 999-8-48.35=942.65 -> px
// 943; release end pinned at the last column, 999. A literal regression pin — no relational or
// bounds-only check catches a formula-shape change the way an exact pixel count does.
static void testAhdsrSchematicPlacement() {
const Rect a = wideArea();
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(0.2, 0.1, 0.3, 0.5, 0.4), overlayOf(a), 4.0);
EnvVertex v;
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 32);
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 52);
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 97);
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 943);
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 999);
}
// The AHDSR schematic is scaled by the PARAM domain, NOT the capture length: the same params
// produce the SAME polyline whether totalSeconds is 0.3 or 10 (gatePolyline doesn't even take
// totalSeconds — only the sustain-less AHD's x-axis is wall-clock/PCM-aligned).
static void testGateLayoutIndependentOfSampleDuration() { static void testGateLayoutIndependentOfSampleDuration() {
// The Gate schematic is scaled by the PARAM domain, NOT the capture length: the same params
// produce the SAME polyline over a 0.3s and a 10s sample (pre-fix, a 60ms release on a 10s
// capture collapsed to ~5px while 2s stages on a 0.3s capture pinned to the right edge).
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.2;
env.holdSeconds = 0.1;
env.decaySeconds = 0.3;
env.sustainLevel = 0.5;
env.releaseSeconds = 0.06;
const Rect a = wideArea(); const Rect a = wideArea();
CHECK(buildEnvelopePolyline(env, overlayOf(a), 0.3) == buildEnvelopePolyline(env, overlayOf(a), 10.0)); const StageEnvelope e = ahdsr(0.2, 0.1, 0.3, 0.5, 0.06);
CHECK(buildEnvelopePolyline(e, overlayOf(a), 0.3) == buildEnvelopePolyline(e, overlayOf(a), 10.0));
} }
static void testGateMinSeparationAtDefaults() { // The layout failure this policy exists to fix: at zero release the sustain plateau must run to
// THE FA2 headline: at the tier-0 Gate defaults (attack 3ms, hold 0, decay 0, sustain 1.0, // (near) the right edge instead of the figure bunching left.
// release 60ms) every consecutive node pair is at least kGateNodeSepPx apart — no node ever static void testZeroReleasePutsTheSustainPlateauAtTheRightEdge() {
// 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 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.05, 0.0, 0.05, 0.7, 0.0), overlayOf(a), 4.0);
for (size_t i = 1; i < poly.size(); ++i) { EnvVertex plateau, end;
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx); 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.
static void testGateSustainPlateauFixedWidth() { CHECK(plateau.x == a.right() - 1 - kGateNodeSepPx);
// The sustain plateau is ALWAYS the reserved width (canvas - timed region), independent of EnvVertex decay;
// the AHDSR times — the bounded region that replaces the old plateau-to-sample-end.
AmpEnvelope env;
env.mode = EnvMode::Gate;
env.attackSeconds = 0.1;
env.holdSeconds = 0.0;
env.decaySeconds = 0.2;
env.sustainLevel = 0.6;
env.releaseSeconds = 0.3;
const Rect a = wideArea();
const int plateauPx = a.width - gateTimedWidth(a); // 150
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0);
EnvVertex decay, plateauEnd;
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; // The compression exists to preserve MINIMUM gaps under overrun, not merely
zero.attackSeconds = 0.0; zero.holdSeconds = 0.0; zero.decaySeconds = 0.0; // non-strict monotonicity — a compression that let two nodes collapse onto one
zero.sustainLevel = 0.0; zero.releaseSeconds = 0.0; // pixel would still pass a `>=` check but defeat the whole point of kGateNodeSepPx.
AmpEnvelope trig = base; CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1);
trig.mode = EnvMode::Trigger; }
trig.lengthFraction = 1.0; trig.fadeInFraction = 0.0; trig.fadeOutFraction = 0.0; EnvVertex end;
// ABSURD stage values must clamp in double space, not overflow the integer cast (32-bit CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
// long on Windows would wrap negative and land on the WRONG edge). CHECK(end.x == a.right() - 1);
AmpEnvelope huge = base; }
huge.mode = EnvMode::Gate;
huge.releaseSeconds = 1e12;
for (const AmpEnvelope& env : {base, big, zero, trig, huge}) { // Absurd stage values must clamp in double space, not overflow the integer cast (32-bit long
for (const EnvVertex& v : buildEnvelopePolyline(env, overlayOf(a), 2.0)) { // on Windows would wrap negative and land on the WRONG edge) — a regression named for the bug
CHECK(v.x >= a.x && v.x < a.right()); // it once was. gateVtx's own double-space clamp is what this exercises.
CHECK(v.y >= a.y && v.y < a.bottom()); static void testAbsurdReleaseValueStaysInBounds() {
const Rect a = wideArea();
StageEnvelope huge = ahdsr(0.1, 0.1, 0.1, 0.5, 0.1);
huge.releaseSeconds = 1e12;
for (const EnvVertex& v : buildEnvelopePolyline(huge, overlayOf(a), 4.0)) {
CHECK(v.x >= a.x && v.x < a.right());
CHECK(v.y >= a.y && v.y < a.bottom());
}
}
// --- the AHD split ------------------------------------------------------------
// The combined-time bound, asserted structurally across the full domains: no (attack, decay,
// fraction) triple can exceed the span, and no clamp on the SUM exists to be exercised.
static void testAhdSplitNeverExceedsTheSpan() {
const double span = 3.0;
for (int ai = 0; ai <= 20; ++ai) {
for (int di = 0; di <= 20; ++di) {
for (int fi = 0; fi <= 10; ++fi) {
const StageEnvelope e =
ahd(ai * 0.25, di * 0.25, fi * 0.1, 0.0, span);
const AhdSplit s = splitAhdSeconds(e);
CHECK(s.attack >= 0.0 && s.hold >= 0.0 && s.decay >= 0.0);
CHECK(s.total <= span + 1e-9);
CHECK(std::fabs(s.total - (s.attack + s.hold + s.decay)) < 1e-12);
}
} }
} }
} }
// --- Trigger polyline --------------------------------------------------------- static void testHoldFractionEndpoints() {
const StageEnvelope none = ahd(0.5, 0.5, 0.0, 0.0, 4.0);
const AhdSplit s0 = splitAhdSeconds(none);
CHECK(s0.hold == 0.0);
CHECK(std::fabs(s0.total - 1.0) < 1e-12);
static void testTriggerShape() { 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() { // F1 regression: DecayEnd stays at its true wall-clock instant even when that instant coincides
// The FA2 fix: at full length + zero fade-out, FadeOutStart and LengthEnd land AT the last // with HoldEnd's (decay ~ 0) — the 1:1 AHD axis promises N seconds -> N seconds, and a nudge
// in-bounds column (right-1), NOT at the half-open right edge — so the shell draws their // away from that instant lies about the shape, including the Trigger default's abrupt cutoff.
// handles and the fade-out node is grabbable even when fade-out == 0. // Fails against the prior nudge, which moved DecayEnd right whenever the gap was under
AmpEnvelope env; // kGateNodeSepPx.
env.mode = EnvMode::Trigger; static void testDecayEndStaysAtItsTrueInstantEvenWhenCoincidentWithHoldEnd() {
env.lengthFraction = 1.0;
env.fadeInFraction = 0.1;
env.fadeOutFraction = 0.0;
const Rect a = wideArea(); const Rect a = wideArea();
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, overlayOf(a), 2.0); const double total = 4.0;
EnvVertex fout, lend; // Short but nonzero decay: the true gap to HoldEnd is a few px, under kGateNodeSepPx, so
CHECK(findNode(poly, EnvNode::FadeOutStart, fout)); // the retired nudge would have fired here too.
CHECK(findNode(poly, EnvNode::LengthEnd, lend)); const StageEnvelope shortDecay = ahd(0.5, 0.02, 1.0, 0.0, 3.0);
CHECK(fout.x == a.right() - 1); // present + in-bounds at zero fade-out const AhdSplit sShort = splitAhdSeconds(shortDecay);
CHECK(lend.x == a.right() - 1); EnvVertex decayShort;
CHECK(fout.level == 1.0 && lend.level == 0.0); CHECK(findNode(buildEnvelopePolyline(shortDecay, overlayOf(a), total), EnvNode::DecayEnd,
decayShort));
CHECK(decayShort.x == timeToX(a, total, sShort.total));
// Zero decay (the Trigger AHD default's shape): DecayEnd and HoldEnd share the exact same
// instant — the abrupt cutoff — and DecayEnd must not be nudged off it.
const StageEnvelope zeroDecay = ahd(0.5, 0.0, 1.0, 0.0, 3.0);
const AhdSplit sZero = splitAhdSeconds(zeroDecay);
const std::vector<EnvVertex> polyZero = buildEnvelopePolyline(zeroDecay, overlayOf(a), total);
EnvVertex decayZero, holdZero;
CHECK(findNode(polyZero, EnvNode::DecayEnd, decayZero));
CHECK(findNode(polyZero, EnvNode::HoldEnd, holdZero));
CHECK(decayZero.x == timeToX(a, total, sZero.total));
CHECK(decayZero.x == holdZero.x); // truly coincident, not nudged apart
} }
// --- Degenerate --------------------------------------------------------------- // --- curve knots --------------------------------------------------------------
static void testDegenerateFlatBaseline() { // A knot rides every sloped stage that has a duration, and none that does not — a zero-length
AmpEnvelope env; // any params // stage has no interior to put a handle in.
const Rect zeroW = Rect::ltrb(0, 0, 0, 100); static void testKnotsRideOnlySlopedNonZeroSegments() {
const std::vector<EnvVertex> p1 = buildEnvelopePolyline(env, overlayOf(zeroW), 2.0); const Rect a = wideArea();
CHECK(p1.size() == 2); // always a drawable line const std::vector<EnvVertex> full =
CHECK(p1.front().level == 0.0 && p1.back().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 Rect ok = wideArea(); const std::vector<EnvVertex> flat =
const std::vector<EnvVertex> p2 = buildEnvelopePolyline(env, overlayOf(ok), 0.0); // no duration buildEnvelopePolyline(ahdsr(0.0, 0.2, 0.0, 0.5, 0.0), overlayOf(a), 4.0);
CHECK(p2.size() == 2); CHECK(!hasNode(flat, EnvNode::AttackCurve));
CHECK(p2.front().level == 0.0 && p2.back().level == 0.0); CHECK(!hasNode(flat, EnvNode::DecayCurve));
CHECK(p2.front().x == ok.x && p2.back().x == ok.right() - 1); // spans the area, in-bounds 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);
}
}
// 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;
e.attackCurve = 1.0;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, neutral));
CHECK(std::fabs(neutral.level - 0.5) < 1e-12); // linear: half way up at half way across
CHECK(neutral.y == levelToY(a, 0.5));
e.attackCurve = 4.0;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, steep));
CHECK(steep.level < neutral.level);
CHECK(steep.y > neutral.y); // lower on screen
e.attackCurve = 0.25;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), 4.0), EnvNode::AttackCurve, shallow));
CHECK(shallow.level > neutral.level);
CHECK(shallow.y < neutral.y);
// The knot sits between its segment's endpoints in x, and inside the canvas in y.
CHECK(steep.x > a.x && steep.x < a.right() - 1);
CHECK(steep.y >= a.y && steep.y <= a.bottom() - 1);
}
// --- degenerate ---------------------------------------------------------------
static void testDegenerateSurfaceYieldsFlatBaseline() {
const std::vector<EnvVertex> zeroArea =
buildEnvelopePolyline(ahdsr(0.1, 0.1, 0.1, 0.5, 0.1), overlayOf(Rect{}), 4.0);
CHECK(zeroArea.size() == 2);
CHECK(zeroArea[0].level == 0.0 && zeroArea[1].level == 0.0);
const std::vector<EnvVertex> zeroDur =
buildEnvelopePolyline(ahd(0.1, 0.1, 0.5, 0.0, 1.0), overlayOf(wideArea()), 0.0);
CHECK(zeroDur.size() == 2);
} }
int main() { int main() {
testTimeToXEndpoints(); testTimeToXEndpoints();
testTimeToXNegativePinsLeft(); testTimeToXClampsBothEnds();
testTimeToXPastEndClamps(); testLevelToY();
testTimeToXDegenerate(); testDegenerateAreaAndDuration();
testGateTimedWidth();
testGatePxPerSecond(); testGatePxPerSecond();
testLevelToYEndpoints();
testLevelToYClamps();
testGateNodeOrderAndLevels(); testAhdsrNodeOrderAndLevels();
testGateSchematicPlacement(); testAhdsrSchematicPlacement();
testGateLayoutIndependentOfSampleDuration(); testGateLayoutIndependentOfSampleDuration();
testGateMinSeparationAtDefaults(); testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
testGateSustainPlateauFixedWidth(); testReleaseGrowsLeftwardFromTheAnchor();
testGateReleaseVisibleInBounds(); testTierZeroDefaultsKeepEveryNodeDistinct();
testGateOverrunCompressesFromRight(); testMaxedStagesCompressWithoutOverrunning();
testGateAllVerticesInBounds(); testAbsurdReleaseValueStaysInBounds();
testTriggerShape(); testAhdSplitNeverExceedsTheSpan();
testTriggerFadeOverlapClamp(); testHoldFractionEndpoints();
testTriggerFullLengthZeroFadeOutInBounds(); testAhdIsOneToOneWithTheTimeAxis();
testDecayEndStaysAtItsTrueInstantEvenWhenCoincidentWithHoldEnd();
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);
+124 -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,81 @@ 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},
// A small hold that finishes the envelope well inside the render window (Hold ends at
// frame ~5090, comfortably short of the window) vs. a live move that opens the hold out
// near the whole span: with the fraction alone unmoved, the boundary the two renders
// cross (or don't) inside the observed tail is what makes this audible, not a level
// change — Hold's own output is flat regardless of exactly where inside it pos_ sits.
{"pitch env hold fraction",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0;
s.play.pitchEnv.shape.attackFrames = 100;
s.play.pitchEnv.shape.decayFrames = 100;
s.play.pitchEnv.shape.holdFraction = 0.05;
},
[](LiveValues& v) { v.pitchEnv.shape.holdFraction = 1.0; }, -1},
// The Trigger AHDs — Voice::applyLive's ampAhd_/filterAhd_ branches, otherwise
// unexercised by this table (every case above is Gate/AdsrEnvelope).
{"trigger amp attack (AHD)",
[](SampleData& s) {
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.attackFrames = 48000;
},
[](LiveValues& v) { v.ampAhd.attackFrames = 4000; }, -1},
{"trigger filter attack (AHD)",
[](SampleData& s) {
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0;
filterSweep(s);
s.play.filter.trigEnv.attackFrames = 48000;
},
[](LiveValues& v) { v.filterAhd.attackFrames = 4000; }, -1},
// One curve exponent per envelope (amp/pitch/filter), reusing each envelope's own
// attack/decay rig above so only the mutated field differs.
{"amp attack curve",
[](SampleData& s) { s.play.adsr.attackFrames = 48000; },
[](LiveValues& v) { v.adsr.attackCurve = 5.0; }, -1},
{"amp release curve",
[](SampleData& s) { s.play.adsr.releaseFrames = 48000; },
[](LiveValues& v) { v.adsr.releaseCurve = 5.0; }, 2},
{"pitch env attack curve",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.shape.attackFrames = 48000;
s.play.pitchEnv.shape.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnv.shape.attackCurve = 5.0; }, -1},
{"filter env decay curve",
[](SampleData& s) {
filterSweep(s);
s.play.filter.env.decayFrames = 48000;
s.play.filter.env.sustainLevel = 0.0;
},
[](LiveValues& v) { v.filterEnv.decayCurve = 5.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 +745,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 +795,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 +804,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 +842,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() {
+53 -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() {
@@ -672,6 +688,30 @@ static void testResolvePlayRoundsAndFloorsNegatives() {
CHECK(p.adsr.releaseFrames == 0); CHECK(p.adsr.releaseFrames == 0);
} }
// The retired Trigger fade pair was SOURCE frames; the AHD that replaced it stores wall-clock
// seconds, and the codec's lift can only divide by the PROJECT rate. This is the far end of
// that seam: the build multiplies by the DECODE rate, so a migrated fade comes back scaled by
// decodeRate/projectRate whenever a file's own rate differs from the project's. The bound is
// documented at the lift in component_state_io.h; this is its measured size.
static void testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate() {
PlaySeconds st;
st.trigAhd.attackSeconds = 441.0 / 44100.0; // a 441-SOURCE-frame fade lifted at 44.1k
st.trigAhd.decaySeconds = 882.0 / 44100.0;
// Matched rates are EXACT: the round trip through seconds loses nothing.
const PlayParams matched = resolvePlay(st, 44100);
CHECK(matched.trigAhd.attackFrames == 441);
CHECK(matched.trigAhd.decayFrames == 882);
// resolvePlay's second argument is the DECODE rate; the seconds above were lifted (divided)
// at the PROJECT rate 44100 — so this is a 48 kHz file opened in a 44.1 kHz project (the
// mirror of component_state_io.h's worked example): 441 * 48000/44100 = 480 source frames,
// ~8.8% longer than the fade the saved instance actually had.
const PlayParams stretched = resolvePlay(st, 48000);
CHECK(stretched.trigAhd.attackFrames == 480);
CHECK(stretched.trigAhd.decayFrames == 960);
}
// --- resolveCapture: the ONE override-beats-intrinsic fold --------------------- // --- resolveCapture: the ONE override-beats-intrinsic fold ---------------------
static SelectedSample ref(const std::string& rel, int root, bool hasLoop = false, static SelectedSample ref(const std::string& rel, int root, bool hasLoop = false,
@@ -898,6 +938,7 @@ int main() {
testResolvePlayConvertsWallClockAtTheRate(); testResolvePlayConvertsWallClockAtTheRate();
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope(); testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
testResolvePlayRoundsAndFloorsNegatives(); testResolvePlayRoundsAndFloorsNegatives();
testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate();
testResolveCaptureUsesIntrinsicsWhenNoOverride(); testResolveCaptureUsesIntrinsicsWhenNoOverride();
testResolveCaptureOverridesBeatIntrinsics(); testResolveCaptureOverridesBeatIntrinsics();
testResolveCaptureLoopOverrideCanDisableTheLoop(); testResolveCaptureLoopOverrideCanDisableTheLoop();
+33 -24
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;
} }
@@ -1065,21 +1068,24 @@ static void testTriggerLengthWithStart() {
} }
// --- Trigger fade-in / fade-out ramp shape (equal-power default). --- // --- Trigger fade-in / fade-out ramp shape (equal-power default). ---
static void testTriggerFadeShape() { // 100 frames, 100% length, attack 20 / decay 20, holdFraction 1.0 — triggerSample() sets no
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity // curve exponent, so both stages default to util::kCurveNeutral (1.0): the ramps are LINEAR,
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1. // not the retired fade pair's equal-power sin/cos. Asserted against the closed form rather
// than monotonicity alone — a monotonicity-only check is blind to exactly this shape change.
static void testTriggerAhdFadeShape() {
SampleData km = (triggerSample(100, 1.0, 20, 20)); SampleData km = (triggerSample(100, 1.0, 20, 20));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
eng.render(out, 120); eng.render(out, 120);
CHECK(approx(out[0], 0.0, 1e-3)); // fade-in starts at 0 for (std::size_t i = 0; i < 20; ++i) {
// Fade-in monotonic non-decreasing. CHECK(approx(out[i], static_cast<double>(i) / 20.0, 1e-3));
for (std::size_t i = 1; i < 20; ++i) CHECK(out[i] >= out[i - 1] - 1e-4); }
// Unity plateau in the middle. // Hold plateau at unity.
for (std::size_t i = 25; i < 75; ++i) CHECK(approx(out[i], 1.0, 1e-3)); for (std::size_t i = 20; i < 80; ++i) CHECK(approx(out[i], 1.0, 1e-3));
// Fade-out monotonic non-increasing over [80,100). for (std::size_t i = 80; i < 100; ++i) {
for (std::size_t i = 81; i < 100; ++i) CHECK(out[i] <= out[i - 1] + 1e-4); CHECK(approx(out[i], 1.0 - static_cast<double>(i - 80) / 20.0, 1e-3));
}
// Past playEnd = silence. // Past playEnd = silence.
for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6)); for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6));
} }
@@ -1096,7 +1102,7 @@ static void testTriggerEdgeCases() {
for (float v : out) CHECK(approx(v, 0.0, 1e-6)); for (float v : out) CHECK(approx(v, 0.0, 1e-6));
CHECK(eng.activeVoiceCount() == 0); CHECK(eng.activeVoiceCount() == 0);
} }
// Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]). // AHD attack + decay beyond the play length are fitted by fitAhd, not overflowed (no crash, no negative gain, amp in [0,1]).
{ {
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped. // 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
SampleData km = (triggerSample(40, 1.0, 30, 30)); SampleData km = (triggerSample(40, 1.0, 30, 30));
@@ -1181,10 +1187,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 +1229,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 +1257,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);
@@ -2572,7 +2581,7 @@ int main() {
testAhdsrHoldZeroEqualsAdsr(); testAhdsrHoldZeroEqualsAdsr();
testTriggerLengthFractionFrames(); testTriggerLengthFractionFrames();
testTriggerLengthWithStart(); testTriggerLengthWithStart();
testTriggerFadeShape(); testTriggerAhdFadeShape();
testTriggerEdgeCases(); testTriggerEdgeCases();
testTriggerIgnoresNoteOff(); testTriggerIgnoresNoteOff();
+683
View File
@@ -0,0 +1,683 @@
// 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; the terminal ring-out under Preserve — its shape,
// what it must NOT hold up (the Preserve cap, mono legato), and the Varispeed render it must
// leave alone; and the pitch envelope's span domain under a transposed Varispeed voice.
//
// The migration contour of the retired fade pair is asserted where the lift lives, in
// component_state_io_tests.
#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 AdsrParams originalAdsr = s.play.adsr;
const auto render = [&]() {
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1000);
return out;
};
s.play.playMode = PlayMode::Gate;
const std::vector<AudioSample> gate = render();
CHECK(gate[0] < 0.01f); // halfway up a 2000-frame attack
CHECK(std::fabs(gate[999] - 999.0f / 2000.0f) < 1e-3f);
// The actual excursion: flip the SAME SampleData to Trigger and render through it — not a
// fresh copy. A prior revision of this test copied `s` per render and never wrote it, so
// "back to Gate" compared two renders of an object nothing had touched: it would have
// passed with trigAhd deleted from the struct.
s.play.playMode = PlayMode::Trigger;
const std::vector<AudioSample> trig = render();
CHECK(trig[0] > 0.99f); // instant onset
CHECK(std::fabs(trig[999] - (1.0f - 999.0f / 2000.0f)) < 1e-3f);
// Flip back to Gate on the SAME object: the AHDSR fields must have survived the round trip
// and the render must reproduce the pre-excursion output exactly.
s.play.playMode = PlayMode::Gate;
CHECK(s.play.adsr.attackFrames == originalAdsr.attackFrames);
CHECK(s.play.adsr.sustainLevel == originalAdsr.sustainLevel);
const std::vector<AudioSample> again = render();
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);
// > 0 alone would pass on a hard cut with no blend at all; the ring-out is ~185 frames
// past the kRingSpanFrames=4000 span, so requiring it past the span is what confirms
// the blend actually engaged rather than merely stopping somewhere.
CHECK(m.soundingFrames > 4000);
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 directly against
// the closed-form source waveform (not by comparing two renders): the read head reproduces the
// source exactly through its span, then stops dead with no ring-out appended.
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);
}
// --- The ring-out must not hold the voice's slot -------------------------------
// A 4000-frame sine one-shot at unity Preserve read rate: the read head leaves the span at
// output frame 4000 and the ~185-frame ring-out runs from there. `kPastEnd` sits inside that
// window, so a note-on at that point is the exact case both tests below need.
static constexpr std::size_t kRingSpanFrames = 4000;
static constexpr std::size_t kPastEnd = 4120;
static SampleData ringOutSample() {
SampleData s;
s.frames.resize(kRingSpanFrames);
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 = 1.0;
return s;
}
static double peakOf(const std::vector<AudioSample>& out, std::size_t from) {
double peak = 0.0;
for (std::size_t i = from; i < out.size(); ++i) {
peak = std::max(peak, std::fabs(static_cast<double>(out[i])));
}
return peak;
}
// The Preserve CAP counts sounding notes, not ring-outs. With the cap at one, a second onset
// fired while the first voice is past its end but still ramping must still be admitted —
// otherwise every Preserve one-shot silently swallows the next hit for ~4 ms.
static void testPreserveCapAdmitsANewOnsetDuringTheRingOut() {
const SampleData s = ringOutSample();
const auto run = [&](bool fireSecondNote) {
VoiceEngine eng(4, s, /*preserveCap=*/1, /*window=*/512);
eng.noteOn(67, 127);
std::vector<AudioSample> out;
eng.render(out, kPastEnd);
if (fireSecondNote) eng.noteOn(72, 127);
eng.render(out, 400);
return out;
};
// Precondition: the first voice really is still ringing at kPastEnd, so the second onset
// meets the cap while a past-end voice is alive. Without it the test proves nothing.
{
VoiceEngine probe(4, s, /*preserveCap=*/1, /*window=*/512);
probe.noteOn(67, 127);
std::vector<AudioSample> discard;
probe.render(discard, kPastEnd);
CHECK(probe.activeVoiceCount() == 1);
}
// The discriminator is rendered signal, not a voice count: a refused onset leaves the two
// renders identical from kPastEnd on (the decaying ramp and nothing else).
const std::vector<AudioSample> withSecond = run(true);
const std::vector<AudioSample> ringOnly = run(false);
CHECK(peakOf(ringOnly, kPastEnd) < 0.02); // the ramp alone is already well down
CHECK(peakOf(withSecond, kPastEnd) > 0.5); // the admitted note is at full amplitude
}
// Mono LEGATO takes a voice over by retuning it — which moves the pitch of a dying ramp and
// produces nothing if the voice is already past its own end. A note-on during the ring-out
// must restart instead.
static void testMonoLegatoRestartsRatherThanRetunesDuringTheRingOut() {
const SampleData s = ringOutSample();
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512, VoiceMode::Mono,
MonoTrigger::Legato);
eng.noteOn(67, 127); // held: never released, so the second press is a legato takeover
std::vector<AudioSample> out;
eng.render(out, kPastEnd);
CHECK(eng.activeVoiceCount() == 1); // precondition: still ringing out
eng.noteOn(72, 127);
eng.render(out, 400);
// A retune of the past-end voice re-enters the run-off branch every frame and emits only
// the decaying ramp; a restart plays the source from the top.
CHECK(peakOf(out, kPastEnd) > 0.5);
}
// The read-head exhaustion path is NOT scoped to Trigger: a held Gate note whose source runs
// out with no sustain loop is cut at its sustain level, and under Preserve that cut lands on
// the same recycled synthetic tail the Trigger one-shot's does.
static void testGatePreserveRunOffRingsOutToo() {
SampleData s = ringOutSample();
s.play.playMode = PlayMode::Gate;
s.play.adsr.attackFrames = 0;
s.play.adsr.decayFrames = 0;
s.play.adsr.sustainLevel = 1.0; // held at full level when the source runs out
s.loop.hasLoop = false;
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(67, 127); // held; never released
std::vector<AudioSample> out;
std::size_t sounding = 0;
for (std::size_t f = 0; f < 8000; ++f) {
eng.render(out, 1);
if (eng.activeVoiceCount() == 0) break;
sounding = f + 1;
}
CHECK(sounding > kRingSpanFrames); // it rings past the source rather than stopping dead
// Same measurable bar as the Trigger tail: no step larger than the source waveform's own
// steepest slope. (The audible judgement is Daniel's; this is the proxy.)
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);
}
// --- The pitch envelope's span domain ------------------------------------------
// The pitch envelope counts OUTPUT frames while the playable span is a SOURCE-frame count, so
// a transposed Varispeed voice must have its span converted. Measured through the read rate the
// envelope itself drives: at +12 semitones (ratio 2) with a full-span hold of -12 semitones the
// voice reads at unity while the envelope holds and at double speed after it, so the note's
// OUTPUT length is a direct readout of the envelope's span.
// +12 st, span/2 = 2000 output frames of hold: 2000 + 2000/2 = 3000 output frames
// +24 st, span/4 = 1000 output frames of hold: 1000 + 3000/4 = 1750 output frames
// The un-converted source-frame span holds for 4000 output frames in BOTH cases, so the note
// runs exactly 4000 at either transposition — the length stops tracking the ratio at all.
static void testPitchEnvelopeSpanIsOutputFramesUnderVarispeed() {
const auto soundingFrames = [](int note, double peakSemis) {
SampleData s = dcSample(4000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.attackFrames = 0;
s.play.trigAhd.decayFrames = 0;
s.play.trigAhd.holdFraction = 1.0;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = peakSemis; // cancels the transposition while it holds
s.play.pitchEnv.shape.attackFrames = 0;
s.play.pitchEnv.shape.decayFrames = 0;
s.play.pitchEnv.shape.holdFraction = 1.0;
VoiceEngine eng(1, s);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
std::size_t sounding = 0;
for (std::size_t f = 0; f < 8000; ++f) {
eng.render(out, 1);
if (eng.activeVoiceCount() == 0) break;
sounding = f + 1;
}
return sounding;
};
CHECK(soundingFrames(72, -12.0) == 3000);
CHECK(soundingFrames(84, -24.0) == 1750);
}
// --- Live delivery must not resurrect a finished AHD ---------------------------
// A live move must never UN-finish a completed AHD. Reachable on a sounding voice past its
// fitted total but still active() (the ring-out past a Trigger AHD's own early end, voice.h) —
// VoiceEngine::applyLiveToActive runs on any active() voice, so a live move there that reopens
// the total could otherwise make a finished one-shot briefly resurge.
static void testApplyLiveNeverUnfinishesACompletedAhd() {
AhdParams p;
p.attackFrames = 10;
p.decayFrames = 10;
p.holdFraction = 0.0; // total = 20, well short of the 1000-frame span
AhdEnvelope ahd;
ahd.configure(1000, p);
CHECK(ahd.amplitudeAt(500.0) == 0.0); // past the fitted total: latched finished
CHECK(ahd.finished());
// A live move that would, on a FRESH fit, reopen the total out across the whole span.
AhdParams reopened = p;
reopened.holdFraction = 1.0;
ahd.applyLive(500.0, reopened); // same sourceOffset the voice is sitting at
CHECK(ahd.finished()); // must stay finished — no resurge
CHECK(ahd.amplitudeAt(500.0) == 0.0);
}
// --- Migration shape ----------------------------------------------------------
// 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();
testPreserveCapAdmitsANewOnsetDuringTheRingOut();
testMonoLegatoRestartsRatherThanRetunesDuringTheRingOut();
testGatePreserveRunOffRingsOutToo();
testPitchEnvelopeSpanIsOutputFramesUnderVarispeed();
testApplyLiveNeverUnfinishesACompletedAhd();
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;
}
+38
View File
@@ -121,6 +121,42 @@ 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));
// Pin the MEASURED value, not just the relational improvement: today's pair sits at
// ~1.37:1 — BELOW the 3:1 indicator floor every other assertion in this file uses. That
// is a known, held deviation (Daniel's palette call, tracked separately) rather than a
// silently accepted one; a regression toward the old ~1.12:1 secondary pairing must fail.
CHECK(contrastRatio(overlay, wave) >= 1.35);
// 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);
}
// editor_paint_waveform.cpp draws the loop span/markers and the envelope overlay trace into
// the SAME overlay rect. Loop markers are secondary (this track's reassignment, off tertiary,
// which the envelope overlay now owns exclusively there); a future palette edit that collapses
// the two back to one role must fail here rather than silently recreating the finding.
static void testLoopMarkerAndEnvelopeOverlayRolesAreDistinct() {
const KitColor loopMarker = roleColor(Role::AccentSecondary);
const KitColor envelopeOverlay = roleColor(Role::AccentTertiary);
CHECK(!(loopMarker == envelopeOverlay));
}
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 +273,8 @@ int main() {
testTextOnPastelFillClearsBodyFloor(); testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor(); testTextOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable(); testSecondaryTertiaryAreDistinguishable();
testOverlayAccentSeparatesFromTheWaveformAccent();
testLoopMarkerAndEnvelopeOverlayRolesAreDistinct();
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;