Merge pth-w4-t2-velocity-deck-and-bipolar-curves: one VELOCITY deck, bipolar pitch and filter curves multiplying their depth knobs, and a drawn preview glyph
This commit is contained in:
@@ -243,10 +243,12 @@ anything for a trigger shape.
|
|||||||
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
|
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
|
||||||
extension's `persist` ext-state module (that would make it project-global rather than
|
extension's `persist` ext-state module (that would make it project-global rather than
|
||||||
per-instance and leak an instrument concern into the extension's key space).
|
per-instance and leak an instrument concern into the extension's key space).
|
||||||
- **Velocity curve** — the one non-back-compat surface in S-VIEW: an
|
- **Velocity curves** — three of them (amp, pitch, filter), all per-instance, edited from ONE
|
||||||
already-saved instance with no stored curve now plays every velocity at unity under the
|
deck group. The amp curve is the one non-back-compat surface in S-VIEW: an already-saved
|
||||||
flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping —
|
instance with no stored curve now plays every velocity at unity under the flat-default
|
||||||
a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
|
(Option A), not bit-identical to the old linear `velocity/127` mapping — a deliberate,
|
||||||
|
Daniel-approved behavior change. The pitch and filter curves are bipolar and off by default
|
||||||
|
(see `velocity_curve` in Modules).
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
@@ -260,13 +262,13 @@ anything for a trigger shape.
|
|||||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||||
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
||||||
- `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 Fritsch–Carlson 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` — the pure velocity transfer curve shared by all THREE destinations: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot). `eval(velocity)` called once per note-on. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
|
||||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||||
|
|
||||||
### `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…v11), 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. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade.
|
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v12), 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. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
|
||||||
- `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.
|
- `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.
|
||||||
@@ -276,7 +278,7 @@ anything for a trigger shape.
|
|||||||
|
|
||||||
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath.
|
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
|
||||||
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||||
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
|
||||||
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
||||||
@@ -285,7 +287,7 @@ anything for a trigger shape.
|
|||||||
- `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), 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.
|
- `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 velocity/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. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
|
||||||
- `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 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_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
|
||||||
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
|
- `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.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ LiveValues foldLive(const PlayParams& params) {
|
|||||||
LiveValues v;
|
LiveValues v;
|
||||||
v.filterSettings = params.filter.settings;
|
v.filterSettings = params.filter.settings;
|
||||||
v.filterModAmount = params.filter.modAmount;
|
v.filterModAmount = params.filter.modAmount;
|
||||||
|
v.filterVelAmount = params.filter.velAmount;
|
||||||
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.filterAhd = params.filter.trigEnv;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ inline constexpr double kLiveRampSeconds = 0.020;
|
|||||||
struct LiveValues {
|
struct LiveValues {
|
||||||
filter::FilterSettings filterSettings{};
|
filter::FilterSettings filterSettings{};
|
||||||
double filterModAmount = 0.0;
|
double filterModAmount = 0.0;
|
||||||
|
// The DEPTH scaling the velocity curve, not the curve's value: the note's velocity is
|
||||||
|
// latched, its depth is a control, exactly as filterKeyTrack is a control over a latched
|
||||||
|
// note number.
|
||||||
|
double filterVelAmount = 0.0;
|
||||||
double filterKeyTrack = 0.0;
|
double filterKeyTrack = 0.0;
|
||||||
AdsrParams filterEnv{};
|
AdsrParams filterEnv{};
|
||||||
AhdParams filterAhd{};
|
AhdParams filterAhd{};
|
||||||
|
|||||||
@@ -116,25 +116,28 @@ struct FilterParams {
|
|||||||
bool enabled = false;
|
bool enabled = false;
|
||||||
instrument::engine::filter::FilterSettings settings;
|
instrument::engine::filter::FilterSettings settings;
|
||||||
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], scales velocityCurve's output
|
||||||
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
||||||
// The filter envelope takes the same shape the amp does under the active play mode:
|
// 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
|
// 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).
|
// mode's dialled values (see core/instrument/CLAUDE.md).
|
||||||
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
|
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
|
||||||
AhdParams trigEnv; // Trigger: the same staged AHD 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
|
// Velocity -> cutoff, in the normalized cutoff domain. The contribution is
|
||||||
// because a flat curve under a depth control would make every velocity the same offset;
|
// velAmount * velocityCurve.eval(velocity): the BIPOLAR curve carries the shape (and its
|
||||||
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
|
// own sign), the depth knob scales it, and BOTH apply. The curve is flat at 0 by default,
|
||||||
// FRESH FilterParams — the shared codec's corrupt/truncated-point-list repair
|
// so no depth setting produces velocity modulation until a curve is drawn.
|
||||||
// (VelocityCurve::fromPoints, used for both this curve and the amp's) still degrades to
|
VelocityCurve velocityCurve = VelocityCurve::zero();
|
||||||
// flat() regardless, since that repair has no curve-specific fallback.
|
|
||||||
VelocityCurve velocityCurve = VelocityCurve::linear();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Full-scale of the velocity->pitch curve: y = +/-1 transposes by this many semitones. Shared
|
||||||
|
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
|
||||||
|
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
|
||||||
|
|
||||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||||
// Varispeed, pitch envelope off, filter off) — core regression tests rely on this; the
|
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
|
||||||
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
|
// on this; the 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; // Gate amp
|
AdsrParams adsr; // Gate amp
|
||||||
@@ -142,6 +145,10 @@ struct PlayParams {
|
|||||||
AhdParams trigAhd; // Trigger amp
|
AhdParams trigAhd; // Trigger amp
|
||||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||||
PitchEnvParams pitchEnv;
|
PitchEnvParams pitchEnv;
|
||||||
|
// Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0
|
||||||
|
// by default, so it transposes nothing until a curve is drawn. Folded into the voice's
|
||||||
|
// baseRatio_ at note-on — it is fixed for the note's lifetime, so it costs no per-frame work.
|
||||||
|
VelocityCurve pitchVelocityCurve = VelocityCurve::zero();
|
||||||
FilterParams filter;
|
FilterParams filter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ namespace reasampler::instrument::engine {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
|
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
|
||||||
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
|
double clampValue(double a, CurveDomain d) { return std::clamp(a, curveYMin(d), kCurveYMax); }
|
||||||
|
|
||||||
// X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
|
// X spans the width for [0,127]; Y spans (height-1) rows for the domain's range with its max at
|
||||||
// (pixel y increases downward, so this axis is inverted relative to amp).
|
// the TOP (pixel y increases downward, so this axis is inverted relative to the value).
|
||||||
double velPerPixel(const VelocityCurve::Box& box) {
|
double velPerPixel(const VelocityCurve::Box& box) {
|
||||||
const int w = std::max(0, box.width);
|
const int w = std::max(0, box.width);
|
||||||
if (w <= 0) return 0.0;
|
if (w <= 0) return 0.0;
|
||||||
return (kVelMax - kVelMin) / static_cast<double>(w);
|
return (kVelMax - kVelMin) / static_cast<double>(w);
|
||||||
}
|
}
|
||||||
double ampPerPixel(const VelocityCurve::Box& box) {
|
double valuePerPixel(const VelocityCurve::Box& box, CurveDomain d) {
|
||||||
const int h = std::max(0, box.height);
|
const int h = std::max(0, box.height);
|
||||||
if (h <= 1) return 0.0;
|
if (h <= 1) return 0.0;
|
||||||
return (kAmpMax - kAmpMin) / static_cast<double>(h - 1);
|
return (kCurveYMax - curveYMin(d)) / static_cast<double>(h - 1);
|
||||||
}
|
}
|
||||||
int velToX(const VelocityCurve::Box& box, double velocity) {
|
int velToX(const VelocityCurve::Box& box, double velocity) {
|
||||||
const int w = std::max(0, box.width);
|
const int w = std::max(0, box.width);
|
||||||
@@ -31,10 +31,11 @@ int velToX(const VelocityCurve::Box& box, double velocity) {
|
|||||||
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
|
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
|
||||||
return box.left + static_cast<int>(frac * static_cast<double>(w) + 0.5);
|
return box.left + static_cast<int>(frac * static_cast<double>(w) + 0.5);
|
||||||
}
|
}
|
||||||
int ampToY(const VelocityCurve::Box& box, double amp) {
|
int valueToY(const VelocityCurve::Box& box, double value, CurveDomain d) {
|
||||||
const int h = std::max(0, box.height);
|
const int h = std::max(0, box.height);
|
||||||
if (h <= 1) return box.top;
|
if (h <= 1) return box.top;
|
||||||
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
|
const double lo = curveYMin(d);
|
||||||
|
const double frac = (clampValue(value, d) - lo) / (kCurveYMax - lo);
|
||||||
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
|
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,39 +43,51 @@ int ampToY(const VelocityCurve::Box& box, double amp) {
|
|||||||
|
|
||||||
VelocityCurve VelocityCurve::flat() {
|
VelocityCurve VelocityCurve::flat() {
|
||||||
VelocityCurve c;
|
VelocityCurve c;
|
||||||
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
|
const double n = curveNeutral(CurveDomain::Unipolar);
|
||||||
|
c.points_ = {{kVelMin, n}, {kVelMax, n}};
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
VelocityCurve VelocityCurve::linear() {
|
VelocityCurve VelocityCurve::linear() {
|
||||||
VelocityCurve c;
|
VelocityCurve c;
|
||||||
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
|
c.points_ = {{kVelMin, 0.0}, {kVelMax, kCurveYMax}};
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
VelocityCurve VelocityCurve::zero() {
|
||||||
|
VelocityCurve c;
|
||||||
|
c.domain_ = CurveDomain::Bipolar;
|
||||||
|
const double n = curveNeutral(CurveDomain::Bipolar);
|
||||||
|
c.points_ = {{kVelMin, n}, {kVelMax, n}};
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) {
|
||||||
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
|
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
|
||||||
// duplicate-X knots).
|
// duplicate-X knots).
|
||||||
for (VelocityPoint& p : pts) {
|
for (VelocityPoint& p : pts) {
|
||||||
p.velocity = clampVelocity(p.velocity);
|
p.velocity = clampVelocity(p.velocity);
|
||||||
p.amp = clampAmp(p.amp);
|
p.value = clampValue(p.value, domain);
|
||||||
}
|
}
|
||||||
std::stable_sort(pts.begin(), pts.end(),
|
std::stable_sort(pts.begin(), pts.end(),
|
||||||
[](const VelocityPoint& a, const VelocityPoint& b) {
|
[](const VelocityPoint& a, const VelocityPoint& b) {
|
||||||
return a.velocity < b.velocity;
|
return a.velocity < b.velocity;
|
||||||
});
|
});
|
||||||
if (pts.size() < 2) return flat();
|
if (pts.size() < 2) {
|
||||||
|
return domain == CurveDomain::Bipolar ? zero() : flat();
|
||||||
|
}
|
||||||
if (pts.front().velocity > kVelMin) {
|
if (pts.front().velocity > kVelMin) {
|
||||||
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
|
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value});
|
||||||
} else {
|
} else {
|
||||||
pts.front().velocity = kVelMin;
|
pts.front().velocity = kVelMin;
|
||||||
}
|
}
|
||||||
if (pts.back().velocity < kVelMax) {
|
if (pts.back().velocity < kVelMax) {
|
||||||
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
|
pts.push_back(VelocityPoint{kVelMax, pts.back().value});
|
||||||
} else {
|
} else {
|
||||||
pts.back().velocity = kVelMax;
|
pts.back().velocity = kVelMax;
|
||||||
}
|
}
|
||||||
VelocityCurve c;
|
VelocityCurve c;
|
||||||
|
c.domain_ = domain;
|
||||||
c.points_ = std::move(pts);
|
c.points_ = std::move(pts);
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
@@ -84,7 +97,7 @@ namespace {
|
|||||||
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
|
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
|
||||||
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
|
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
|
||||||
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
|
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
|
||||||
// makes the spline reproduce a straight line to ~1e-15 for linear()-style input.
|
// makes the spline reproduce a straight line for linear()-style input.
|
||||||
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
||||||
if (dPrev * dNext <= 0.0) return 0.0;
|
if (dPrev * dNext <= 0.0) return 0.0;
|
||||||
const double w1 = 2.0 * spanNext + spanPrev;
|
const double w1 = 2.0 * spanNext + spanPrev;
|
||||||
@@ -95,29 +108,29 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
double VelocityCurve::eval(double velocity) const {
|
double VelocityCurve::eval(double velocity) const {
|
||||||
if (points_.empty()) return kAmpMax;
|
if (points_.empty()) return curveNeutral(domain_);
|
||||||
if (points_.size() == 1) return clampAmp(points_[0].amp);
|
if (points_.size() == 1) return clampValue(points_[0].value, domain_);
|
||||||
const double v = clampVelocity(velocity);
|
const double v = clampVelocity(velocity);
|
||||||
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
|
if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_);
|
||||||
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
|
if (v >= points_.back().velocity) return clampValue(points_.back().value, domain_);
|
||||||
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
||||||
const VelocityPoint& a = points_[i];
|
const VelocityPoint& a = points_[i];
|
||||||
const VelocityPoint& b = points_[i + 1];
|
const VelocityPoint& b = points_[i + 1];
|
||||||
if (v >= a.velocity && v <= b.velocity) {
|
if (v >= a.velocity && v <= b.velocity) {
|
||||||
const double span = b.velocity - a.velocity;
|
const double span = b.velocity - a.velocity;
|
||||||
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
|
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
|
||||||
if (span <= 0.0) return clampAmp(b.amp);
|
if (span <= 0.0) return clampValue(b.value, domain_);
|
||||||
|
|
||||||
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
|
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.value, b.value]
|
||||||
// between the two knots (no overshoot), reproducing a straight line for collinear input.
|
// between the two knots (no overshoot), reproducing a straight line for collinear input.
|
||||||
const double d = (b.amp - a.amp) / span;
|
const double d = (b.value - a.value) / span;
|
||||||
|
|
||||||
double mA = d;
|
double mA = d;
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
const VelocityPoint& prev = points_[i - 1];
|
const VelocityPoint& prev = points_[i - 1];
|
||||||
const double spanPrev = a.velocity - prev.velocity;
|
const double spanPrev = a.velocity - prev.velocity;
|
||||||
if (spanPrev > 0.0) {
|
if (spanPrev > 0.0) {
|
||||||
const double dPrev = (a.amp - prev.amp) / spanPrev;
|
const double dPrev = (a.value - prev.value) / spanPrev;
|
||||||
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
||||||
} else {
|
} else {
|
||||||
mA = 0.0;
|
mA = 0.0;
|
||||||
@@ -128,7 +141,7 @@ double VelocityCurve::eval(double velocity) const {
|
|||||||
const VelocityPoint& next = points_[i + 2];
|
const VelocityPoint& next = points_[i + 2];
|
||||||
const double spanNext = next.velocity - b.velocity;
|
const double spanNext = next.velocity - b.velocity;
|
||||||
if (spanNext > 0.0) {
|
if (spanNext > 0.0) {
|
||||||
const double dNext = (next.amp - b.amp) / spanNext;
|
const double dNext = (next.value - b.value) / spanNext;
|
||||||
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
||||||
} else {
|
} else {
|
||||||
mB = 0.0;
|
mB = 0.0;
|
||||||
@@ -142,15 +155,15 @@ double VelocityCurve::eval(double velocity) const {
|
|||||||
const double h10 = t3 - 2.0 * t2 + t;
|
const double h10 = t3 - 2.0 * t2 + t;
|
||||||
const double h01 = -2.0 * t3 + 3.0 * t2;
|
const double h01 = -2.0 * t3 + 3.0 * t2;
|
||||||
const double h11 = t3 - t2;
|
const double h11 = t3 - t2;
|
||||||
const double y = h00 * a.amp + h10 * span * mA + h01 * b.amp + h11 * span * mB;
|
const double y = h00 * a.value + h10 * span * mA + h01 * b.value + h11 * span * mB;
|
||||||
return clampAmp(y);
|
return clampValue(y, domain_);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return clampAmp(points_.back().amp); // unreachable (v is between the endpoints)
|
return clampValue(points_.back().value, domain_); // unreachable (v is between the endpoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
|
std::size_t VelocityCurve::addPoint(double velocity, double value) {
|
||||||
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
|
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_)};
|
||||||
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
|
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
|
||||||
std::size_t i = 0;
|
std::size_t i = 0;
|
||||||
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
||||||
@@ -158,12 +171,12 @@ std::size_t VelocityCurve::addPoint(double velocity, double amp) {
|
|||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
|
|
||||||
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double amp) {
|
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) {
|
||||||
if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range)
|
if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range)
|
||||||
const bool isFirst = (index == 0);
|
const bool isFirst = (index == 0);
|
||||||
const bool isLast = (index + 1 == points_.size());
|
const bool isLast = (index + 1 == points_.size());
|
||||||
|
|
||||||
double newAmp = clampAmp(amp);
|
double newValue = clampValue(value, domain_);
|
||||||
double newVel;
|
double newVel;
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
newVel = kVelMin;
|
newVel = kVelMin;
|
||||||
@@ -174,7 +187,7 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
|
|||||||
const double hi = points_[index + 1].velocity;
|
const double hi = points_[index + 1].velocity;
|
||||||
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
||||||
}
|
}
|
||||||
points_[index] = VelocityPoint{newVel, newAmp};
|
points_[index] = VelocityPoint{newVel, newValue};
|
||||||
return points_[index];
|
return points_[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,12 +198,13 @@ bool VelocityCurve::deletePoint(std::size_t index) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const VelocityPoint& p) {
|
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box,
|
||||||
return CurvePixel{velToX(box, p.velocity), ampToY(box, p.amp)};
|
const VelocityPoint& p) const {
|
||||||
|
return CurvePixel{velToX(box, p.velocity), valueToY(box, p.value, domain_)};
|
||||||
}
|
}
|
||||||
|
|
||||||
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) const {
|
||||||
// Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
|
// Exact inverse of velToX/valueToY (within one pixel); degenerate dims collapse the same way.
|
||||||
VelocityPoint p;
|
VelocityPoint p;
|
||||||
const int w = std::max(0, box.width);
|
const int w = std::max(0, box.width);
|
||||||
const int h = std::max(0, box.height);
|
const int h = std::max(0, box.height);
|
||||||
@@ -198,17 +212,19 @@ VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
|||||||
? kVelMin
|
? kVelMin
|
||||||
: clampVelocity(kVelMin + static_cast<double>(x - box.left) / static_cast<double>(w) *
|
: clampVelocity(kVelMin + static_cast<double>(x - box.left) / static_cast<double>(w) *
|
||||||
(kVelMax - kVelMin));
|
(kVelMax - kVelMin));
|
||||||
p.amp = (h <= 1)
|
const double lo = curveYMin(domain_);
|
||||||
? kAmpMax
|
p.value = (h <= 1)
|
||||||
: clampAmp(kAmpMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
|
? kCurveYMax
|
||||||
(kAmpMax - kAmpMin));
|
: clampValue(kCurveYMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
|
||||||
|
(kCurveYMax - lo),
|
||||||
|
domain_);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
int VelocityCurve::pointAtPixel(const Box& box, int x, int y) const {
|
int VelocityCurve::pointAtPixel(const Box& box, int x, int y) const {
|
||||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||||
const int px = velToX(box, points_[i].velocity);
|
const int px = velToX(box, points_[i].velocity);
|
||||||
const int py = ampToY(box, points_[i].amp);
|
const int py = valueToY(box, points_[i].value, domain_);
|
||||||
if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) {
|
if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) {
|
||||||
return static_cast<int>(i);
|
return static_cast<int>(i);
|
||||||
}
|
}
|
||||||
@@ -221,22 +237,24 @@ VelocityCurve VelocityCurve::resolvePointDrag(const VelocityCurve& grabCurve, st
|
|||||||
VelocityCurve out = grabCurve;
|
VelocityCurve out = grabCurve;
|
||||||
if (index >= out.points_.size()) return out; // out of range -> no motion
|
if (index >= out.points_.size()) return out; // out of range -> no motion
|
||||||
const double velPerPx = velPerPixel(box);
|
const double velPerPx = velPerPixel(box);
|
||||||
const double ampPerPx = ampPerPixel(box);
|
const double valPerPx = valuePerPixel(box, grabCurve.domain_);
|
||||||
if (velPerPx <= 0.0 || ampPerPx <= 0.0) return out; // degenerate box -> no motion
|
if (velPerPx <= 0.0 || valPerPx <= 0.0) return out; // degenerate box -> no motion
|
||||||
|
|
||||||
const VelocityPoint& grab = grabCurve.points_[index];
|
const VelocityPoint& grab = grabCurve.points_[index];
|
||||||
const double newVel = grab.velocity + static_cast<double>(dxPixels) * velPerPx;
|
const double newVel = grab.velocity + static_cast<double>(dxPixels) * velPerPx;
|
||||||
// Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp.
|
// Y increases downward but the value increases upward, so a downward drag (positive dy)
|
||||||
const double newAmp = grab.amp - static_cast<double>(dyPixels) * ampPerPx;
|
// LOWERS the value.
|
||||||
out.movePoint(index, newVel, newAmp); // applies box + neighbour-X + endpoint-pin clamps
|
const double newValue = grab.value - static_cast<double>(dyPixels) * valPerPx;
|
||||||
|
out.movePoint(index, newVel, newValue); // applies box + neighbour-X + endpoint-pin clamps
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
|
bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
|
||||||
|
if (domain_ != other.domain_) return false;
|
||||||
if (points_.size() != other.points_.size()) return false;
|
if (points_.size() != other.points_.size()) return false;
|
||||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||||
if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false;
|
if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false;
|
||||||
if (std::fabs(points_[i].amp - other.points_[i].amp) > eps) return false;
|
if (std::fabs(points_[i].value - other.points_[i].value) > eps) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
|
// velocity_curve.h — the velocity->modulation transfer curve, shared by all three
|
||||||
// in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
|
// destinations (amp gain, pitch offset, filter cutoff offset). eval(velocity) is called once
|
||||||
// rather than a Rect: this module sits below sampler_core in the link graph and must not gain
|
// per note-on in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit
|
||||||
// a transitive dependency on editor-layout types.
|
// pixel Box rather than a Rect: this module sits below sampler_core in the link graph and must
|
||||||
|
// not gain a transitive dependency on editor-layout types.
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -10,17 +11,31 @@
|
|||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
|
|
||||||
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
|
// The MIDI velocity domain [0,127] — the X span every point clamps into.
|
||||||
inline constexpr double kVelMin = 0.0;
|
inline constexpr double kVelMin = 0.0;
|
||||||
inline constexpr double kVelMax = 127.0;
|
inline constexpr double kVelMax = 127.0;
|
||||||
inline constexpr double kAmpMin = 0.0;
|
inline constexpr double kCurveYMax = 1.0;
|
||||||
inline constexpr double kAmpMax = 1.0;
|
|
||||||
|
// The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing
|
||||||
|
// curve is flat at 1. BIPOLAR [-1,1] is a SIGNED modulation shape — the pitch and filter
|
||||||
|
// domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A
|
||||||
|
// bipolar curve does not preclude a depth control beside it: the filter has one, and the two
|
||||||
|
// compose multiplicatively (play_params.h).
|
||||||
|
enum class CurveDomain { Unipolar, Bipolar };
|
||||||
|
|
||||||
|
constexpr double curveYMin(CurveDomain d) { return d == CurveDomain::Bipolar ? -1.0 : 0.0; }
|
||||||
|
|
||||||
|
// The value that changes nothing in each domain — unity gain, or zero modulation. THE one home
|
||||||
|
// for that value: eval()'s own empty-curve fallback reads it directly, and flat()/zero() (what
|
||||||
|
// fromPoints' sub-2-point fallback constructs) are built from it too, so a corrupt blob always
|
||||||
|
// loses the shaping rather than inventing one, however the fallback is reached.
|
||||||
|
constexpr double curveNeutral(CurveDomain d) { return d == CurveDomain::Bipolar ? 0.0 : 1.0; }
|
||||||
|
|
||||||
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
|
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
|
||||||
// through the named constructors / addPoint rather than pushing raw points.
|
// through the named constructors / addPoint rather than pushing raw points.
|
||||||
struct VelocityPoint {
|
struct VelocityPoint {
|
||||||
double velocity = 0.0; // X, [0,127]
|
double velocity = 0.0; // X, [0,127]
|
||||||
double amp = 0.0; // Y, [0,1]
|
double value = 0.0; // Y, in the owning curve's domain
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pick radius (px) around a node's drawn point for the editor hit-test.
|
// Pick radius (px) around a node's drawn point for the editor hit-test.
|
||||||
@@ -28,44 +43,49 @@ inline constexpr int kCurveNodeGrabRadius = 6;
|
|||||||
|
|
||||||
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
|
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
|
||||||
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
|
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
|
||||||
// overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
|
// overshoots a segment's value range. For collinear knots the tangents reduce to the secant
|
||||||
// so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
|
// slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints
|
||||||
// 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
|
// (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never
|
||||||
|
// deletable.
|
||||||
class VelocityCurve {
|
class VelocityCurve {
|
||||||
public:
|
public:
|
||||||
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the default — see
|
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
|
||||||
// velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the
|
// velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the
|
||||||
// pre-existing linear() response.
|
// pre-existing linear() response.
|
||||||
static VelocityCurve flat();
|
static VelocityCurve flat();
|
||||||
static VelocityCurve linear();
|
static VelocityCurve linear();
|
||||||
|
// The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn.
|
||||||
|
static VelocityCurve zero();
|
||||||
|
|
||||||
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
|
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
|
||||||
// each point, stable-sorts by velocity, forces both endpoints present (synthesized if
|
// each point into `domain`, stable-sorts by velocity, forces both endpoints present
|
||||||
// missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
|
// (synthesized if missing), falls back to the domain's neutral curve if fewer than 2 usable
|
||||||
// blob yields a well-formed curve, never an invariant-violating one.
|
// points remain. A corrupt/truncated blob yields a well-formed curve, never an
|
||||||
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
|
// invariant-violating one.
|
||||||
|
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain);
|
||||||
|
|
||||||
|
CurveDomain domain() const { return domain_; }
|
||||||
const std::vector<VelocityPoint>& points() const { return points_; }
|
const std::vector<VelocityPoint>& points() const { return points_; }
|
||||||
std::size_t size() const { return points_.size(); }
|
std::size_t size() const { return points_.size(); }
|
||||||
|
|
||||||
// Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
|
// Degenerate cases (shouldn't occur post-construction): empty curve returns the domain's
|
||||||
// one-point curve returns that point's amp.
|
// neutral; a one-point curve returns that point's value.
|
||||||
double eval(double velocity) const;
|
double eval(double velocity) const;
|
||||||
|
|
||||||
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
|
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
|
||||||
// subsequent move can separate them. Returns the inserted index.
|
// subsequent move can separate them. Returns the inserted index.
|
||||||
std::size_t addPoint(double velocity, double amp);
|
std::size_t addPoint(double velocity, double value);
|
||||||
|
|
||||||
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
|
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
|
||||||
// endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
|
// endpoints are pinned in X (only their value moves); out-of-range index is a no-op.
|
||||||
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
|
VelocityPoint movePoint(std::size_t index, double velocity, double value);
|
||||||
|
|
||||||
// Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
|
// Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
|
||||||
// returning false.
|
// returning false.
|
||||||
bool deletePoint(std::size_t index);
|
bool deletePoint(std::size_t index);
|
||||||
|
|
||||||
// The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
|
// The drawn box, in pixels: X = velocity across the width, Y = value UP the height (the
|
||||||
// top). Passed explicitly rather than a Rect — see header preamble.
|
// domain's max at top). Passed explicitly rather than a Rect — see header preamble.
|
||||||
struct Box {
|
struct Box {
|
||||||
int left = 0;
|
int left = 0;
|
||||||
int top = 0;
|
int top = 0;
|
||||||
@@ -82,14 +102,15 @@ public:
|
|||||||
int x = 0;
|
int x = 0;
|
||||||
int y = 0;
|
int y = 0;
|
||||||
};
|
};
|
||||||
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
|
CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p) const;
|
||||||
|
|
||||||
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
|
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space
|
||||||
// lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
|
// click lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1
|
||||||
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
|
// reads the domain's max (the top row is what a collapsed box draws).
|
||||||
|
VelocityPoint pointFromPixel(const Box& box, int x, int y) const;
|
||||||
|
|
||||||
// `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
|
// `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
|
||||||
// Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
|
// Maps the pixel delta to velocity/value over the box, then applies movePoint's clamp. Zero
|
||||||
// width/height box or out-of-range index returns grabCurve unchanged.
|
// width/height box or out-of-range index returns grabCurve unchanged.
|
||||||
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
||||||
const Box& box, int dxPixels, int dyPixels);
|
const Box& box, int dxPixels, int dyPixels);
|
||||||
@@ -97,9 +118,16 @@ public:
|
|||||||
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
|
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Private: an implicit-default curve is empty (no endpoints) and Unipolar, so a stray
|
||||||
|
// default-construction wouldn't fail loudly — it would eval() to unity gain everywhere,
|
||||||
|
// or a full +/-1 (a full-scale transpose / wide-open filter) if ever read as bipolar. Build
|
||||||
|
// through flat()/linear()/zero()/fromPoints(), all of which establish the endpoint invariant.
|
||||||
|
VelocityCurve() = default;
|
||||||
|
|
||||||
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
|
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
|
||||||
// invariant, mutators preserve it.
|
// invariant, mutators preserve it.
|
||||||
std::vector<VelocityPoint> points_;
|
std::vector<VelocityPoint> points_;
|
||||||
|
CurveDomain domain_ = CurveDomain::Unipolar;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace reasampler::instrument::engine
|
} // namespace reasampler::instrument::engine
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||||
// velocityGain_.
|
// velocityGain_.
|
||||||
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
||||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
|
||||||
// amount both derive from it below).
|
|
||||||
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack);
|
|
||||||
sample_ = &sample;
|
sample_ = &sample;
|
||||||
|
|
||||||
const PlayParams& p = sample.play;
|
const PlayParams& p = sample.play;
|
||||||
|
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather
|
||||||
|
// than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed
|
||||||
|
// read-rate bias and Preserve shift amount both derive from it below).
|
||||||
|
velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity);
|
||||||
|
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_;
|
||||||
playMode_ = p.playMode;
|
playMode_ = p.playMode;
|
||||||
pitchEngine_ = p.pitchEngine;
|
pitchEngine_ = p.pitchEngine;
|
||||||
|
|
||||||
@@ -129,8 +131,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
|
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
|
||||||
filterModAmount_ = p.filter.modAmount;
|
filterModAmount_ = p.filter.modAmount;
|
||||||
filterKeyTrack_ = p.filter.keyTrack;
|
filterKeyTrack_ = p.filter.keyTrack;
|
||||||
filterVelOffset_ =
|
filterVelCurve_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
||||||
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
filterVelOffset_ = p.filter.velAmount * filterVelCurve_;
|
||||||
filterRate_ = static_cast<double>(sample.sampleRate);
|
filterRate_ = static_cast<double>(sample.sampleRate);
|
||||||
rModAmount_.set(p.filter.modAmount);
|
rModAmount_.set(p.filter.modAmount);
|
||||||
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
|
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
|
||||||
@@ -247,6 +249,9 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
|
|||||||
}
|
}
|
||||||
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
|
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
|
||||||
filterKeyTrack_ = live.filterKeyTrack;
|
filterKeyTrack_ = live.filterKeyTrack;
|
||||||
|
// The note's curve value stays latched; only the depth over it is live. Both this and the
|
||||||
|
// key-track depth land in the base cutoff, so they glide through rBaseCutoff_ below.
|
||||||
|
filterVelOffset_ = live.filterVelAmount * filterVelCurve_;
|
||||||
filterSettings_.morphLaw = live.filterSettings.morphLaw;
|
filterSettings_.morphLaw = live.filterSettings.morphLaw;
|
||||||
const double baseTarget = filterCutoffBaseTarget(note_);
|
const double baseTarget = filterCutoffBaseTarget(note_);
|
||||||
if (snap) {
|
if (snap) {
|
||||||
@@ -281,7 +286,9 @@ void Voice::retune(int note) {
|
|||||||
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
|
// 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
|
// 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.
|
// first note's domain — consistent with "touch nothing else," but the drift lives here.
|
||||||
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
// The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ —
|
||||||
|
// one gesture, one strike.
|
||||||
|
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_;
|
||||||
// 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_.
|
||||||
if (filterOn_) updateFilterCutoffBase(note);
|
if (filterOn_) updateFilterCutoffBase(note);
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
|||||||
return std::pow(2.0, semis / 12.0);
|
return std::pow(2.0, semis / 12.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which
|
||||||
|
// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields
|
||||||
|
// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing.
|
||||||
|
inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) {
|
||||||
|
const double semis = curve.eval(static_cast<double>(velocity)) * kVelocityPitchRangeSemitones;
|
||||||
|
return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
// One octave expressed in the cutoff control's normalized domain, read out of the filter
|
// One octave expressed in the cutoff control's normalized domain, read out of the filter
|
||||||
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
|
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
|
||||||
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
|
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
|
||||||
@@ -561,7 +569,8 @@ private:
|
|||||||
bool releasing_ = false;
|
bool releasing_ = false;
|
||||||
int note_ = 0;
|
int note_ = 0;
|
||||||
double velocityGain_ = 1.0;
|
double velocityGain_ = 1.0;
|
||||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in
|
||||||
|
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
|
||||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||||
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;
|
||||||
@@ -593,7 +602,10 @@ private:
|
|||||||
double filterRate_ = 0.0;
|
double filterRate_ = 0.0;
|
||||||
double filterCutoffNorm_ = 1.0;
|
double filterCutoffNorm_ = 1.0;
|
||||||
double filterModAmount_ = 0.0;
|
double filterModAmount_ = 0.0;
|
||||||
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
|
// The curve's value at THIS note's velocity — a fact about the note, latched at note-on —
|
||||||
|
// and the product with the live depth, which a live depth move recomputes.
|
||||||
|
double filterVelCurve_ = 0.0;
|
||||||
|
double filterVelOffset_ = 0.0;
|
||||||
double filterKeyTrack_ = 0.0;
|
double filterKeyTrack_ = 0.0;
|
||||||
instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls
|
instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls
|
||||||
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// 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..v11) must be preserved exactly. This header is the ONE home for both ladders
|
// payload v1..v12) 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.
|
// and every version constant; the payload half is IMPLEMENTED in params_payload.
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -85,9 +85,24 @@ namespace reasampler::instrument::map {
|
|||||||
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
|
// 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 amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
|
||||||
//
|
//
|
||||||
// v11 (CURRENT WRITE FORMAT) is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE
|
// v11 is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE frames (a source-timeline
|
||||||
// frames (a source-timeline quantity like the loop points, so no rate resolves it). A v10-or-
|
// quantity like the loop points, so no rate resolves it). A v10-or-older blob is a strict
|
||||||
// older blob is a strict prefix and lifts to 0 — the hard seam it always played.
|
// prefix and lifts to 0 — the hard seam it always played.
|
||||||
|
//
|
||||||
|
// v12 (CURRENT WRITE FORMAT) is v11 PLUS the velocity->PITCH transfer curve (count + points,
|
||||||
|
// the same shape as v7's), appended after the loop crossfade. Its y is a normalized fraction
|
||||||
|
// of kVelocityPitchRangeSemitones (play_params.h) — a full-scale constant that lives OUTSIDE
|
||||||
|
// this frozen ladder, so retuning it re-tunes every saved v12 project's pitch-curve throw. It
|
||||||
|
// also RE-TAGS the DOMAIN of one frozen slot inside the v9 filter tail: that curve's y is read
|
||||||
|
// as BIPOLAR [-1,+1] from v12 on, having been UNIPOLAR [0,1] before. Every other filter slot,
|
||||||
|
// velAmount included, keeps its meaning — the cutoff contribution is still
|
||||||
|
// velAmount * curve(velocity).
|
||||||
|
// PRE-v12 LIFT: a domain re-tag and nothing more. A pre-v12 curve's stored y values all lie in
|
||||||
|
// [0,1], which is inside [-1,+1], so the widened box-clamp alters no knot and eval is unchanged
|
||||||
|
// at every velocity — a pre-v12 project sounds identical without any rounding argument. A
|
||||||
|
// pre-v12 blob carries no pitch curve at all and lifts to the bipolar flat-at-zero default,
|
||||||
|
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
|
||||||
|
// drawn into the negative half comes back with that half clamped to 0.
|
||||||
//
|
//
|
||||||
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
|
// 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
|
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
|
||||||
@@ -120,7 +135,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
|||||||
// The params-payload format version and its detection marker. The marker is a high sentinel
|
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||||
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||||
// a reader detects record shape independent of the envelope version.
|
// a reader detects record shape independent of the envelope version.
|
||||||
inline constexpr std::uint32_t kParamsPayloadVersion = 11; // v10 + the loop-crossfade tail
|
inline constexpr std::uint32_t kParamsPayloadVersion = 12; // v11 + the velocity->pitch curve
|
||||||
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
|
||||||
@@ -139,6 +154,11 @@ inline constexpr std::uint32_t kParamsCurveVersion = 10;
|
|||||||
// v10 + the loop-crossfade frame count.
|
// v10 + the loop-crossfade frame count.
|
||||||
inline constexpr std::uint32_t kParamsLoopVersion = 11;
|
inline constexpr std::uint32_t kParamsLoopVersion = 11;
|
||||||
|
|
||||||
|
// v11 + the velocity->pitch curve; the appended tail branches on THIS, never on
|
||||||
|
// kParamsPayloadVersion. The filter curve's v12 domain re-tag needs no branch of its own — a
|
||||||
|
// pre-v12 curve's y values are already valid bipolar ones.
|
||||||
|
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
|
||||||
|
|
||||||
// (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
|
||||||
|
|||||||
@@ -38,14 +38,15 @@ void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
|||||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
|
// A velocity curve: 4-byte LE control-point count, then per point velocity + value as doubles.
|
||||||
// The amp curve (v7) and the filter's own curve (v9) share this shape.
|
// The amp curve (v7), the filter's own curve (v9) and the pitch curve (v12) share this shape;
|
||||||
|
// the y DOMAIN is not on the wire — it is a property of the slot, so the reader supplies it.
|
||||||
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||||
const std::vector<VelocityPoint>& pts = curve.points();
|
const std::vector<VelocityPoint>& pts = curve.points();
|
||||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||||
for (const VelocityPoint& pt : pts) {
|
for (const VelocityPoint& pt : pts) {
|
||||||
putLE(out, doubleToBits(pt.velocity));
|
putLE(out, doubleToBits(pt.velocity));
|
||||||
putLE(out, doubleToBits(pt.amp));
|
putLE(out, doubleToBits(pt.value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +93,11 @@ void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate)
|
|||||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
|
// Read a velocity curve tail into `curve`, interpreting its y values in `domain` — the domain
|
||||||
// defensively; a truncated read leaves `curve` at whatever default it came in with.
|
// is not on the wire, it is a property of the slot. fromPoints repairs the X-order/endpoint
|
||||||
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
|
// invariant defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||||
|
void readCurveTail(ByteReader& r, VelocityCurve& curve,
|
||||||
|
reasampler::instrument::engine::CurveDomain domain) {
|
||||||
const std::uint32_t ptCount = r.u32();
|
const std::uint32_t ptCount = r.u32();
|
||||||
std::vector<VelocityPoint> pts;
|
std::vector<VelocityPoint> pts;
|
||||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||||
@@ -103,16 +106,18 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve) {
|
|||||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||||
const double vel = bitsToDouble(r.u64());
|
const double vel = bitsToDouble(r.u64());
|
||||||
const double amp = bitsToDouble(r.u64());
|
const double value = bitsToDouble(r.u64());
|
||||||
pts.push_back(VelocityPoint{vel, amp});
|
pts.push_back(VelocityPoint{vel, value});
|
||||||
}
|
}
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
// 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.
|
// which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
|
||||||
|
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
|
||||||
|
// v12 domain re-tag needs no version branch (see component_state_io.h).
|
||||||
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||||
FilterSeconds& f = p.play.filter;
|
FilterSeconds& f = p.play.filter;
|
||||||
f.enabled = (r.u8() != 0);
|
f.enabled = (r.u8() != 0);
|
||||||
@@ -135,7 +140,7 @@ void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
|||||||
f.env.decaySeconds = bitsToDouble(r.u64());
|
f.env.decaySeconds = bitsToDouble(r.u64());
|
||||||
f.env.sustainLevel = bitsToDouble(r.u64());
|
f.env.sustainLevel = bitsToDouble(r.u64());
|
||||||
f.env.releaseSeconds = bitsToDouble(r.u64());
|
f.env.releaseSeconds = bitsToDouble(r.u64());
|
||||||
readCurveTail(r, f.velocityCurve);
|
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
|
// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
|
||||||
@@ -240,7 +245,10 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
|
|||||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
// 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().
|
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||||
if (curveTail) readCurveTail(r, p.velocityCurve);
|
if (curveTail) {
|
||||||
|
readCurveTail(r, p.velocityCurve,
|
||||||
|
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||||
|
}
|
||||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||||
// leave the seconds product defaults on p.play.
|
// leave the seconds product defaults on p.play.
|
||||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||||
@@ -321,6 +329,8 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
|
|||||||
putAhd(out, f.trigEnv);
|
putAhd(out, f.trigEnv);
|
||||||
// v11: the loop crossfade, in SOURCE frames.
|
// v11: the loop crossfade, in SOURCE frames.
|
||||||
putLE(out, asU64(p.loopCrossfadeFrames));
|
putLE(out, asU64(p.loopCrossfadeFrames));
|
||||||
|
// v12: the velocity->pitch curve.
|
||||||
|
putCurve(out, pp.pitchVelocityCurve);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||||
@@ -350,7 +360,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
|||||||
if (hasStart) p.startPoint = r.i64();
|
if (hasStart) p.startPoint = r.i64();
|
||||||
readSecondsPlayTail(r, p, projectRate);
|
readSecondsPlayTail(r, p, projectRate);
|
||||||
p.keyTrack = bitsToDouble(r.u64());
|
p.keyTrack = bitsToDouble(r.u64());
|
||||||
readCurveTail(r, p.velocityCurve);
|
readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||||
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||||
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
|
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
|
||||||
if (pv >= kParamsLoopVersion) {
|
if (pv >= kParamsLoopVersion) {
|
||||||
@@ -359,6 +369,10 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
|||||||
const std::int64_t xf = r.i64();
|
const std::int64_t xf = r.i64();
|
||||||
p.loopCrossfadeFrames = xf > 0 ? xf : 0;
|
p.loopCrossfadeFrames = xf > 0 ? xf : 0;
|
||||||
}
|
}
|
||||||
|
if (pv >= kParamsVelocityVersion) {
|
||||||
|
readCurveTail(r, p.play.pitchVelocityCurve,
|
||||||
|
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
}
|
||||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||||
// the same degrade-don't-throw contract the zone ladder always had.
|
// the same degrade-don't-throw contract the zone ladder always had.
|
||||||
if (!r.ok) return PayloadRead{};
|
if (!r.ok) return PayloadRead{};
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
|||||||
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
||||||
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);
|
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
|
||||||
|
out.pitchVelocityCurve = stored.pitchVelocityCurve; // transfer curve, not a time
|
||||||
// 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;
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ struct FilterSeconds {
|
|||||||
double keyTrack = 0.0;
|
double keyTrack = 0.0;
|
||||||
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
|
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
|
||||||
AhdSeconds trigEnv; // Trigger
|
AhdSeconds trigEnv; // Trigger
|
||||||
VelocityCurve velocityCurve = VelocityCurve::linear();
|
VelocityCurve velocityCurve = VelocityCurve::zero();
|
||||||
};
|
};
|
||||||
|
|
||||||
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||||
@@ -200,6 +200,7 @@ struct PlaySeconds {
|
|||||||
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
|
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
|
||||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
||||||
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
|
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
|
||||||
|
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
|
||||||
FilterSeconds filter; // per-voice filter, off by default
|
FilterSeconds filter; // per-voice filter, off by default
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -57,4 +57,6 @@ reasampler_pure_library(deck_groups
|
|||||||
reasampler_test(deck_groups LINK deck_groups sample_bands)
|
reasampler_test(deck_groups LINK deck_groups sample_bands)
|
||||||
|
|
||||||
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
||||||
reasampler_test(curve_popup LINK curve_popup)
|
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
|
||||||
|
# proving that takes a curve of each domain mapped through the one curveBox.
|
||||||
|
reasampler_test(curve_popup LINK curve_popup velocity_curve)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
|
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor, shared
|
||||||
|
// by all three curves: the sheet is domain-agnostic, and only the curve's own y map differs.
|
||||||
// Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
|
// Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
|
||||||
// these rects.
|
// these rects.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -89,6 +89,17 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
|||||||
}
|
}
|
||||||
out.push_back(std::move(amp));
|
out.push_back(std::move(amp));
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// The three velocity transfer curves share one home, immediately left of VOICE: they
|
||||||
|
// shape three different destinations but are one gesture, and MASTER is reserved for
|
||||||
|
// post-voice-mixer concerns.
|
||||||
|
DeckGroupDesc vel;
|
||||||
|
vel.id = kGroupVelocity;
|
||||||
|
vel.captionWidth = 54;
|
||||||
|
vel.cellIds = {id(DeckParam::kAmpVelCurve), id(DeckParam::kPitchVelCurve),
|
||||||
|
id(DeckParam::kFilterVelCurve)};
|
||||||
|
out.push_back(std::move(vel));
|
||||||
|
}
|
||||||
{
|
{
|
||||||
DeckGroupDesc voice;
|
DeckGroupDesc voice;
|
||||||
voice.id = kGroupVoice;
|
voice.id = kGroupVoice;
|
||||||
@@ -108,6 +119,15 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CurveTarget curveTargetFor(int controlId) {
|
||||||
|
switch (static_cast<DeckParam>(controlId)) {
|
||||||
|
case DeckParam::kAmpVelCurve: return CurveTarget::kAmp;
|
||||||
|
case DeckParam::kPitchVelCurve: return CurveTarget::kPitch;
|
||||||
|
case DeckParam::kFilterVelCurve: return CurveTarget::kFilter;
|
||||||
|
default: return CurveTarget::kNone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DeckParam curveParamFor(DeckParam knob) {
|
DeckParam curveParamFor(DeckParam knob) {
|
||||||
switch (knob) {
|
switch (knob) {
|
||||||
case DeckParam::kAttack: return DeckParam::kAttackCurve;
|
case DeckParam::kAttack: return DeckParam::kAttackCurve;
|
||||||
@@ -145,6 +165,7 @@ bool isLiveDeckParam(DeckParam id) {
|
|||||||
case DeckParam::kFilterQ:
|
case DeckParam::kFilterQ:
|
||||||
case DeckParam::kFilterDrive:
|
case DeckParam::kFilterDrive:
|
||||||
case DeckParam::kFilterModAmt:
|
case DeckParam::kFilterModAmt:
|
||||||
|
case DeckParam::kFilterVel:
|
||||||
case DeckParam::kFilterKeyTrack:
|
case DeckParam::kFilterKeyTrack:
|
||||||
case DeckParam::kFilterEnvAttack:
|
case DeckParam::kFilterEnvAttack:
|
||||||
case DeckParam::kFilterEnvHold:
|
case DeckParam::kFilterEnvHold:
|
||||||
@@ -176,7 +197,9 @@ bool isLiveDeckParam(DeckParam id) {
|
|||||||
case DeckParam::kPitchEnvEnable:
|
case DeckParam::kPitchEnvEnable:
|
||||||
case DeckParam::kKeyTrack:
|
case DeckParam::kKeyTrack:
|
||||||
case DeckParam::kFilterEnable:
|
case DeckParam::kFilterEnable:
|
||||||
case DeckParam::kFilterVel:
|
case DeckParam::kAmpVelCurve:
|
||||||
|
case DeckParam::kPitchVelCurve:
|
||||||
|
case DeckParam::kFilterVelCurve:
|
||||||
case DeckParam::kFilterLaw:
|
case DeckParam::kFilterLaw:
|
||||||
case DeckParam::kAmpEnvSelect:
|
case DeckParam::kAmpEnvSelect:
|
||||||
case DeckParam::kPitchEnvSelect:
|
case DeckParam::kPitchEnvSelect:
|
||||||
@@ -217,6 +240,37 @@ bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) {
|
|||||||
return false; // unreachable for a valid enumerator; silences a warning.
|
return false; // unreachable for a valid enumerator; silences a warning.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
|
||||||
|
switch (id) {
|
||||||
|
case DeckParam::kPitchEnvAttack:
|
||||||
|
case DeckParam::kPitchEnvHold:
|
||||||
|
case DeckParam::kPitchEnvDecay:
|
||||||
|
case DeckParam::kPitchEnvDepth:
|
||||||
|
return !pitchEnvEnabled;
|
||||||
|
case DeckParam::kFilterMorph:
|
||||||
|
case DeckParam::kFilterCutoff:
|
||||||
|
case DeckParam::kFilterQ:
|
||||||
|
case DeckParam::kFilterDrive:
|
||||||
|
case DeckParam::kFilterModAmt:
|
||||||
|
case DeckParam::kFilterVel:
|
||||||
|
case DeckParam::kFilterKeyTrack:
|
||||||
|
// The filter's velocity curve sits in the VELOCITY group but is a filter parameter:
|
||||||
|
// it goes inert with every other one, so no surface can reach a param the knobs can't.
|
||||||
|
case DeckParam::kFilterVelCurve:
|
||||||
|
case DeckParam::kFilterEnvAttack:
|
||||||
|
case DeckParam::kFilterEnvHold:
|
||||||
|
case DeckParam::kFilterEnvDecay:
|
||||||
|
case DeckParam::kFilterEnvSustain:
|
||||||
|
case DeckParam::kFilterEnvRelease:
|
||||||
|
case DeckParam::kFilterTrigAttack:
|
||||||
|
case DeckParam::kFilterTrigHold:
|
||||||
|
case DeckParam::kFilterTrigDecay:
|
||||||
|
return !filterEnabled;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool liveCommitFor(LiveDragKind kind, int paramId) {
|
bool liveCommitFor(LiveDragKind kind, int paramId) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case LiveDragKind::kDeckKnob:
|
case LiveDragKind::kDeckKnob:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ enum class DeckParam {
|
|||||||
kFilterQ, // resonance
|
kFilterQ, // resonance
|
||||||
kFilterDrive, // in-loop drive depth
|
kFilterDrive, // in-loop drive depth
|
||||||
kFilterModAmt, // filter envelope -> cutoff, +/-100%
|
kFilterModAmt, // filter envelope -> cutoff, +/-100%
|
||||||
kFilterVel, // velocity -> cutoff, +/-100%
|
kFilterVel, // velocity curve -> cutoff depth, +/-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, // filter AHDSR (Gate)
|
kFilterEnvAttack, // filter AHDSR (Gate)
|
||||||
@@ -65,6 +65,11 @@ enum class DeckParam {
|
|||||||
kFilterEnvReleaseCurve,
|
kFilterEnvReleaseCurve,
|
||||||
kFilterTrigAttackCurve,
|
kFilterTrigAttackCurve,
|
||||||
kFilterTrigDecayCurve,
|
kFilterTrigDecayCurve,
|
||||||
|
// VELOCITY: the three transfer-curve cells. Each opens the curve popup rather than
|
||||||
|
// dragging a value — see curveTargetFor, which is also what tells a cell apart from a knob.
|
||||||
|
kAmpVelCurve,
|
||||||
|
kPitchVelCurve,
|
||||||
|
kFilterVelCurve,
|
||||||
// Overlay selection radios — transient view state, not parameters.
|
// Overlay selection radios — transient view state, not parameters.
|
||||||
kAmpEnvSelect,
|
kAmpEnvSelect,
|
||||||
kPitchEnvSelect,
|
kPitchEnvSelect,
|
||||||
@@ -86,10 +91,19 @@ enum DeckGroupId {
|
|||||||
kGroupFilter,
|
kGroupFilter,
|
||||||
kGroupFilterEnv,
|
kGroupFilterEnv,
|
||||||
kGroupAmpEnv,
|
kGroupAmpEnv,
|
||||||
|
kGroupVelocity,
|
||||||
kGroupVoice,
|
kGroupVoice,
|
||||||
kGroupMaster,
|
kGroupMaster,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
|
||||||
|
// one place a control id resolves to a curve target — paint (draw a curve thumbnail, not a
|
||||||
|
// dial) and hit-test (open a popup, not start a drag) both read this predicate rather than
|
||||||
|
// re-deriving which ids are curve cells. What each resolved target then shows (which stored
|
||||||
|
// curve, which title) is a separate switch — see the shell's curveFor/curveTitle.
|
||||||
|
enum class CurveTarget { kNone, kAmp, kPitch, kFilter };
|
||||||
|
CurveTarget curveTargetFor(int controlId);
|
||||||
|
|
||||||
// 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 and FILTER ENV groups' faces — AHDSR in
|
// two instance-wide groups. `playMode` picks the AMP and FILTER ENV groups' faces — AHDSR in
|
||||||
// Gate, AHD in Trigger — via knob_deck's blank-cell reservation (knob_deck.h) so a mode flip
|
// Gate, AHD in Trigger — via knob_deck's blank-cell reservation (knob_deck.h) so a mode flip
|
||||||
@@ -113,9 +127,11 @@ DeckParam curveParamFor(DeckParam knob);
|
|||||||
// 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
|
||||||
// the decoded PCM;
|
// the decoded PCM;
|
||||||
// - kKeyTrack and kFilterVel feed values a voice latches at note-on by design (the pitch
|
// - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by
|
||||||
// ratio and the velocity-curve result), so live delivery would retune or re-gain a note
|
// design (the pitch ratio and the curve results), so live delivery would retune or re-gain
|
||||||
// already struck;
|
// a note already struck. kFilterVel is NOT one of them: it is the DEPTH over the filter
|
||||||
|
// curve's latched result, the exact shape kFilterKeyTrack already has over the latched note
|
||||||
|
// number, and it glides through the same base-cutoff ramp;
|
||||||
// - kTrigLength resolves playEnd_, a fact about the note, not a setting of it;
|
// - kTrigLength resolves playEnd_, a fact about the note, not a setting of it;
|
||||||
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
|
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
|
||||||
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
|
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
|
||||||
@@ -152,6 +168,13 @@ OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
|
|||||||
// a param a knob couldn't (envelope_edit.h). Amp has no enable toggle and is never inert.
|
// 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);
|
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled);
|
||||||
|
|
||||||
|
// Whether a deck knob cell is drawn-but-dead: the pitch envelope's four knobs while it is
|
||||||
|
// disabled, and the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter
|
||||||
|
// parameter that just sits in that group) while the filter is disabled. Every other id is
|
||||||
|
// always live. Mirrors overlayEnvInert's group-toggle-gates-its-knobs shape for the deck's own
|
||||||
|
// mouse-down/paint (the shell's deckKnobDisabled is a thin int-id wrapper over this).
|
||||||
|
bool deckKnobInert(DeckParam id, 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
|
||||||
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
|
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ inline constexpr int kEditorMinWidth = 840;
|
|||||||
inline constexpr int kEditorMinHeight = 620;
|
inline constexpr int kEditorMinHeight = 620;
|
||||||
|
|
||||||
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
|
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
|
||||||
// preview, velocity knob, curve button, channel toggle). sample_chrome partitions it.
|
// preview, velocity knob, channel toggle). sample_chrome partitions it.
|
||||||
inline constexpr int kTitleHeight = 26;
|
inline constexpr int kTitleHeight = 26;
|
||||||
inline constexpr int kChromeRowHeight = 52;
|
inline constexpr int kChromeRowHeight = 52;
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ constexpr int kStripBandHeight = 30;
|
|||||||
constexpr int kRunGap = 6; // between adjacent items of the toolbar run
|
constexpr int kRunGap = 6; // between adjacent items of the toolbar run
|
||||||
constexpr int kChanSegW = 52;
|
constexpr int kChanSegW = 52;
|
||||||
constexpr int kChanSegH = 18;
|
constexpr int kChanSegH = 18;
|
||||||
constexpr int kCurveBtnSize = 24;
|
|
||||||
constexpr int kVelCellW = 44;
|
constexpr int kVelCellW = 44;
|
||||||
constexpr int kVelLabelH = 12;
|
constexpr int kVelLabelH = 12;
|
||||||
constexpr int kPreviewBtnW = 64;
|
constexpr int kPreviewBtnW = 64;
|
||||||
constexpr int kRunButtonH = 24; // Browse and Preview
|
constexpr int kRunButtonH = 24; // Browse and Preview
|
||||||
|
|
||||||
|
constexpr int kPreviewGlyphMinH = 6;
|
||||||
|
constexpr int kPreviewGlyphMaxH = 14;
|
||||||
|
constexpr int kPreviewGlyphPad = 4; // clearance between the glyph and the button edge
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||||
@@ -39,7 +42,8 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
|||||||
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
|
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
|
||||||
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
|
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
|
||||||
|
|
||||||
// The fixed run, right to left: Browse, Mono|Stereo, curve, velocity cell, preview.
|
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview. The
|
||||||
|
// velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
|
||||||
const int navH = std::min(kRunButtonH, row.height);
|
const int navH = std::min(kRunButtonH, row.height);
|
||||||
const int navTop = topFor(navH);
|
const int navTop = topFor(navH);
|
||||||
const int navRight = std::max(row.x, row.right() - kPad);
|
const int navRight = std::max(row.x, row.right() - kPad);
|
||||||
@@ -53,14 +57,9 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
|||||||
r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x,
|
r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x,
|
||||||
chanTop + kChanSegH);
|
chanTop + kChanSegH);
|
||||||
|
|
||||||
const int curveTop = topFor(kCurveBtnSize);
|
|
||||||
const int curveRight = leftOf(r.chanMono.x, kRunGap);
|
|
||||||
r.curveBtn = Rect::ltrb(leftOf(curveRight, kCurveBtnSize), curveTop, curveRight,
|
|
||||||
curveTop + kCurveBtnSize);
|
|
||||||
|
|
||||||
const int cellH = std::min(row.height, knobSize + kVelLabelH);
|
const int cellH = std::min(row.height, knobSize + kVelLabelH);
|
||||||
const int cellTop = topFor(cellH);
|
const int cellTop = topFor(cellH);
|
||||||
const int cellRight = leftOf(r.curveBtn.x, kRunGap);
|
const int cellRight = leftOf(r.chanMono.x, kRunGap);
|
||||||
r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH);
|
r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH);
|
||||||
const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2;
|
const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2;
|
||||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||||
@@ -89,4 +88,24 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PreviewGlyph previewGlyph(const Rect& button) {
|
||||||
|
PreviewGlyph g;
|
||||||
|
if (button.empty()) return g;
|
||||||
|
// Even height so the apex lands exactly on the button's horizontal centre line rather
|
||||||
|
// than a half-pixel off it.
|
||||||
|
int h = std::min({kPreviewGlyphMaxH, button.height - 2 * kPreviewGlyphPad,
|
||||||
|
button.width - 2 * kPreviewGlyphPad});
|
||||||
|
h -= h % 2;
|
||||||
|
if (h < kPreviewGlyphMinH) return g;
|
||||||
|
const int w = std::max(2, (h * 7) / 8); // ~equilateral: the classic transport triangle
|
||||||
|
const int cx = button.x + button.width / 2;
|
||||||
|
const int cy = button.y + button.height / 2;
|
||||||
|
g.leftX = cx - w / 2;
|
||||||
|
g.apexX = g.leftX + w;
|
||||||
|
g.topY = cy - h / 2;
|
||||||
|
g.bottomY = g.topY + h;
|
||||||
|
g.apexY = cy;
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler::instrument::ui
|
} // namespace reasampler::instrument::ui
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ struct ChromeRects {
|
|||||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||||
Rect velKnob;
|
Rect velKnob;
|
||||||
Rect velLabel;
|
Rect velLabel;
|
||||||
Rect curveBtn; // opens the velocity-curve popup
|
|
||||||
Rect chanMono;
|
Rect chanMono;
|
||||||
Rect chanStereo;
|
Rect chanStereo;
|
||||||
Rect navBrowse;
|
Rect navBrowse;
|
||||||
@@ -32,4 +31,20 @@ struct ChromeRects {
|
|||||||
// `knobSize` is the deck knob square, passed in so this module does not depend on knob_deck.
|
// `knobSize` is the deck knob square, passed in so this module does not depend on knob_deck.
|
||||||
ChromeRects chromeRects(const Rect& chrome, int knobSize);
|
ChromeRects chromeRects(const Rect& chrome, int knobSize);
|
||||||
|
|
||||||
|
// A right-pointing play triangle centered in the preview button: the button's whole label.
|
||||||
|
// Three vertices, handed straight to one filled-triangle draw. Drawn rather than embedded as a
|
||||||
|
// bitmap so it inherits the palette role of whatever interaction state the button is in.
|
||||||
|
struct PreviewGlyph {
|
||||||
|
int leftX = 0; // the vertical edge
|
||||||
|
int topY = 0;
|
||||||
|
int bottomY = 0;
|
||||||
|
int apexX = 0; // the point, on the button's vertical centre line
|
||||||
|
int apexY = 0;
|
||||||
|
bool empty() const { return apexX <= leftX || bottomY <= topY; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sized off the button's shorter dimension and clamped, so the glyph stays legible in a squat
|
||||||
|
// button and never outgrows a generous one. An unusably small button yields an empty glyph.
|
||||||
|
PreviewGlyph previewGlyph(const Rect& button);
|
||||||
|
|
||||||
} // namespace reasampler::instrument::ui
|
} // namespace reasampler::instrument::ui
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio> // snprintf (deck value labels)
|
#include <cstdio> // snprintf (deck value labels)
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility> // std::as_const (the const/non-const editedCurve pair)
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#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
|
||||||
@@ -52,7 +53,9 @@ namespace {
|
|||||||
// The ceiling is READ from the overlay's schematic scale rather than restated: the AHDSR
|
// The ceiling is READ from the overlay's schematic scale rather than restated: the AHDSR
|
||||||
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
|
// schematic anchors a maxed knob at the canvas edge, which only holds while the two agree.
|
||||||
constexpr double kEnvTimeMaxSeconds = instrument::ui::kGateStageMaxSeconds;
|
constexpr double kEnvTimeMaxSeconds = instrument::ui::kGateStageMaxSeconds;
|
||||||
constexpr double kPitchDepthMaxSemis = 24.0; // pitch depth throw: +/-24 st, centered
|
// Pitch depth throw: +/-kVelocityPitchRangeSemitones, centered. The one throw the pitch
|
||||||
|
// envelope's peak and the velocity->pitch curve's full scale both speak (play_params.h).
|
||||||
|
constexpr double kPitchDepthMaxSemis = kVelocityPitchRangeSemitones;
|
||||||
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 —
|
// The raw stored curve exponent for a curve-dial control id, read DIRECTLY off the field —
|
||||||
@@ -513,6 +516,45 @@ void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) const {
|
||||||
|
switch (target) {
|
||||||
|
case CurveTarget::kPitch: return params_.play.pitchVelocityCurve;
|
||||||
|
case CurveTarget::kFilter: return params_.play.filter.velocityCurve;
|
||||||
|
case CurveTarget::kAmp:
|
||||||
|
case CurveTarget::kNone:
|
||||||
|
// kNone reads as amp — a valid, harmless choice for a target-agnostic caller (a
|
||||||
|
// disabled deck cell still needs SOME curve to draw). editedCurve()'s mutable
|
||||||
|
// overload below refuses kNone rather than relying on this fallback to protect amp.
|
||||||
|
return params_.velocityCurve;
|
||||||
|
}
|
||||||
|
return params_.velocityCurve;
|
||||||
|
}
|
||||||
|
|
||||||
|
VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) {
|
||||||
|
return const_cast<VelocityCurve&>(std::as_const(*this).curveFor(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
const VelocityCurve& ReaSamplerEditor::editedCurve() const { return curveFor(curvePopup_); }
|
||||||
|
|
||||||
|
VelocityCurve& ReaSamplerEditor::editedCurve() {
|
||||||
|
// Refuses kNone rather than aliasing amp (see curvePopup_'s declaration for why); should be
|
||||||
|
// unreachable now that every writer of kNone also cancels the drag, but this is the second,
|
||||||
|
// independent line of defense.
|
||||||
|
if (curvePopup_ == CurveTarget::kNone) {
|
||||||
|
static VelocityCurve sink = VelocityCurve::flat();
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
|
return curveFor(curvePopup_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::closeCurvePopup() {
|
||||||
|
curvePopup_ = CurveTarget::kNone;
|
||||||
|
if (drag_ == DragKind::kCurveNode) {
|
||||||
|
drag_ = DragKind::kNone;
|
||||||
|
curvePointIndex_ = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
||||||
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
||||||
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
|
|||||||
y < curveRect.y - kCurveDragOffMargin ||
|
y < curveRect.y - kCurveDragOffMargin ||
|
||||||
y > curveRect.bottom() + kCurveDragOffMargin;
|
y > curveRect.bottom() + kCurveDragOffMargin;
|
||||||
if (off) {
|
if (off) {
|
||||||
params_.velocityCurve.deletePoint(static_cast<std::size_t>(curveIdx));
|
editedCurve().deletePoint(static_cast<std::size_t>(curveIdx));
|
||||||
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node
|
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) {
|
|||||||
HoverTarget h; // kNone by default
|
HoverTarget h; // kNone by default
|
||||||
if (view_ == View::kBrowse) {
|
if (view_ == View::kBrowse) {
|
||||||
h = hoverBrowse(w, hgt, x, y);
|
h = hoverBrowse(w, hgt, x, y);
|
||||||
} else if (curvePopupOpen_) { // modal over the face
|
} else if (curvePopup_ != CurveTarget::kNone) { // modal over the face
|
||||||
h = hoverCurvePopup(w, hgt, x, y);
|
h = hoverCurvePopup(w, hgt, x, y);
|
||||||
} else {
|
} else {
|
||||||
const FaceLayout fl = faceLayout(w, hgt);
|
const FaceLayout fl = faceLayout(w, hgt);
|
||||||
|
|||||||
@@ -131,9 +131,12 @@ void ReaSamplerEditor::onMouseWheel(int delta) {
|
|||||||
|
|
||||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||||
// The curve popup: Esc dismisses (checked first — the popup is modal over the face, and
|
// The curve popup: Esc dismisses (checked first — the popup is modal over the face, and
|
||||||
// the Browse search cannot hold focus under it).
|
// the Browse search cannot hold focus under it). Esc reaches here unconditionally
|
||||||
if (curvePopupOpen_ && ch == 27) {
|
// (editor_platform's WM_CHAR routing), including mid-drag on a curve node — closeCurvePopup
|
||||||
curvePopupOpen_ = false;
|
// cancels that drag too, or a subsequent WM_MOUSEMOVE would resolve editedCurve() with the
|
||||||
|
// popup already closed.
|
||||||
|
if (curvePopup_ != CurveTarget::kNone && ch == 27) {
|
||||||
|
closeCurvePopup();
|
||||||
invalidate();
|
invalidate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger,
|
// editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger,
|
||||||
// the preview-velocity knob grab, the curve-button summon, the channel toggle, and the
|
// the preview-velocity knob grab, the channel toggle, and the piano strip's root grab plus
|
||||||
// piano strip's root grab plus its live drag. Windows-only.
|
// its live drag. Windows-only.
|
||||||
|
|
||||||
#include "shell/instrument/reasampler_editor.h"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
|
|
||||||
@@ -51,12 +51,6 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
|
|||||||
invalidate();
|
invalidate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// The mini curve-preview button: summon the popup editor.
|
|
||||||
if (contains(cr.curveBtn, x, y)) {
|
|
||||||
curvePopupOpen_ = true;
|
|
||||||
invalidate();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (contains(cr.chanMono, x, y)) {
|
if (contains(cr.chanMono, x, y)) {
|
||||||
channelMode_ = ChannelMode::Mono;
|
channelMode_ = ChannelMode::Mono;
|
||||||
processor_->setChannelMode(ChannelMode::Mono);
|
processor_->setChannelMode(ChannelMode::Mono);
|
||||||
@@ -105,7 +99,6 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl
|
|||||||
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
|
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
|
||||||
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
|
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
|
||||||
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
|
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
|
||||||
if (contains(cr.curveBtn, x, y)) return {HoverKind::kCurveButton, -1};
|
|
||||||
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
|
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
|
||||||
if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1};
|
if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1};
|
||||||
if (!cr.rootStrip.empty()) {
|
if (!cr.rootStrip.empty()) {
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
|||||||
// While open the sheet is modal over the face — it owns every left-click. Close click /
|
// While open the sheet is modal over the face — it owns every left-click. Close click /
|
||||||
// outside-wash click dismiss (outside only when no drag is in flight); in-box clicks
|
// outside-wash click dismiss (outside only when no drag is in flight); in-box clicks
|
||||||
// route to the curve machinery; anything else on the sheet is swallowed.
|
// route to the curve machinery; anything else on the sheet is swallowed.
|
||||||
if (!curvePopupOpen_) return false;
|
if (curvePopup_ == CurveTarget::kNone) return false;
|
||||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||||
if (contains(pl.close, x, y)) {
|
if (contains(pl.close, x, y)) {
|
||||||
curvePopupOpen_ = false;
|
closeCurvePopup();
|
||||||
invalidate();
|
invalidate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
|
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
|
||||||
curvePopupOpen_ = false;
|
closeCurvePopup();
|
||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -42,12 +42,12 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
|
|||||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||||
if (box.width <= 0 || box.height <= 1) return;
|
if (box.width <= 0 || box.height <= 1) return;
|
||||||
|
|
||||||
int idx = params_.velocityCurve.pointAtPixel(box, x, y);
|
int idx = editedCurve().pointAtPixel(box, x, y);
|
||||||
|
|
||||||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
||||||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
||||||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||||||
if (params_.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) {
|
||||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||||
commitAndReload();
|
commitAndReload();
|
||||||
}
|
}
|
||||||
@@ -66,8 +66,8 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
|
|||||||
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
||||||
y >= box.top && y < box.top + box.height);
|
y >= box.top && y < box.top + box.height);
|
||||||
if (inBox) {
|
if (inBox) {
|
||||||
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
|
const VelocityPoint p = editedCurve().pointFromPixel(box, x, y);
|
||||||
idx = static_cast<int>(params_.velocityCurve.addPoint(p.velocity, p.amp));
|
idx = static_cast<int>(editedCurve().addPoint(p.velocity, p.value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
|
|||||||
|
|
||||||
drag_ = DragKind::kCurveNode;
|
drag_ = DragKind::kCurveNode;
|
||||||
curvePointIndex_ = idx;
|
curvePointIndex_ = idx;
|
||||||
dragStartCurve_ = params_.velocityCurve; // AFTER the add — resolvePointDrag's delta base
|
dragStartCurve_ = editedCurve(); // AFTER the add — resolvePointDrag's delta base
|
||||||
dragCurveRect_ = r;
|
dragCurveRect_ = r;
|
||||||
dragStartX_ = x;
|
dragStartX_ = x;
|
||||||
dragStartY_ = y;
|
dragStartY_ = y;
|
||||||
@@ -87,7 +87,7 @@ void ReaSamplerEditor::dragCurve(int x, int y) {
|
|||||||
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box (absolute
|
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box (absolute
|
||||||
// delta — the mirror of the envelope-node drag). Live feedback only; commit on release.
|
// delta — the mirror of the envelope-node drag). Live feedback only; commit on release.
|
||||||
if (curvePointIndex_ < 0) return;
|
if (curvePointIndex_ < 0) return;
|
||||||
params_.velocityCurve = VelocityCurve::resolvePointDrag(
|
editedCurve() = VelocityCurve::resolvePointDrag(
|
||||||
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
|
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
|
||||||
curveBoxFromRect(dragCurveRect_), x - dragStartX_, y - dragStartY_);
|
curveBoxFromRect(dragCurveRect_), x - dragStartX_, y - dragStartY_);
|
||||||
invalidate();
|
invalidate();
|
||||||
@@ -98,16 +98,16 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
|||||||
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
||||||
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
||||||
// Right-clicks act only while the popup is open, and never during an in-flight left drag.
|
// Right-clicks act only while the popup is open, and never during an in-flight left drag.
|
||||||
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
|
if (!processor_ || view_ == View::kBrowse || curvePopup_ == CurveTarget::kNone) return;
|
||||||
if (drag_ != DragKind::kNone) return;
|
if (drag_ != DragKind::kNone) return;
|
||||||
RECT rc{};
|
RECT rc{};
|
||||||
GetClientRect(childHwnd_, &rc);
|
GetClientRect(childHwnd_, &rc);
|
||||||
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
||||||
if (!contains(pl.curveBox, x, y)) return;
|
if (!contains(pl.curveBox, x, y)) return;
|
||||||
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
||||||
const int idx = params_.velocityCurve.pointAtPixel(box, x, y);
|
const int idx = editedCurve().pointAtPixel(box, x, y);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
if (params_.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) {
|
||||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||||
commitAndReload();
|
commitAndReload();
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, in
|
|||||||
if (!contains(pl.curveBox, x, y)) return {};
|
if (!contains(pl.curveBox, x, y)) return {};
|
||||||
// A curve node under the pointer lights accent-hot.
|
// A curve node under the pointer lights accent-hot.
|
||||||
const int idx =
|
const int idx =
|
||||||
params_.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
|
editedCurve().pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
|
||||||
if (idx < 0) return {};
|
if (idx < 0) return {};
|
||||||
return {HoverKind::kCurveNode, idx};
|
return {HoverKind::kCurveNode, idx};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,31 +17,10 @@ using namespace reasampler::ui;
|
|||||||
using namespace reasampler::instrument::ui;
|
using namespace reasampler::instrument::ui;
|
||||||
|
|
||||||
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
|
bool ReaSamplerEditor::deckKnobDisabled(int id) const {
|
||||||
switch (static_cast<ParamControl>(id)) {
|
// Thin int-id wrapper over the pure, CTest-covered predicate — see deckKnobInert
|
||||||
case ParamControl::kPitchEnvAttack:
|
// (deck_groups.h) for which cells go inert and why.
|
||||||
case ParamControl::kPitchEnvHold:
|
return deckKnobInert(static_cast<DeckParam>(id), params_.play.pitchEnv.enabled,
|
||||||
case ParamControl::kPitchEnvDecay:
|
params_.play.filter.enabled);
|
||||||
case ParamControl::kPitchEnvDepth:
|
|
||||||
return !params_.play.pitchEnv.enabled;
|
|
||||||
case ParamControl::kFilterMorph:
|
|
||||||
case ParamControl::kFilterCutoff:
|
|
||||||
case ParamControl::kFilterQ:
|
|
||||||
case ParamControl::kFilterDrive:
|
|
||||||
case ParamControl::kFilterModAmt:
|
|
||||||
case ParamControl::kFilterVel:
|
|
||||||
case ParamControl::kFilterKeyTrack:
|
|
||||||
case ParamControl::kFilterEnvAttack:
|
|
||||||
case ParamControl::kFilterEnvHold:
|
|
||||||
case ParamControl::kFilterEnvDecay:
|
|
||||||
case ParamControl::kFilterEnvSustain:
|
|
||||||
case ParamControl::kFilterEnvRelease:
|
|
||||||
case ParamControl::kFilterTrigAttack:
|
|
||||||
case ParamControl::kFilterTrigHold:
|
|
||||||
case ParamControl::kFilterTrigDecay:
|
|
||||||
return !params_.play.filter.enabled;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||||
@@ -96,6 +75,13 @@ 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 VELOCITY cell summons the popup editor instead of starting a knob drag.
|
||||||
|
const CurveTarget curveCell = curveTargetFor(hit.id);
|
||||||
|
if (curveCell != CurveTarget::kNone) {
|
||||||
|
curvePopup_ = curveCell;
|
||||||
|
invalidate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// A grab on the inner disc drags the CURVE control instead, but only where the stage
|
// 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.
|
// 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 ParamControl curve = curveParamFor(static_cast<ParamControl>(hit.id));
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
paintDeck(bmp, fl);
|
paintDeck(bmp, fl);
|
||||||
|
|
||||||
// The curve popup: a centered sheet over the whole face, drawn last.
|
// The curve popup: a centered sheet over the whole face, drawn last.
|
||||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
if (curvePopup_ != CurveTarget::kNone) paintCurvePopup(bmp, w, h);
|
||||||
|
|
||||||
// The piano strip's note-name chip overhangs its band, so it goes on top of everything.
|
// The piano strip's note-name chip overhangs its band, so it goes on top of everything.
|
||||||
paintChromeTooltip(bmp, fl, w, h);
|
paintChromeTooltip(bmp, fl, w, h);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title +
|
// editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title +
|
||||||
// live readout, then the control run — preview, preview-velocity knob, curve button,
|
// live readout, then the control run — preview, preview-velocity knob, Mono|Stereo, Browse)
|
||||||
// Mono|Stereo, Browse) over the strip row, which the piano strip has to itself. Windows-only;
|
// over the strip row, which the piano strip has to itself. Windows-only; all rects come from
|
||||||
// all rects come from the pure sample_chrome interior and the pure keyboard_strip geometry.
|
// the pure sample_chrome interior and the pure keyboard_strip geometry.
|
||||||
|
|
||||||
#include "shell/instrument/reasampler_editor.h"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
|
|
||||||
@@ -129,12 +129,23 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
|
|||||||
if (empty) return;
|
if (empty) return;
|
||||||
|
|
||||||
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
||||||
|
// A drawn play triangle rather than a label or an embedded image: it inherits the button's
|
||||||
|
// own foreground role, so it stays legible in every interaction state at no build cost.
|
||||||
{
|
{
|
||||||
const KitButtonBox box{toKitBox(cr.preview)};
|
const KitButtonBox box{toKitBox(cr.preview)};
|
||||||
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
|
const bool active = (previewingNote_ >= 0);
|
||||||
|
const InteractionState st = active ? InteractionState::Active
|
||||||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover
|
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover
|
||||||
: InteractionState::Rest);
|
: InteractionState::Rest);
|
||||||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
drawButton(bmp, box, nullptr, st, /*warn=*/false);
|
||||||
|
const PreviewGlyph g = previewGlyph(cr.preview);
|
||||||
|
if (!g.empty()) {
|
||||||
|
// Same surface as the button label, so the glyph tracks drawButton's own rule
|
||||||
|
// (buttonLabelRole) rather than a second copy of it that could desync from the kit.
|
||||||
|
const LICE_pixel ink = toLice(roleColor(buttonLabelRole(st)));
|
||||||
|
LICE_FillTriangle(bmp, g.leftX, g.topY, g.leftX, g.bottomY, g.apexX, g.apexY,
|
||||||
|
ink, 1.0f, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
||||||
@@ -156,9 +167,6 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mini curve-preview button: opens the popup editor.
|
|
||||||
paintCurveButton(bmp, cr.curveBtn);
|
|
||||||
|
|
||||||
// Mono | Stereo output-mode toggle.
|
// Mono | Stereo output-mode toggle.
|
||||||
{
|
{
|
||||||
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
|
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// editor_paint_curve.cpp — the velocity->amp curve surfaces: the chrome band's mini
|
// editor_paint_curve.cpp — the velocity-curve surfaces: the VELOCITY deck group's mini
|
||||||
// preview button and the modal popup sheet that hosts the full editor. Band-independent
|
// thumbnails and the modal popup sheet that hosts the full editor. Band-independent (the
|
||||||
// (the popup floats over the whole face). Windows-only.
|
// popup floats over the whole face). Windows-only.
|
||||||
|
|
||||||
#include "shell/instrument/reasampler_editor.h"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
|
|
||||||
@@ -15,28 +15,53 @@ namespace reasampler::vst {
|
|||||||
using namespace reasampler::ui; // kit vocabulary
|
using namespace reasampler::ui; // kit vocabulary
|
||||||
using namespace reasampler::instrument::ui; // popup geometry
|
using namespace reasampler::instrument::ui; // popup geometry
|
||||||
|
|
||||||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r) {
|
namespace {
|
||||||
|
|
||||||
|
const char* curveTitle(CurveTarget target) {
|
||||||
|
switch (target) {
|
||||||
|
case CurveTarget::kPitch: return "VELOCITY -> PITCH";
|
||||||
|
case CurveTarget::kFilter: return "VELOCITY -> FILTER";
|
||||||
|
case CurveTarget::kAmp:
|
||||||
|
case CurveTarget::kNone:
|
||||||
|
return "VELOCITY -> AMP";
|
||||||
|
}
|
||||||
|
return "VELOCITY -> AMP";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target,
|
||||||
|
bool disabled, bool hovered) {
|
||||||
if (r.width <= 0 || r.height <= 0) return;
|
if (r.width <= 0 || r.height <= 0) return;
|
||||||
// A hairline-bordered bg/cell square with the live velocity curve traced in miniature
|
// A hairline-bordered bg/cell square with the live velocity curve traced in miniature
|
||||||
// (no node markers at this scale). Hover lifts it; it draws Active (accent-primary
|
// (no node markers at this scale). Hover lifts it; it draws Active (accent-primary
|
||||||
// border) while its popup is open, and re-renders live as the popup edits the curve.
|
// border) while its popup is open, and re-renders live as the popup edits the curve.
|
||||||
const bool hov = isHovered(HoverKind::kCurveButton, -1);
|
|
||||||
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
||||||
hov ? InteractionState::Hover : InteractionState::Rest);
|
disabled ? InteractionState::Disabled
|
||||||
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
|
: (hovered ? InteractionState::Hover : InteractionState::Rest));
|
||||||
: roleColor(Role::LineHairline);
|
const KitColor border = (!disabled && curvePopup_ == target)
|
||||||
|
? roleColor(Role::AccentPrimary)
|
||||||
|
: roleColor(Role::LineHairline);
|
||||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
|
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
|
||||||
const VelocityCurve& curve = params_.velocityCurve;
|
const VelocityCurve& curve = curveFor(target);
|
||||||
const int inset = 3;
|
const int inset = 3;
|
||||||
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
|
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
|
||||||
r.height - 2 * inset};
|
r.height - 2 * inset};
|
||||||
if (mini.width > 1 && mini.height > 1) {
|
if (mini.width > 1 && mini.height > 1) {
|
||||||
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
|
// A bipolar thumbnail gets its zero line, without which a flat-at-zero curve and a
|
||||||
|
// flat-at-minimum one would draw identically at this scale.
|
||||||
|
if (curve.domain() == instrument::engine::CurveDomain::Bipolar) {
|
||||||
|
const int zy = curve.pixelFromPoint(mini, {0.0, 0.0}).y;
|
||||||
|
LICE_Line(bmp, mini.left, zy, mini.left + mini.width, zy,
|
||||||
|
toLice(roleColor(Role::LineHairline)), 1.0f, 0, false);
|
||||||
|
}
|
||||||
|
const LICE_pixel trace =
|
||||||
|
toLice(roleColor(disabled ? Role::LineHairline : Role::AccentSecondary));
|
||||||
int prevX = 0, prevY = 0;
|
int prevX = 0, prevY = 0;
|
||||||
for (int px = 0; px <= mini.width; ++px) {
|
for (int px = 0; px <= mini.width; ++px) {
|
||||||
const int mx = mini.left + px;
|
const int mx = mini.left + px;
|
||||||
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
|
const double vel = curve.pointFromPixel(mini, mx, mini.top).velocity;
|
||||||
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
|
const int my = curve.pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
|
||||||
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
|
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
|
||||||
prevX = mx;
|
prevX = mx;
|
||||||
prevY = my;
|
prevY = my;
|
||||||
@@ -52,7 +77,7 @@ void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
|
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
|
||||||
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
|
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
|
||||||
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||||
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
|
kitText(bmp, pl.title, curveTitle(curvePopup_), Font::Micro, Role::TextDim);
|
||||||
{
|
{
|
||||||
const KitButtonBox box{toKitBox(pl.close)};
|
const KitButtonBox box{toKitBox(pl.close)};
|
||||||
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
|
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
|
||||||
@@ -69,26 +94,33 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r) {
|
|||||||
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
|
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
|
||||||
|
|
||||||
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
||||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
|
// caption — the popup sheet's own title labels this context (the popup is the only host).
|
||||||
// is the only host).
|
|
||||||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||||
|
|
||||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||||
if (box.width <= 0 || box.height <= 1) return;
|
if (box.width <= 0 || box.height <= 1) return;
|
||||||
const VelocityCurve& curve = params_.velocityCurve;
|
const VelocityCurve& curve = editedCurve();
|
||||||
|
|
||||||
|
// A bipolar editor needs its zero axis drawn: it is the whole "off" reading, and without
|
||||||
|
// it the default flat curve is indistinguishable from any other flat one.
|
||||||
|
if (curve.domain() == instrument::engine::CurveDomain::Bipolar) {
|
||||||
|
const int zy = curve.pixelFromPoint(box, {0.0, 0.0}).y;
|
||||||
|
LICE_Line(bmp, box.left, zy, box.left + box.width, zy,
|
||||||
|
toLice(roleColor(Role::LineHairline)), 1.0f, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
||||||
// secondary accent (the same grammar as the envelope trace over the waveform). The x ->
|
// secondary accent (the same grammar as the envelope trace over the waveform). The x ->
|
||||||
// velocity and amp -> y mappings both go through the pure module so the trace, the node
|
// velocity and value -> y mappings both go through the pure module so the trace, the node
|
||||||
// handles, and the hit-test all share one coordinate system.
|
// handles, and the hit-test all share one coordinate system.
|
||||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||||
int prevX = 0, prevY = 0;
|
int prevX = 0, prevY = 0;
|
||||||
for (int px = 0; px <= box.width; ++px) {
|
for (int px = 0; px <= box.width; ++px) {
|
||||||
const int cx = box.left + px;
|
const int cx = box.left + px;
|
||||||
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
|
const double vel = curve.pointFromPixel(box, cx, box.top).velocity;
|
||||||
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
const int cy = curve.pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
||||||
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
|
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
|
||||||
prevX = cx;
|
prevX = cx;
|
||||||
prevY = cy;
|
prevY = cy;
|
||||||
@@ -108,7 +140,7 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r) {
|
|||||||
dragCurY_ < r.y - kCurveDragOffMargin ||
|
dragCurY_ < r.y - kCurveDragOffMargin ||
|
||||||
dragCurY_ > r.bottom() + kCurveDragOffMargin);
|
dragCurY_ > r.bottom() + kCurveDragOffMargin);
|
||||||
for (std::size_t i = 0; i < curve.points().size(); ++i) {
|
for (std::size_t i = 0; i < curve.points().size(); ++i) {
|
||||||
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
|
const auto np = curve.pixelFromPoint(box, curve.points()[i]);
|
||||||
const bool grabbed = (drag_ == DragKind::kCurveNode &&
|
const bool grabbed = (drag_ == DragKind::kCurveNode &&
|
||||||
curvePointIndex_ == static_cast<int>(i));
|
curvePointIndex_ == static_cast<int>(i));
|
||||||
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
|||||||
case ParamControl::kFilterModAmt: return "Mod";
|
case ParamControl::kFilterModAmt: return "Mod";
|
||||||
case ParamControl::kFilterVel: return "Vel";
|
case ParamControl::kFilterVel: return "Vel";
|
||||||
case ParamControl::kFilterKeyTrack: return "Key Trk";
|
case ParamControl::kFilterKeyTrack: return "Key Trk";
|
||||||
|
case ParamControl::kAmpVelCurve: return "Amp";
|
||||||
|
case ParamControl::kPitchVelCurve: return "Pitch";
|
||||||
|
case ParamControl::kFilterVelCurve: return "Filter";
|
||||||
case ParamControl::kFilterEnvAttack: return "F.Att";
|
case ParamControl::kFilterEnvAttack: return "F.Att";
|
||||||
case ParamControl::kFilterEnvHold: return "F.Hold";
|
case ParamControl::kFilterEnvHold: return "F.Hold";
|
||||||
case ParamControl::kFilterEnvDecay: return "F.Dec";
|
case ParamControl::kFilterEnvDecay: return "F.Dec";
|
||||||
@@ -102,6 +105,7 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
|||||||
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
||||||
case kGroupFilter: caption = "FILTER"; break;
|
case kGroupFilter: caption = "FILTER"; break;
|
||||||
case kGroupFilterEnv: caption = "FILTER ENV"; break;
|
case kGroupFilterEnv: caption = "FILTER ENV"; break;
|
||||||
|
case kGroupVelocity: caption = "VELOCITY"; break;
|
||||||
case kGroupVoice: caption = "VOICE"; break;
|
case kGroupVoice: caption = "VOICE"; break;
|
||||||
case kGroupMaster: caption = "MASTER"; break;
|
case kGroupMaster: caption = "MASTER"; break;
|
||||||
default: break;
|
default: break;
|
||||||
@@ -169,6 +173,16 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
|||||||
for (const DeckCellLayout& c : g.cells) {
|
for (const DeckCellLayout& c : g.cells) {
|
||||||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's spare)
|
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);
|
||||||
|
// A VELOCITY cell is a popup opener, not a dial: it shows its curve in miniature
|
||||||
|
// where a knob face would be, and its whole cell is the click target.
|
||||||
|
const CurveTarget curveCell = curveTargetFor(c.id);
|
||||||
|
if (curveCell != CurveTarget::kNone) {
|
||||||
|
paintCurveButton(bmp, c.knob, curveCell, disabled,
|
||||||
|
!disabled && isHovered(HoverKind::kControl, c.id));
|
||||||
|
kitTextCentered(bmp, c.label, knobName(static_cast<ParamControl>(c.id)),
|
||||||
|
Font::Micro, Role::TextDim);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
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);
|
||||||
const InteractionState st =
|
const InteractionState st =
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ void ReaSamplerEditor::refreshFromBank() {
|
|||||||
monoTrigger_ = processor_->monoTrigger();
|
monoTrigger_ = processor_->monoTrigger();
|
||||||
// A refresh that emptied the selection closes the curve popup — an open-but-invisible
|
// A refresh that emptied the selection closes the curve popup — an open-but-invisible
|
||||||
// modal would otherwise swallow clicks on the empty state.
|
// modal would otherwise swallow clicks on the empty state.
|
||||||
if (selectedId_.empty()) curvePopupOpen_ = false;
|
if (selectedId_.empty()) closeCurvePopup();
|
||||||
// Drop a filter that names a bank no longer present.
|
// Drop a filter that names a bank no longer present.
|
||||||
if (!activeFilterBankId_.empty()) {
|
if (!activeFilterBankId_.empty()) {
|
||||||
bool found = false;
|
bool found = false;
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ private:
|
|||||||
// spelling.
|
// spelling.
|
||||||
using OverlayEnv = instrument::ui::OverlayEnv;
|
using OverlayEnv = instrument::ui::OverlayEnv;
|
||||||
|
|
||||||
|
// Which of the three velocity curves a deck cell edits — also the popup's open state.
|
||||||
|
using CurveTarget = instrument::ui::CurveTarget;
|
||||||
|
|
||||||
// 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
|
||||||
@@ -120,7 +123,6 @@ private:
|
|||||||
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
|
||||||
kCurveButton, // the chrome mini curve-preview button (opens the popup)
|
|
||||||
kPopupClose, // the curve popup's Close (x) button
|
kPopupClose, // the curve popup's Close (x) button
|
||||||
};
|
};
|
||||||
struct HoverTarget {
|
struct HoverTarget {
|
||||||
@@ -148,7 +150,7 @@ private:
|
|||||||
|
|
||||||
// --- Band painters (one TU each, mirroring the input side) ---
|
// --- Band painters (one TU each, mirroring the input side) ---
|
||||||
// Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
|
// Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
|
||||||
// curve button, channel toggle).
|
// channel toggle).
|
||||||
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty);
|
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty);
|
||||||
// The hovered piano key's note-name chip. Drawn after every band — it overhangs the
|
// The hovered piano key's note-name chip. Drawn after every band — it overhangs the
|
||||||
// chrome into whatever is below it.
|
// chrome into whatever is below it.
|
||||||
@@ -159,11 +161,12 @@ private:
|
|||||||
// label<->value swap on hover/drag.
|
// label<->value swap on hover/drag.
|
||||||
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
|
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
|
||||||
|
|
||||||
// The mini curve-preview button (chrome) and the modal curve editor it summons.
|
// A deck cell's mini curve thumbnail (the VELOCITY group) and the modal editor it summons.
|
||||||
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r);
|
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target, bool disabled,
|
||||||
|
bool hovered);
|
||||||
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
|
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
|
||||||
// The velocity->amp transfer-curve editor (X = velocity 0-127, Y = amp 0-1); its only
|
// The velocity transfer-curve editor (X = velocity 0-127, Y = the curve's own domain); its
|
||||||
// host is the popup sheet. `r` empty -> draws nothing.
|
// only host is the popup sheet. `r` empty -> draws nothing.
|
||||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
|
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
|
||||||
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at
|
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at
|
||||||
// full band height (never per lane).
|
// full band height (never per lane).
|
||||||
@@ -456,8 +459,24 @@ private:
|
|||||||
// delta from this anchor, so a grab never jumps the value.
|
// delta from this anchor, so a grab never jumps the value.
|
||||||
double dragKnobStartValue_ = 0.0;
|
double dragKnobStartValue_ = 0.0;
|
||||||
|
|
||||||
// Curve popup open flag, never persisted.
|
// Which velocity curve the popup is editing; kNone = closed. Never persisted. Every writer
|
||||||
bool curvePopupOpen_ = false;
|
// of kNone must also cancel a live curve-node drag (closeCurvePopup does both) — an Esc
|
||||||
|
// mid-drag that closed the popup without cancelling the drag used to leave editedCurve()'s
|
||||||
|
// mutable overload aliasing the amp curve underneath an in-flight pitch/filter drag.
|
||||||
|
CurveTarget curvePopup_ = CurveTarget::kNone;
|
||||||
|
void closeCurvePopup();
|
||||||
|
|
||||||
|
// THE one switch from a CurveTarget to the parameter-set curve it names — paint (button
|
||||||
|
// thumbnails, for every target) and edit (editedCurve, for curvePopup_ specifically) both
|
||||||
|
// route through it, so a fourth curve or a moved field is a one-place edit.
|
||||||
|
const VelocityCurve& curveFor(CurveTarget target) const;
|
||||||
|
VelocityCurve& curveFor(CurveTarget target);
|
||||||
|
|
||||||
|
// The curve curvePopup_ names — curveFor(curvePopup_), typed as its own pair because every
|
||||||
|
// edit path needs the mutable overload and paint needs the const one. The mutable overload
|
||||||
|
// refuses kNone (a closed popup has nothing open to edit) rather than aliasing amp.
|
||||||
|
VelocityCurve& editedCurve();
|
||||||
|
const VelocityCurve& editedCurve() const;
|
||||||
|
|
||||||
// Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
|
// Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
|
||||||
// recomputes at the new width. Cleared on refresh so a stale sample never shows.
|
// recomputes at the new width. Cleared on refresh so a stale sample never shows.
|
||||||
|
|||||||
@@ -214,14 +214,15 @@ void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label
|
|||||||
radius, toLice(borderCol), drawAlpha(borderCol), 0, true);
|
radius, toLice(borderCol), drawAlpha(borderCol), 0, true);
|
||||||
|
|
||||||
if (label && *label) {
|
if (label && *label) {
|
||||||
// Active fill is the accent — label goes in bg/base for contrast; else text/primary.
|
text(bmp, b, label, Font::Label, buttonLabelRole(state), Align::Center);
|
||||||
const Role textRole = (state == InteractionState::Active)
|
|
||||||
? Role::BgBase
|
|
||||||
: Role::TextPrimary;
|
|
||||||
text(bmp, b, label, Font::Label, textRole, Align::Center);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Role buttonLabelRole(InteractionState state) {
|
||||||
|
// Active fill is the accent — the mark goes in bg/base for contrast; else text/primary.
|
||||||
|
return state == InteractionState::Active ? Role::BgBase : Role::TextPrimary;
|
||||||
|
}
|
||||||
|
|
||||||
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state) {
|
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state) {
|
||||||
if (!bmp || geom.track.empty()) return;
|
if (!bmp || geom.track.empty()) return;
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ void fillSurface(LICE_IBitmap* bmp, const KitBox& box, Role role, InteractionSta
|
|||||||
void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label,
|
void drawButton(LICE_IBitmap* bmp, const KitButtonBox& button, const char* label,
|
||||||
InteractionState state, bool warn);
|
InteractionState state, bool warn);
|
||||||
|
|
||||||
|
// THE ink role drawButton's own label draws in, for `state` (see draw_kit.cpp). A non-text
|
||||||
|
// button mark (e.g. a drawn glyph, not text()) that needs to sit legibly on a drawButton
|
||||||
|
// surface should call this rather than re-deriving the rule.
|
||||||
|
Role buttonLabelRole(InteractionState state);
|
||||||
|
|
||||||
// A horizontal slider: track groove, accent-filled portion up to the handle, and the
|
// A horizontal slider: track groove, accent-filled portion up to the handle, and the
|
||||||
// handle itself. `geom` is the pure SliderGeometry the caller computed.
|
// handle itself. `geom` is the pure SliderGeometry the caller computed.
|
||||||
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state);
|
void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState state);
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ static void putRecordBody(std::vector<std::uint8_t>& out, const Zone& z, std::ui
|
|||||||
const std::vector<VelocityPoint> pts =
|
const std::vector<VelocityPoint> pts =
|
||||||
z.curve.empty() ? std::vector<VelocityPoint>{{0.0, 1.0}, {127.0, 1.0}} : z.curve;
|
z.curve.empty() ? std::vector<VelocityPoint>{{0.0, 1.0}, {127.0, 1.0}} : z.curve;
|
||||||
u32v(out, static_cast<std::uint32_t>(pts.size()));
|
u32v(out, static_cast<std::uint32_t>(pts.size()));
|
||||||
for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.amp); }
|
for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.value); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +281,8 @@ static void testComponentStateRoundTrip() {
|
|||||||
in.params.startPoint = 5;
|
in.params.startPoint = 5;
|
||||||
in.params.keyTrack = 1.5;
|
in.params.keyTrack = 1.5;
|
||||||
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
|
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}},
|
||||||
|
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||||
in.params.play.playMode = PlayMode::Trigger;
|
in.params.play.playMode = PlayMode::Trigger;
|
||||||
in.params.play.adsr.attackSeconds = 0.01;
|
in.params.play.adsr.attackSeconds = 0.01;
|
||||||
in.params.play.adsr.holdSeconds = 0.05;
|
in.params.play.adsr.holdSeconds = 0.05;
|
||||||
@@ -422,7 +423,8 @@ static void testGoldenFullBlobFixture() {
|
|||||||
in.params.startPoint = 250;
|
in.params.startPoint = 250;
|
||||||
in.params.keyTrack = 0.5;
|
in.params.keyTrack = 0.5;
|
||||||
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
|
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}},
|
||||||
|
reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||||
in.params.play.playMode = PlayMode::Trigger;
|
in.params.play.playMode = PlayMode::Trigger;
|
||||||
in.params.play.adsr.attackSeconds = 0.01;
|
in.params.play.adsr.attackSeconds = 0.01;
|
||||||
in.params.play.adsr.holdSeconds = 0.05;
|
in.params.play.adsr.holdSeconds = 0.05;
|
||||||
@@ -450,7 +452,7 @@ 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,0x0b,0x00,0x00,
|
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0c,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,
|
||||||
@@ -479,11 +481,11 @@ static void testGoldenFullBlobFixture() {
|
|||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env decay 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env decay 0.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // env sustain 1.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // env sustain 1.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env release 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env release 0.0
|
||||||
0x02,0x00,0x00,0x00, // filter curve: 2 points (linear)
|
0x02,0x00,0x00,0x00, // filter curve: 2 points (flat at zero)
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 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,0x00,0x00, // value 0.0
|
||||||
// --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no
|
// --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no
|
||||||
// curve or AHD field), in the header's documented order ---
|
// 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 attack curve 1.0
|
||||||
@@ -507,6 +509,12 @@ static void testGoldenFullBlobFixture() {
|
|||||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0
|
||||||
// --- payload v11 loop-crossfade tail ---
|
// --- payload v11 loop-crossfade tail ---
|
||||||
0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00, // loopCrossfadeFrames 256
|
0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00, // loopCrossfadeFrames 256
|
||||||
|
// --- payload v12 velocity->pitch curve, at its off default (flat at zero) ---
|
||||||
|
0x02,0x00,0x00,0x00, // 2 points
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
|
||||||
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
|
||||||
};
|
};
|
||||||
// clang-format on
|
// clang-format on
|
||||||
CHECK(bytes.size() == sizeof(kGolden));
|
CHECK(bytes.size() == sizeof(kGolden));
|
||||||
@@ -554,16 +562,17 @@ static void testEnvelopePrefixBytesFrozen() {
|
|||||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||||
}
|
}
|
||||||
CHECK(kComponentStateVersion == 11);
|
CHECK(kComponentStateVersion == 11);
|
||||||
CHECK(kParamsPayloadVersion == 11);
|
CHECK(kParamsPayloadVersion == 12);
|
||||||
CHECK(kParamsSingleRecordVersion == 8);
|
CHECK(kParamsSingleRecordVersion == 8);
|
||||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||||
// The filter, staged-curve and loop tails rode PAYLOAD bumps, not envelope ones — the two
|
// The filter, staged-curve, loop and velocity tails rode PAYLOAD bumps, not envelope ones
|
||||||
// axes stay independent, so a future envelope field cannot collide with any of them on one
|
// — the two axes stay independent, so a future envelope field cannot collide with any of
|
||||||
// number.
|
// them on one number.
|
||||||
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
|
||||||
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
CHECK(kParamsCurveVersion > kParamsFilterVersion);
|
||||||
CHECK(kParamsLoopVersion > kParamsCurveVersion);
|
CHECK(kParamsLoopVersion > kParamsCurveVersion);
|
||||||
CHECK(kParamsPayloadVersion == kParamsLoopVersion);
|
CHECK(kParamsVelocityVersion > kParamsLoopVersion);
|
||||||
|
CHECK(kParamsPayloadVersion == kParamsVelocityVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The filter tail (payload v9) --------------------------------------------
|
// --- The filter tail (payload v9) --------------------------------------------
|
||||||
@@ -601,7 +610,7 @@ static void testV8RecordLiftsToTheOffNeutralFilter() {
|
|||||||
CHECK(f.settings.driveNorm == def.settings.driveNorm);
|
CHECK(f.settings.driveNorm == def.settings.driveNorm);
|
||||||
CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow);
|
CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow);
|
||||||
CHECK(f.modAmount == 0.0);
|
CHECK(f.modAmount == 0.0);
|
||||||
CHECK(f.velAmount == 0.0);
|
for (int v = 0; v <= 127; ++v) CHECK(f.velocityCurve.eval(v) == 0.0);
|
||||||
CHECK(f.keyTrack == 0.0);
|
CHECK(f.keyTrack == 0.0);
|
||||||
CHECK(f.env.sustainLevel == 1.0);
|
CHECK(f.env.sustainLevel == 1.0);
|
||||||
CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 &&
|
CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 &&
|
||||||
@@ -628,15 +637,18 @@ static void testFilterTailRoundTripsLosslessly() {
|
|||||||
f.settings.driveNorm = 0.5f;
|
f.settings.driveNorm = 0.5f;
|
||||||
f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
||||||
f.modAmount = -0.625;
|
f.modAmount = -0.625;
|
||||||
f.velAmount = 0.5;
|
f.velAmount = 0.875;
|
||||||
f.keyTrack = 1.5;
|
f.keyTrack = 1.5;
|
||||||
f.env.attackSeconds = 0.031;
|
f.env.attackSeconds = 0.031;
|
||||||
f.env.holdSeconds = 0.062;
|
f.env.holdSeconds = 0.062;
|
||||||
f.env.decaySeconds = 0.125;
|
f.env.decaySeconds = 0.125;
|
||||||
f.env.sustainLevel = 0.25;
|
f.env.sustainLevel = 0.25;
|
||||||
f.env.releaseSeconds = 0.5;
|
f.env.releaseSeconds = 0.5;
|
||||||
|
// Bipolar, and reaching into the negative half the retired unipolar shape could not
|
||||||
|
// express: a codec that read this back through the old domain would clamp it to 0.
|
||||||
f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
{VelocityPoint{0.0, 0.1}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}});
|
{VelocityPoint{0.0, -0.75}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}},
|
||||||
|
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
// The amp's own curve stays different, so a codec that read one into the other fails here.
|
// The amp's own curve stays different, so a codec that read one into the other fails here.
|
||||||
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat();
|
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat();
|
||||||
|
|
||||||
@@ -658,11 +670,41 @@ static void testFilterTailRoundTripsLosslessly() {
|
|||||||
CHECK(g.env.sustainLevel == f.env.sustainLevel);
|
CHECK(g.env.sustainLevel == f.env.sustainLevel);
|
||||||
CHECK(g.env.releaseSeconds == f.env.releaseSeconds);
|
CHECK(g.env.releaseSeconds == f.env.releaseSeconds);
|
||||||
CHECK(g.velocityCurve.size() == 3);
|
CHECK(g.velocityCurve.size() == 3);
|
||||||
CHECK(g.velocityCurve.equals(f.velocityCurve));
|
CHECK(g.velocityCurve.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
CHECK(g.velocityCurve.eval(0.0) == -0.75); // the negative half survives the round trip
|
||||||
|
CHECK(g.velocityCurve.eval(100.0) == 0.4);
|
||||||
|
CHECK(g.velocityCurve.eval(127.0) == 0.9);
|
||||||
CHECK(out.params.velocityCurve.equals(
|
CHECK(out.params.velocityCurve.equals(
|
||||||
reasampler::instrument::engine::VelocityCurve::flat()));
|
reasampler::instrument::engine::VelocityCurve::flat()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The velocity->PITCH curve (payload v12) is a third, independent slot: it round-trips whole,
|
||||||
|
// and neither of the other two leaks into it.
|
||||||
|
static void testPitchVelocityCurveRoundTripsIndependently() {
|
||||||
|
ComponentState in;
|
||||||
|
in.selectionId = "pad";
|
||||||
|
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
|
{VelocityPoint{0.0, -1.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 0.5}},
|
||||||
|
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
in.params.play.filter.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
|
{VelocityPoint{0.0, 0.2}, VelocityPoint{127.0, -0.6}},
|
||||||
|
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::linear();
|
||||||
|
|
||||||
|
const ComponentState out =
|
||||||
|
deserializeComponentState(serializeComponentState(in), 48000.0);
|
||||||
|
const reasampler::instrument::engine::VelocityCurve& p = out.params.play.pitchVelocityCurve;
|
||||||
|
CHECK(p.size() == 3);
|
||||||
|
CHECK(p.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
CHECK(p.eval(0.0) == -1.0);
|
||||||
|
CHECK(p.eval(64.0) == 0.25);
|
||||||
|
CHECK(p.eval(127.0) == 0.5);
|
||||||
|
// The other two slots kept their own values — no cross-talk between the three curves.
|
||||||
|
CHECK(out.params.play.filter.velocityCurve.eval(127.0) == -0.6);
|
||||||
|
CHECK(out.params.velocityCurve.eval(127.0) == 1.0);
|
||||||
|
CHECK(out.params.velocityCurve.eval(0.0) == 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
// A non-finite modAmount/velAmount/keyTrack (a corrupt blob, or any writer that skipped the
|
// A non-finite modAmount/velAmount/keyTrack (a corrupt blob, or any writer that skipped the
|
||||||
// same guard the v8 master gain already applies) must lift to the neutral default rather than
|
// same guard the v8 master gain already applies) must lift to the neutral default rather than
|
||||||
// reach Voice::tickFilterCutoff, where both clamp compares are false against NaN and the
|
// reach Voice::tickFilterCutoff, where both clamp compares are false against NaN and the
|
||||||
@@ -747,6 +789,7 @@ static void testNegativeCrossfadeOnTheWireLiftsToZero() {
|
|||||||
// output with version N stamped in and the (N+1..current) tails cut. Building the older blobs
|
// output with version N stamped in and the (N+1..current) tails cut. Building the older blobs
|
||||||
// that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped
|
// that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped
|
||||||
// being a pure suffix, these would decode as garbage instead of as the documented lift.
|
// being a pure suffix, these would decode as garbage instead of as the documented lift.
|
||||||
|
static const std::size_t kVelocityTailBytes = 4 + 2 * 2 * 8; // v12: the 2-pt pitch curve
|
||||||
static const std::size_t kLoopTailBytes = 8; // v11: crossfade, one int64
|
static const std::size_t kLoopTailBytes = 8; // v11: crossfade, one int64
|
||||||
static const std::size_t kCurveTailBytes = 19 * 8; // v10: nineteen doubles
|
static const std::size_t kCurveTailBytes = 19 * 8; // v10: nineteen doubles
|
||||||
static const std::size_t kFilterTailBytes =
|
static const std::size_t kFilterTailBytes =
|
||||||
@@ -775,8 +818,21 @@ static std::vector<std::uint8_t> payloadDowngradedTo(const ComponentState& state
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A project saved before this change reopens sounding identical: its loop span still applies
|
// Every knot of `lifted` equals `stored`'s BIT for bit — the whole claim of a domain re-tag,
|
||||||
// and its seam is still hard, at EVERY prior single-record version.
|
// which is why this compares with == rather than a tolerance.
|
||||||
|
static bool knotsAreIdentical(const reasampler::instrument::engine::VelocityCurve& lifted,
|
||||||
|
const reasampler::instrument::engine::VelocityCurve& stored) {
|
||||||
|
if (lifted.size() != stored.size()) return false;
|
||||||
|
for (std::size_t i = 0; i < lifted.size(); ++i) {
|
||||||
|
if (lifted.points()[i].velocity != stored.points()[i].velocity) return false;
|
||||||
|
if (lifted.points()[i].value != stored.points()[i].value) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A project saved before this change reopens sounding identical: its loop span still applies,
|
||||||
|
// its seam is still hard, and velocity still modulates pitch not at all, at EVERY prior
|
||||||
|
// single-record version.
|
||||||
static void testPriorPayloadVersionsLiftToAHardSeam() {
|
static void testPriorPayloadVersionsLiftToAHardSeam() {
|
||||||
ComponentState in;
|
ComponentState in;
|
||||||
in.selectionId = "pad";
|
in.selectionId = "pad";
|
||||||
@@ -792,20 +848,39 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
|||||||
// it — proving the cuts land where the ladder says they do.
|
// it — proving the cuts land where the ladder says they do.
|
||||||
in.params.play.adsr.attackCurve = 4.0;
|
in.params.play.adsr.attackCurve = 4.0;
|
||||||
in.params.loopCrossfadeFrames = 777; // present in the bytes only at v11
|
in.params.loopCrossfadeFrames = 777; // present in the bytes only at v11
|
||||||
|
// Present in the bytes only at v12: an off-default pitch curve, so a lift that leaked one
|
||||||
|
// in from anywhere else fails rather than coincidentally matching the default.
|
||||||
|
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
|
{VelocityPoint{0.0, 0.5}, VelocityPoint{127.0, 1.0}},
|
||||||
|
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
// The filter's velocity pair, so EVERY version that carries a filter tail (v9..v11) walks
|
||||||
|
// the v12 domain re-tag, not just v11 (see testPreV12FilterVelocityLiftsAsAPureDomainReTag
|
||||||
|
// for the single-version proof). The knots stay inside [0,1] — what a pre-v12 unipolar
|
||||||
|
// curve could actually hold.
|
||||||
|
in.params.play.filter.enabled = true;
|
||||||
|
in.params.play.filter.modAmount = -0.6251953125;
|
||||||
|
in.params.play.filter.velAmount = -0.75;
|
||||||
|
const reasampler::instrument::engine::VelocityCurve filterShape =
|
||||||
|
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
|
{VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}},
|
||||||
|
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
in.params.play.filter.velocityCurve = filterShape;
|
||||||
|
|
||||||
struct Case {
|
struct Case {
|
||||||
std::uint32_t pv;
|
std::uint32_t pv;
|
||||||
std::size_t cut;
|
std::size_t cut;
|
||||||
bool keepsCurveTail;
|
bool keepsCurveTail;
|
||||||
|
bool keepsFilterTail;
|
||||||
};
|
};
|
||||||
const Case cases[] = {
|
const Case cases[] = {
|
||||||
{10, kLoopTailBytes, true},
|
{11, kVelocityTailBytes, true, true},
|
||||||
{9, kLoopTailBytes + kCurveTailBytes, false},
|
{10, kVelocityTailBytes + kLoopTailBytes, true, true},
|
||||||
{8, kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false},
|
{9, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes, false, true},
|
||||||
|
{8, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false, false},
|
||||||
};
|
};
|
||||||
for (const Case& c : cases) {
|
for (const Case& c : cases) {
|
||||||
const ComponentState out =
|
const std::vector<std::uint8_t> bytes = payloadDowngradedTo(in, c.pv, c.cut);
|
||||||
deserializeComponentState(payloadDowngradedTo(in, c.pv, c.cut), 48000.0);
|
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||||
// The span itself has been in the format since v2 and must survive untouched.
|
// The span itself has been in the format since v2 and must survive untouched.
|
||||||
CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
|
CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
|
||||||
CHECK(out.params.loopOverride && out.params.loopOverride->start == 2000);
|
CHECK(out.params.loopOverride && out.params.loopOverride->start == 2000);
|
||||||
@@ -813,14 +888,92 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
|||||||
CHECK(out.params.startPoint && *out.params.startPoint == 128);
|
CHECK(out.params.startPoint && *out.params.startPoint == 128);
|
||||||
CHECK(out.params.keyTrack == 0.5);
|
CHECK(out.params.keyTrack == 0.5);
|
||||||
CHECK(out.params.play.adsr.releaseSeconds == 0.25);
|
CHECK(out.params.play.adsr.releaseSeconds == 0.25);
|
||||||
// The documented pre-change behaviour: a hard seam.
|
// The documented pre-change behaviour: a hard seam and no velocity->pitch at all.
|
||||||
CHECK(out.params.loopCrossfadeFrames == 0);
|
CHECK(out.params.loopCrossfadeFrames == (c.pv >= 11 ? 777 : 0));
|
||||||
|
for (int v = 0; v <= 127; ++v) {
|
||||||
|
CHECK(out.params.play.pitchVelocityCurve.eval(v) == 0.0);
|
||||||
|
}
|
||||||
// And the cut landed on the tail boundary the ladder claims, not somewhere inside it.
|
// And the cut landed on the tail boundary the ladder claims, not somewhere inside it.
|
||||||
CHECK(out.params.play.adsr.attackCurve ==
|
CHECK(out.params.play.adsr.attackCurve ==
|
||||||
(c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral));
|
(c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral));
|
||||||
|
// The filter's velocity pair where the tail survives (v9..v11): the depth carries
|
||||||
|
// forward untouched and the curve is re-tagged, not rescaled — so the cutoff
|
||||||
|
// contribution, depth * curve(v), is exactly what the pre-change reader computed.
|
||||||
|
// Where the tail was cut away entirely (v8) it is the off/neutral default.
|
||||||
|
if (c.keepsFilterTail) {
|
||||||
|
CHECK(out.params.play.filter.enabled);
|
||||||
|
CHECK(out.params.play.filter.velAmount == in.params.play.filter.velAmount);
|
||||||
|
CHECK(knotsAreIdentical(out.params.play.filter.velocityCurve, filterShape));
|
||||||
|
for (int v = 0; v <= 127; ++v) {
|
||||||
|
CHECK(out.params.play.filter.velAmount *
|
||||||
|
out.params.play.filter.velocityCurve.eval(v) ==
|
||||||
|
in.params.play.filter.velAmount * filterShape.eval(v));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CHECK(!out.params.play.filter.enabled);
|
||||||
|
CHECK(out.params.play.filter.velAmount == 0.0);
|
||||||
|
for (int v = 0; v <= 127; ++v) {
|
||||||
|
CHECK(out.params.play.filter.velocityCurve.eval(v) == 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sharp edge of the bipolar change: v12 widened the filter curve's y domain, and the lift
|
||||||
|
// is a pure DOMAIN RE-TAG — no rescaling, no rounding. A pre-v12 curve's y values all lie in
|
||||||
|
// [0,1], which is inside [-1,+1], so every knot must come back bit-identical, the depth beside
|
||||||
|
// it untouched, and the cutoff contribution equal to the pre-change product at every velocity.
|
||||||
|
static void testPreV12FilterVelocityLiftsAsAPureDomainReTag() {
|
||||||
|
// A shape confined to [0,1] — what a pre-v12 unipolar curve could actually store. The
|
||||||
|
// reference reads the SAME knots through the old domain, so the comparison is against what
|
||||||
|
// the pre-change reader built, not against another read of the new one.
|
||||||
|
const std::vector<VelocityPoint> knots = {VelocityPoint{0.0, 0.0}, VelocityPoint{40.0, 0.125},
|
||||||
|
VelocityPoint{64.0, 0.75}, VelocityPoint{127.0, 1.0}};
|
||||||
|
const reasampler::instrument::engine::VelocityCurve asStored =
|
||||||
|
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
|
knots, reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||||
|
|
||||||
|
for (const double depth : {-0.75, 0.5, 0.0}) {
|
||||||
|
ComponentState in;
|
||||||
|
in.selectionId = "pad";
|
||||||
|
FilterSeconds& f = in.params.play.filter;
|
||||||
|
f.enabled = true;
|
||||||
|
f.modAmount = -0.6251953125;
|
||||||
|
f.velAmount = depth;
|
||||||
|
f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
|
knots, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
|
||||||
|
const std::vector<std::uint8_t> bytes =
|
||||||
|
payloadDowngradedTo(in, kParamsLoopVersion, kVelocityTailBytes);
|
||||||
|
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||||
|
const reasampler::instrument::engine::VelocityCurve& lifted =
|
||||||
|
out.params.play.filter.velocityCurve;
|
||||||
|
|
||||||
|
CHECK(lifted.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
CHECK(knotsAreIdentical(lifted, asStored)); // bit-identical, not within a tolerance
|
||||||
|
CHECK(out.params.play.filter.velAmount == depth); // the depth carries forward untouched
|
||||||
|
CHECK(out.params.play.filter.modAmount == f.modAmount);
|
||||||
|
// Sound-identical, stated as the product the voice actually computes.
|
||||||
|
for (int v = 0; v <= 127; ++v) {
|
||||||
|
CHECK(out.params.play.filter.velAmount * lifted.eval(v) == depth * asStored.eval(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The v12 blob of the same state reads back the same way — the re-tag is what the reader
|
||||||
|
// does at EVERY version, so the pre-v12 and current paths cannot diverge.
|
||||||
|
ComponentState now;
|
||||||
|
now.selectionId = "pad";
|
||||||
|
now.params.play.filter.enabled = true;
|
||||||
|
now.params.play.filter.velAmount = -0.75;
|
||||||
|
now.params.play.filter.velocityCurve =
|
||||||
|
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
|
knots, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
const ComponentState back =
|
||||||
|
deserializeComponentState(serializeComponentState(now), 48000.0);
|
||||||
|
CHECK(knotsAreIdentical(back.params.play.filter.velocityCurve, asStored));
|
||||||
|
CHECK(back.params.play.filter.velAmount == -0.75);
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -1306,11 +1459,12 @@ 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 + the 160-byte v10 staged-curve tail) = 452 bytes; entry two is 47 bytes
|
// v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the
|
||||||
// (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 472 keeps the first
|
// 36-byte v12 pitch curve) = 488 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4,
|
||||||
// 27 of entry two's 47 — mid loop.start (offset 23..31).
|
// loop 1+8+8, channels4, name 4+0). Cutting 508 keeps the first 27 of entry two's 47 —
|
||||||
CHECK(bytes.size() > 472);
|
// mid loop.start (offset 23..31).
|
||||||
bytes.resize(bytes.size() - 472);
|
CHECK(bytes.size() > 508);
|
||||||
|
bytes.resize(bytes.size() - 508);
|
||||||
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");
|
||||||
@@ -1393,6 +1547,7 @@ int main() {
|
|||||||
testLoopSpanAndCrossfadeRoundTrip();
|
testLoopSpanAndCrossfadeRoundTrip();
|
||||||
testNegativeCrossfadeOnTheWireLiftsToZero();
|
testNegativeCrossfadeOnTheWireLiftsToZero();
|
||||||
testPriorPayloadVersionsLiftToAHardSeam();
|
testPriorPayloadVersionsLiftToAHardSeam();
|
||||||
|
testPreV12FilterVelocityLiftsAsAPureDomainReTag();
|
||||||
testWriterEmitsCurrentPayloadVersion();
|
testWriterEmitsCurrentPayloadVersion();
|
||||||
testSingleZoneMigrationIsLossless();
|
testSingleZoneMigrationIsLossless();
|
||||||
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
|
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
|
||||||
@@ -1415,6 +1570,7 @@ int main() {
|
|||||||
testTruncationDegradesCleanly();
|
testTruncationDegradesCleanly();
|
||||||
testV8RecordLiftsToTheOffNeutralFilter();
|
testV8RecordLiftsToTheOffNeutralFilter();
|
||||||
testFilterTailRoundTripsLosslessly();
|
testFilterTailRoundTripsLosslessly();
|
||||||
|
testPitchVelocityCurveRoundTripsIndependently();
|
||||||
testNonFiniteFilterFieldsLiftToTheNeutralDefault();
|
testNonFiniteFilterFieldsLiftToTheNeutralDefault();
|
||||||
testNonFiniteAhdSecondsLiftToZero();
|
testNonFiniteAhdSecondsLiftToZero();
|
||||||
if (failures == 0) {
|
if (failures == 0) {
|
||||||
|
|||||||
@@ -6,10 +6,15 @@
|
|||||||
|
|
||||||
#include "../src/core/instrument/ui/curve_popup.h"
|
#include "../src/core/instrument/ui/curve_popup.h"
|
||||||
|
|
||||||
|
#include "../src/core/instrument/engine/velocity_curve.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
using namespace reasampler;
|
using namespace reasampler;
|
||||||
using namespace reasampler::instrument::ui;
|
using namespace reasampler::instrument::ui;
|
||||||
|
using reasampler::instrument::engine::CurveDomain;
|
||||||
|
using reasampler::instrument::engine::VelocityCurve;
|
||||||
|
|
||||||
static int g_fail = 0;
|
static int g_fail = 0;
|
||||||
#define CHECK(cond) do { if(!(cond)) { \
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
@@ -80,7 +85,47 @@ static void testOutsideSheetDismissTest() {
|
|||||||
CHECK(!popupOutsideSheet(pl, pl.sheet.right() - 1, pl.sheet.bottom() - 1));
|
CHECK(!popupOutsideSheet(pl, pl.sheet.right() - 1, pl.sheet.bottom() - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sheet hosts all three curves, and its box is domain-agnostic: the SAME curveBox drives a
|
||||||
|
// unipolar and a bipolar editor, and only the curve's own y map differs. Asserted here, against
|
||||||
|
// the popup's real geometry, because that is what makes one popup code path legitimate.
|
||||||
|
static void testTheSameCurveBoxHostsBothDomains() {
|
||||||
|
const CurvePopupLayout pl = computeCurvePopup(840, 620);
|
||||||
|
// The editor never maps against the raw curveBox — shell/instrument/editor_internal.h's
|
||||||
|
// curveBoxFromRect insets it first (kVelCurveInset == 14, mirrored here since this pure
|
||||||
|
// target cannot link the shell). Applying it, not the raw rect, is what exercises the
|
||||||
|
// actual box shape paint/hit-test/drag agree on.
|
||||||
|
constexpr int kInset = 14;
|
||||||
|
const VelocityCurve::Box box{pl.curveBox.x + kInset, pl.curveBox.y + kInset,
|
||||||
|
std::max(0, pl.curveBox.width - 2 * kInset),
|
||||||
|
std::max(0, pl.curveBox.height - 2 * kInset)};
|
||||||
|
CHECK(box.width > 1 && box.height > 1);
|
||||||
|
|
||||||
|
const VelocityCurve amp = VelocityCurve::flat();
|
||||||
|
const VelocityCurve mod = VelocityCurve::zero();
|
||||||
|
const int top = box.top;
|
||||||
|
const int bottom = box.top + box.height - 1;
|
||||||
|
|
||||||
|
// Both domains put their MAX on the top row and their MIN on the bottom row...
|
||||||
|
CHECK(amp.pixelFromPoint(box, {0.0, 1.0}).y == top);
|
||||||
|
CHECK(amp.pixelFromPoint(box, {0.0, 0.0}).y == bottom);
|
||||||
|
CHECK(mod.pixelFromPoint(box, {0.0, 1.0}).y == top);
|
||||||
|
CHECK(mod.pixelFromPoint(box, {0.0, -1.0}).y == bottom);
|
||||||
|
// ...so value 0 is the FLOOR for the amp curve and the MIDLINE for a modulation curve. The
|
||||||
|
// expected row replicates valueToY's own HALF-UP rounding on the inverted fraction (velocity_
|
||||||
|
// curve.cpp) rather than a plain integer bisection of top/bottom: the two agree only when
|
||||||
|
// box.height-1 is even, so a naive (top+bottom)/2 silently depends on this box's parity.
|
||||||
|
const int midY = top + static_cast<int>(0.5 * static_cast<double>(box.height - 1) + 0.5);
|
||||||
|
CHECK(mod.pixelFromPoint(box, {0.0, 0.0}).y == midY);
|
||||||
|
|
||||||
|
// And a click at the vertical centre adds a point at 0 in the bipolar editor, at 0.5 in
|
||||||
|
// the unipolar one — one hit-test path, two correct answers.
|
||||||
|
CHECK(mod.pointFromPixel(box, box.left, midY).value == 0.0);
|
||||||
|
CHECK(amp.pointFromPixel(box, box.left, midY).value > 0.49);
|
||||||
|
CHECK(amp.pointFromPixel(box, box.left, midY).value < 0.51);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
|
testTheSameCurveBoxHostsBothDomains();
|
||||||
testDefaultWindowMidClamp();
|
testDefaultWindowMidClamp();
|
||||||
testMinClamp();
|
testMinClamp();
|
||||||
testMaxClamp();
|
testMaxClamp();
|
||||||
|
|||||||
+98
-12
@@ -1,7 +1,9 @@
|
|||||||
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
|
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
|
||||||
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
|
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
|
||||||
// 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 VELOCITY group's exclusive ownership of the three curve
|
||||||
|
// cells and its placement immediately left of VOICE, 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, the commit-tier routing — which controls are live, and which drags take
|
// law's inverse pair, the commit-tier routing — which controls are live, and which drags take
|
||||||
// the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
|
// the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
|
||||||
@@ -48,13 +50,71 @@ static void testDeckReadsPitchThenFilterThenAmpLeftToRight() {
|
|||||||
CHECK(penv < filt);
|
CHECK(penv < filt);
|
||||||
CHECK(filt < fenv);
|
CHECK(filt < fenv);
|
||||||
CHECK(fenv < amp);
|
CHECK(fenv < amp);
|
||||||
// The two instance-wide groups stay at the end.
|
// VELOCITY then the two instance-wide groups at the end. Velocity sits IMMEDIATELY
|
||||||
CHECK(amp < indexOfGroup(g, kGroupVoice));
|
// left of VOICE — MASTER is reserved for post-voice-mixer concerns, so the curves
|
||||||
|
// must not drift into it.
|
||||||
|
const int vel = indexOfGroup(g, kGroupVelocity);
|
||||||
|
CHECK(amp < vel);
|
||||||
|
CHECK(vel + 1 == indexOfGroup(g, kGroupVoice));
|
||||||
CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
|
CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
|
// The three velocity curves live together in VELOCITY and nowhere else: no other group may
|
||||||
|
// carry a curve cell, or the "one home" the group exists for is not one.
|
||||||
|
static void testVelocityGroupOwnsTheThreeCurvesExclusively() {
|
||||||
|
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||||
|
const DeckGroupDesc& v =
|
||||||
|
g[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
|
||||||
|
const std::vector<int> expected = {cell(DeckParam::kAmpVelCurve),
|
||||||
|
cell(DeckParam::kPitchVelCurve),
|
||||||
|
cell(DeckParam::kFilterVelCurve)};
|
||||||
|
CHECK(v.cellIds == expected);
|
||||||
|
CHECK(v.captionToggle.id == -1 && v.rowToggle.id == -1 && v.captionRadio.id == -1);
|
||||||
|
for (const DeckGroupDesc& d : g) {
|
||||||
|
if (d.id == kGroupVelocity) continue;
|
||||||
|
for (int id : d.cellIds) CHECK(curveTargetFor(id) == CurveTarget::kNone);
|
||||||
|
CHECK(curveTargetFor(d.captionToggle.id) == CurveTarget::kNone);
|
||||||
|
CHECK(curveTargetFor(d.rowToggle.id) == CurveTarget::kNone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each curve cell names its OWN destination, and an ordinary knob names none — the predicate
|
||||||
|
// the shell uses to tell a popup opener from a dial.
|
||||||
|
static void testCurveTargetNamesEachCellsOwnDestination() {
|
||||||
|
CHECK(curveTargetFor(cell(DeckParam::kAmpVelCurve)) == CurveTarget::kAmp);
|
||||||
|
CHECK(curveTargetFor(cell(DeckParam::kPitchVelCurve)) == CurveTarget::kPitch);
|
||||||
|
CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter);
|
||||||
|
CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone);
|
||||||
|
CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone);
|
||||||
|
CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a blank reserved cell
|
||||||
|
CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cells hit-test inside their own group, from the centre of each cell — the deck grammar
|
||||||
|
// treats them as knob cells, so the popup routing rides an ordinary Knob hit.
|
||||||
|
static void testVelocityCellsHitTestWithinTheirGroup() {
|
||||||
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
|
const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth);
|
||||||
|
const DeckGroupLayout& v =
|
||||||
|
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupVelocity))];
|
||||||
|
CHECK(v.cells.size() == 3);
|
||||||
|
const CurveTarget want[] = {CurveTarget::kAmp, CurveTarget::kPitch, CurveTarget::kFilter};
|
||||||
|
for (std::size_t i = 0; i < v.cells.size(); ++i) {
|
||||||
|
const DeckCellLayout& c = v.cells[i];
|
||||||
|
const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2,
|
||||||
|
c.cell.y + c.cell.height / 2);
|
||||||
|
CHECK(hit.kind == DeckHitKind::Knob);
|
||||||
|
CHECK(hit.id == c.id);
|
||||||
|
CHECK(curveTargetFor(hit.id) == want[i]);
|
||||||
|
// Inside its own group box, and the cell the hit resolved is this one.
|
||||||
|
CHECK(c.cell.x >= v.box.x && c.cell.right() <= v.box.right());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFilterGroupCarriesItsToneControlsPlusModulation() {
|
||||||
const std::vector<DeckGroupDesc>& g = sampleDeckGroups(PlayMode::Gate);
|
const std::vector<DeckGroupDesc>& g = sampleDeckGroups(PlayMode::Gate);
|
||||||
const DeckGroupDesc& f = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
const DeckGroupDesc& f = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||||||
const std::vector<int> expected = {
|
const std::vector<int> expected = {
|
||||||
@@ -177,10 +237,13 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
|
|||||||
|
|
||||||
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
|
static void testWrappedDeckHeightAtTheEditorFloorWidth() {
|
||||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||||
// At the floor (== default) 840 the deck takes two rows: PITCH + PITCH ENV + FILTER fill
|
// At the floor (== default) 840 the deck takes three rows: PITCH + PITCH ENV + FILTER fill
|
||||||
// the first, the remaining four fit the second.
|
// the first (818 of the 824 available — six px of headroom, so one more FILTER cell would
|
||||||
CHECK(deckRowCount(g, kAvailAtMinWidth) == 2);
|
// wrap the group and reflow everything under it), FILTER ENV + AMP + VELOCITY the second,
|
||||||
CHECK(deckHeight(g, kAvailAtMinWidth) == 2 * kDeckGroupH + kDeckRowGap);
|
// VOICE + MASTER the third. Two rows cannot hold the eight groups in ANY order at this
|
||||||
|
// width: 1666 px of group plus 72 px of gaps against a 1648 px two-row capacity.
|
||||||
|
CHECK(deckRowCount(g, kAvailAtMinWidth) == 3);
|
||||||
|
CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap);
|
||||||
|
|
||||||
// Whole groups only, never split: every group's box lies inside the available width or is
|
// Whole groups only, never split: every group's box lies inside the available width or is
|
||||||
// the first of its row.
|
// the first of its row.
|
||||||
@@ -272,11 +335,12 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void testEveryDeckControlIsClassifiedLiveOrReloading() {
|
static void testEveryDeckControlIsClassifiedLiveOrReloading() {
|
||||||
// The live set: the six filter tone/modulation knobs, plus every stage time, stage level,
|
// The live set: the seven filter tone/modulation knobs, plus every stage time, stage level,
|
||||||
// hold fraction and curve exponent on all three envelopes — in BOTH mode shapes.
|
// 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::kFilterVel,
|
||||||
|
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::kTrigAttack, DeckParam::kTrigHold, DeckParam::kTrigDecay,
|
||||||
@@ -298,7 +362,8 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
|
|||||||
// is excluded.
|
// is excluded.
|
||||||
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::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
||||||
DeckParam::kKeyTrack, DeckParam::kTrigLength,
|
DeckParam::kKeyTrack, DeckParam::kTrigLength,
|
||||||
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
||||||
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||||
@@ -397,15 +462,36 @@ static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
|
|||||||
CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false));
|
CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A deck knob goes inert exactly with its group's own enable toggle — including the filter's
|
||||||
|
// VELOCITY cell, which sits in the VELOCITY group visually but is a filter parameter and must
|
||||||
|
// go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck
|
||||||
|
// checks before ever routing a curve-cell click to the popup).
|
||||||
|
static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
|
||||||
|
CHECK(deckKnobInert(DeckParam::kFilterVelCurve, /*pitchEnv=*/true, /*filter=*/false));
|
||||||
|
CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, true, true));
|
||||||
|
CHECK(deckKnobInert(DeckParam::kFilterCutoff, true, false));
|
||||||
|
CHECK(!deckKnobInert(DeckParam::kFilterCutoff, true, true));
|
||||||
|
CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, /*pitchEnv=*/false, true));
|
||||||
|
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, true, true));
|
||||||
|
// The amp's own velocity cell and every ordinary control are never inert here — inertness
|
||||||
|
// is a filter/pitch-env-group-only concept.
|
||||||
|
CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, false, false));
|
||||||
|
CHECK(!deckKnobInert(DeckParam::kAttack, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
|
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
|
||||||
testClickingTheActiveOverlayRadioClearsToNone();
|
testClickingTheActiveOverlayRadioClearsToNone();
|
||||||
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
||||||
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
||||||
|
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
|
||||||
testEveryDeckControlIsClassifiedLiveOrReloading();
|
testEveryDeckControlIsClassifiedLiveOrReloading();
|
||||||
testOnlyALiveControlsDragTakesTheLiveTier();
|
testOnlyALiveControlsDragTakesTheLiveTier();
|
||||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||||
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
|
testVelocityGroupOwnsTheThreeCurvesExclusively();
|
||||||
|
testCurveTargetNamesEachCellsOwnDestination();
|
||||||
|
testVelocityCellsHitTestWithinTheirGroup();
|
||||||
|
testFilterGroupCarriesItsToneControlsPlusModulation();
|
||||||
testOnlyTheThreeEnvelopeDecksCarryARadio();
|
testOnlyTheThreeEnvelopeDecksCarryARadio();
|
||||||
testGateAndTriggerFacesCarryTheirOwnShapes();
|
testGateAndTriggerFacesCarryTheirOwnShapes();
|
||||||
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
|
testOnlySlopedStageKnobsCarryAnInnerCurveDial();
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ static SampleData filteredSine() {
|
|||||||
s.play.filter.settings.cutoffNorm = 0.8f;
|
s.play.filter.settings.cutoffNorm = 0.8f;
|
||||||
s.play.filter.settings.resonanceNorm = 0.9f;
|
s.play.filter.settings.resonanceNorm = 0.9f;
|
||||||
s.play.filter.settings.morphNorm = 1.0f;
|
s.play.filter.settings.morphNorm = 1.0f;
|
||||||
|
// A drawn velocity curve, so the velocity-DEPTH knob has something to scale. It costs
|
||||||
|
// every other case nothing: the depth is 0 until a case moves it, so the product is 0.
|
||||||
|
s.play.filter.velocityCurve = VelocityCurve::fromPoints(
|
||||||
|
{{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,6 +605,10 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
|
|||||||
{"morph", [](LiveValues& v) { v.filterSettings.morphNorm = 0.0f; }},
|
{"morph", [](LiveValues& v) { v.filterSettings.morphNorm = 0.0f; }},
|
||||||
{"drive", [](LiveValues& v) { v.filterSettings.driveNorm = 1.0f; }},
|
{"drive", [](LiveValues& v) { v.filterSettings.driveNorm = 1.0f; }},
|
||||||
{"mod", [](LiveValues& v) { v.filterModAmount = 1.0; }},
|
{"mod", [](LiveValues& v) { v.filterModAmount = 1.0; }},
|
||||||
|
// The velocity DEPTH is live even though the velocity itself is latched: the depth is
|
||||||
|
// a control over the note's latched curve value, the same shape key-track has over the
|
||||||
|
// note's latched number (deck_groups.h).
|
||||||
|
{"velamount", [](LiveValues& v) { v.filterVelAmount = 1.0; }},
|
||||||
{"keytrack", [](LiveValues& v) { v.filterKeyTrack = 2.0; }},
|
{"keytrack", [](LiveValues& v) { v.filterKeyTrack = 2.0; }},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -644,7 +652,7 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
|
|||||||
// The window is a FRACTION OF THE GLIDE, not a frame count: kLiveRampSeconds is the
|
// The window is a FRACTION OF THE GLIDE, not a frame count: kLiveRampSeconds is the
|
||||||
// full travel time, so at 1/240 of it a working glide has barely started when the
|
// full travel time, so at 1/240 of it a working glide has barely started when the
|
||||||
// window closes. kGlideMargin then puts the bound at the geometric middle of the two
|
// window closes. kGlideMargin then puts the bound at the geometric middle of the two
|
||||||
// MEASURED populations — with the ramp in place these six controls ratio 0.0005..0.060;
|
// MEASURED populations — with the ramp in place these seven controls ratio 0.0005..0.060;
|
||||||
// with it defeated (every live move delivered as a snap, run) they ratio 0.52..1.10.
|
// with it defeated (every live move delivered as a snap, run) they ratio 0.52..1.10.
|
||||||
// The bound lands at 0.175: ~3x above the worst glide, ~3x below the tamest snap.
|
// The bound lands at 0.175: ~3x above the worst glide, ~3x below the tamest snap.
|
||||||
const std::size_t rampFrames =
|
const std::size_t rampFrames =
|
||||||
@@ -745,6 +753,7 @@ 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.filterVelAmount = 1.0;
|
||||||
hostile.pitchEnv.shape.attackFrames = 4800;
|
hostile.pitchEnv.shape.attackFrames = 4800;
|
||||||
hostile.pitchEnv.shape.decayFrames = 4800;
|
hostile.pitchEnv.shape.decayFrames = 4800;
|
||||||
hostile.pitchEnv.peakSemitones = 24.0;
|
hostile.pitchEnv.peakSemitones = 24.0;
|
||||||
@@ -786,14 +795,13 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
|||||||
|
|
||||||
static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
|
static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
|
||||||
// Filter AND pitch envelope enabled, so every field the block carries actually reaches the
|
// Filter AND pitch envelope enabled, so every field the block carries actually reaches the
|
||||||
// voice. velAmount is 0, so velocity enters the render exactly once — as the amp gain
|
// voice. The filter and pitch velocity curves are left at their off defaults, so velocity
|
||||||
// latched at note-on — which makes two runs at different velocities exactly proportional
|
// enters the render exactly once — as the amp gain latched at note-on — which makes two
|
||||||
// unless the publish moved that gain (a re-derived gain would have to preserve the ratio
|
// runs at different velocities exactly proportional unless the publish moved that gain (a
|
||||||
// 100:64 to slip through).
|
// re-derived gain would have to preserve the ratio 100:64 to slip through).
|
||||||
SampleData rig = periodicSine(200000, 64.0);
|
SampleData rig = periodicSine(200000, 64.0);
|
||||||
rig.velocityCurve = VelocityCurve::linear();
|
rig.velocityCurve = VelocityCurve::linear();
|
||||||
filterSweep(rig);
|
filterSweep(rig);
|
||||||
rig.play.filter.velAmount = 0.0;
|
|
||||||
rig.play.pitchEnv.enabled = true;
|
rig.play.pitchEnv.enabled = true;
|
||||||
rig.play.pitchEnv.shape.decayFrames = 24000;
|
rig.play.pitchEnv.shape.decayFrames = 24000;
|
||||||
rig.play.pitchEnv.peakSemitones = 3.0;
|
rig.play.pitchEnv.peakSemitones = 3.0;
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
// test framework.
|
// test framework.
|
||||||
//
|
//
|
||||||
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
|
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
|
||||||
// toolbar's fixed right-anchored run in order (preview, velocity cell, curve button,
|
// toolbar's fixed right-anchored run in order (preview, velocity cell, Mono|Stereo,
|
||||||
// Mono|Stereo, Browse) with the title taking the remainder; the velocity knob centred in its
|
// Browse) with the title taking the remainder; the velocity knob centred in its
|
||||||
// cell above its label; the piano strip owning its whole row at every width; no rect on the
|
// cell above its label; the piano strip owning its whole row at every width; no rect on the
|
||||||
// toolbar overlapping any other; and degenerate bands yielding no inverted rects.
|
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
|
||||||
|
// button's play-triangle glyph, which sits inside the button without changing its rect.
|
||||||
|
|
||||||
#include "../src/core/instrument/ui/sample_bands.h"
|
#include "../src/core/instrument/ui/sample_bands.h"
|
||||||
#include "../src/core/instrument/ui/sample_chrome.h"
|
#include "../src/core/instrument/ui/sample_chrome.h"
|
||||||
@@ -48,20 +49,19 @@ static void testRowsTileTheBandExactly() {
|
|||||||
static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
|
||||||
const Rect band = chromeBand();
|
const Rect band = chromeBand();
|
||||||
const ChromeRects r = chromeRects(band, kKnob);
|
const ChromeRects r = chromeRects(band, kKnob);
|
||||||
// Rightmost first: Browse, stereo, mono, curve button, velocity cell, preview, title.
|
// Rightmost first: Browse, stereo, mono, velocity cell, preview, title.
|
||||||
CHECK(r.navBrowse.right() == band.right() - kPad);
|
CHECK(r.navBrowse.right() == band.right() - kPad);
|
||||||
CHECK(r.navBrowse.width == kNavButtonWidth);
|
CHECK(r.navBrowse.width == kNavButtonWidth);
|
||||||
CHECK(r.chanStereo.right() <= r.navBrowse.x);
|
CHECK(r.chanStereo.right() <= r.navBrowse.x);
|
||||||
CHECK(r.chanMono.right() == r.chanStereo.x);
|
CHECK(r.chanMono.right() == r.chanStereo.x);
|
||||||
CHECK(r.curveBtn.right() <= r.chanMono.x);
|
CHECK(r.velCell.right() <= r.chanMono.x);
|
||||||
CHECK(r.velCell.right() <= r.curveBtn.x);
|
|
||||||
CHECK(r.preview.right() <= r.velCell.x);
|
CHECK(r.preview.right() <= r.velCell.x);
|
||||||
CHECK(r.title.right() <= r.preview.x);
|
CHECK(r.title.right() <= r.preview.x);
|
||||||
CHECK(r.title.x == band.x + kPad);
|
CHECK(r.title.x == band.x + kPad);
|
||||||
CHECK(r.title.width > 0);
|
CHECK(r.title.width > 0);
|
||||||
|
|
||||||
// Every toolbar rect sits inside the toolbar row.
|
// Every toolbar rect sits inside the toolbar row.
|
||||||
const Rect items[] = {r.title, r.preview, r.velCell, r.curveBtn, r.chanMono,
|
const Rect items[] = {r.title, r.preview, r.velCell, r.chanMono,
|
||||||
r.chanStereo, r.navBrowse};
|
r.chanStereo, r.navBrowse};
|
||||||
for (const Rect& it : items) {
|
for (const Rect& it : items) {
|
||||||
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
|
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
|
||||||
@@ -75,7 +75,7 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
|
|||||||
// stay inside its own row, clear of every control.
|
// stay inside its own row, clear of every control.
|
||||||
CHECK(!overlaps(r.toolbar, r.rootStrip));
|
CHECK(!overlaps(r.toolbar, r.rootStrip));
|
||||||
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
|
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
|
||||||
const Rect items[] = {r.preview, r.velCell, r.curveBtn, r.chanMono, r.chanStereo,
|
const Rect items[] = {r.preview, r.velCell, r.chanMono, r.chanStereo,
|
||||||
r.navBrowse};
|
r.navBrowse};
|
||||||
for (const Rect& it : items) {
|
for (const Rect& it : items) {
|
||||||
CHECK(!overlaps(it, r.rootStrip));
|
CHECK(!overlaps(it, r.rootStrip));
|
||||||
@@ -83,8 +83,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
|
|||||||
}
|
}
|
||||||
// The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell,
|
// The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell,
|
||||||
// so they are checked against the cell's neighbours, not the cell).
|
// so they are checked against the cell's neighbours, not the cell).
|
||||||
for (int i = 0; i < 6; ++i) {
|
for (int i = 0; i < 5; ++i) {
|
||||||
for (int j = i + 1; j < 6; ++j) CHECK(!overlaps(items[i], items[j]));
|
for (int j = i + 1; j < 5; ++j) CHECK(!overlaps(items[i], items[j]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,11 +127,38 @@ static void testDegenerateBandYieldsNoInvertedRects() {
|
|||||||
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
|
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
|
||||||
kKnob);
|
kKnob);
|
||||||
const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
|
const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
|
||||||
tiny.curveBtn, tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
|
tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
|
||||||
tiny.rootStrip};
|
tiny.rootStrip};
|
||||||
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
|
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void testPreviewGlyphSitsInsideTheButtonAndPointsRight() {
|
||||||
|
const ChromeRects r = chromeRects(chromeBand(), kKnob);
|
||||||
|
const PreviewGlyph g = previewGlyph(r.preview);
|
||||||
|
CHECK(!g.empty());
|
||||||
|
// Wholly inside the button — the glyph replaces the label, it does not resize the target.
|
||||||
|
CHECK(g.leftX >= r.preview.x && g.apexX <= r.preview.right());
|
||||||
|
CHECK(g.topY >= r.preview.y && g.bottomY <= r.preview.bottom());
|
||||||
|
// Right-pointing, and the apex on the button's own centre line so it reads as balanced.
|
||||||
|
CHECK(g.apexX > g.leftX);
|
||||||
|
CHECK(g.apexY == r.preview.y + r.preview.height / 2);
|
||||||
|
CHECK(g.apexY - g.topY == g.bottomY - g.apexY); // isosceles about the centre line
|
||||||
|
// The button's rect is what the hit-test uses, and the glyph must not have moved it: the
|
||||||
|
// preview button still sits at the run's fixed size, exactly where the text button did.
|
||||||
|
CHECK(r.preview.width == 64 && r.preview.height == 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPreviewGlyphDegradesRatherThanOverflowing() {
|
||||||
|
// An unusably small button yields an empty glyph (draw nothing) rather than a triangle
|
||||||
|
// spilling past the button edge.
|
||||||
|
CHECK(previewGlyph(Rect{}).empty());
|
||||||
|
CHECK(previewGlyph(Rect::ltrb(0, 0, 6, 6)).empty());
|
||||||
|
// A tall, wide button caps the glyph instead of scaling without bound.
|
||||||
|
const PreviewGlyph big = previewGlyph(Rect::ltrb(0, 0, 400, 200));
|
||||||
|
CHECK(!big.empty());
|
||||||
|
CHECK(big.bottomY - big.topY <= 14);
|
||||||
|
}
|
||||||
|
|
||||||
static void testToolbarOnlyBandStillPlacesTheNav() {
|
static void testToolbarOnlyBandStillPlacesTheNav() {
|
||||||
// A band clipped to just the toolbar row: the strip row is empty but Browse still
|
// A band clipped to just the toolbar row: the strip row is empty but Browse still
|
||||||
// resolves, so the empty state's call-to-action is never unreachable.
|
// resolves, so the empty state's call-to-action is never unreachable.
|
||||||
@@ -149,6 +176,8 @@ int main() {
|
|||||||
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
|
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
|
||||||
testVelocityKnobIsCentredInItsCellAboveTheLabel();
|
testVelocityKnobIsCentredInItsCellAboveTheLabel();
|
||||||
testDegenerateBandYieldsNoInvertedRects();
|
testDegenerateBandYieldsNoInvertedRects();
|
||||||
|
testPreviewGlyphSitsInsideTheButtonAndPointsRight();
|
||||||
|
testPreviewGlyphDegradesRatherThanOverflowing();
|
||||||
testToolbarOnlyBandStillPlacesTheNav();
|
testToolbarOnlyBandStillPlacesTheNav();
|
||||||
|
|
||||||
if (g_fail == 0) {
|
if (g_fail == 0) {
|
||||||
|
|||||||
@@ -638,7 +638,9 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
|
|||||||
st.filter.settings.driveNorm = 0.125f;
|
st.filter.settings.driveNorm = 0.125f;
|
||||||
st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
|
||||||
st.filter.modAmount = -0.5;
|
st.filter.modAmount = -0.5;
|
||||||
st.filter.velAmount = 0.25;
|
st.filter.velAmount = -0.375; // distinct from every neighbouring field, so a mis-wire shows
|
||||||
|
st.filter.velocityCurve = VelocityCurve::fromPoints(
|
||||||
|
{{0.0, 0.0}, {127.0, 0.25}}, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
st.filter.keyTrack = 1.25;
|
st.filter.keyTrack = 1.25;
|
||||||
st.filter.env.attackSeconds = 0.01;
|
st.filter.env.attackSeconds = 0.01;
|
||||||
st.filter.env.holdSeconds = 0.02;
|
st.filter.env.holdSeconds = 0.02;
|
||||||
@@ -654,7 +656,12 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
|
|||||||
CHECK(at48.filter.settings.driveNorm == 0.125f);
|
CHECK(at48.filter.settings.driveNorm == 0.125f);
|
||||||
CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
||||||
CHECK(at48.filter.modAmount == -0.5);
|
CHECK(at48.filter.modAmount == -0.5);
|
||||||
CHECK(at48.filter.velAmount == 0.25);
|
CHECK(at48.filter.velAmount == -0.375);
|
||||||
|
// The transfer curve is dimensionless, so it crosses unchanged — asserted against the
|
||||||
|
// straight line the two stored knots describe, not against the stored object.
|
||||||
|
CHECK(at48.filter.velocityCurve.eval(0.0) == 0.0);
|
||||||
|
CHECK(approx(at48.filter.velocityCurve.eval(127.0), 0.25));
|
||||||
|
CHECK(approx(at48.filter.velocityCurve.eval(63.5), 0.125));
|
||||||
CHECK(at48.filter.keyTrack == 1.25);
|
CHECK(at48.filter.keyTrack == 1.25);
|
||||||
CHECK(at48.filter.env.attackFrames == 480);
|
CHECK(at48.filter.env.attackFrames == 480);
|
||||||
CHECK(at48.filter.env.holdFrames == 960);
|
CHECK(at48.filter.env.holdFrames == 960);
|
||||||
@@ -673,8 +680,25 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
|
|||||||
CHECK(!bare.filter.enabled);
|
CHECK(!bare.filter.enabled);
|
||||||
CHECK(bare.filter.modAmount == 0.0);
|
CHECK(bare.filter.modAmount == 0.0);
|
||||||
CHECK(bare.filter.velAmount == 0.0);
|
CHECK(bare.filter.velAmount == 0.0);
|
||||||
|
for (int v = 0; v <= 127; ++v) CHECK(bare.filter.velocityCurve.eval(v) == 0.0);
|
||||||
CHECK(bare.filter.keyTrack == 0.0);
|
CHECK(bare.filter.keyTrack == 0.0);
|
||||||
CHECK(bare.filter.env.sustainLevel == 1.0);
|
CHECK(bare.filter.env.sustainLevel == 1.0);
|
||||||
|
// The velocity->pitch curve rides the same boundary and is off by the same default.
|
||||||
|
for (int v = 0; v <= 127; ++v) CHECK(bare.pitchVelocityCurve.eval(v) == 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The velocity->pitch curve is dimensionless like the filter's, so resolvePlay carries it
|
||||||
|
// across the seconds->frames boundary untouched at any rate.
|
||||||
|
static void testResolvePlayCarriesThePitchVelocityCurve() {
|
||||||
|
PlaySeconds st;
|
||||||
|
st.pitchVelocityCurve = VelocityCurve::fromPoints(
|
||||||
|
{{0.0, -1.0}, {127.0, 1.0}}, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
|
for (const int rate : {44100, 96000}) {
|
||||||
|
const PlayParams p = resolvePlay(st, rate);
|
||||||
|
CHECK(p.pitchVelocityCurve.eval(0.0) == -1.0);
|
||||||
|
CHECK(p.pitchVelocityCurve.eval(127.0) == 1.0);
|
||||||
|
CHECK(approx(p.pitchVelocityCurve.eval(63.5), 0.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testResolvePlayRoundsAndFloorsNegatives() {
|
static void testResolvePlayRoundsAndFloorsNegatives() {
|
||||||
@@ -937,6 +961,7 @@ int main() {
|
|||||||
testLegacyLiftDecision();
|
testLegacyLiftDecision();
|
||||||
testResolvePlayConvertsWallClockAtTheRate();
|
testResolvePlayConvertsWallClockAtTheRate();
|
||||||
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
|
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
|
||||||
|
testResolvePlayCarriesThePitchVelocityCurve();
|
||||||
testResolvePlayRoundsAndFloorsNegatives();
|
testResolvePlayRoundsAndFloorsNegatives();
|
||||||
testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate();
|
testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate();
|
||||||
testResolveCaptureUsesIntrinsicsWhenNoOverride();
|
testResolveCaptureUsesIntrinsicsWhenNoOverride();
|
||||||
|
|||||||
@@ -1766,7 +1766,8 @@ static SampleData twoLevelSample() {
|
|||||||
km.velocityCurve = VelocityCurve::fromPoints({{0.0, 0.0},
|
km.velocityCurve = VelocityCurve::fromPoints({{0.0, 0.0},
|
||||||
{static_cast<double>(kVelLow), 0.25},
|
{static_cast<double>(kVelLow), 0.25},
|
||||||
{static_cast<double>(kVelHigh), 0.75},
|
{static_cast<double>(kVelHigh), 0.75},
|
||||||
{127.0, 1.0}});
|
{127.0, 1.0}},
|
||||||
|
instrument::engine::CurveDomain::Unipolar);
|
||||||
return km;
|
return km;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1966,6 +1967,65 @@ static SampleData rampSample(std::size_t frames, int rootNote) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- velocity -> pitch ---------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A ramp sample reads its own position, so the value on frame N IS the accumulated read rate:
|
||||||
|
// the transpose is directly observable rather than inferred from a spectrum.
|
||||||
|
|
||||||
|
static void testVelocityPitchIsExactlyOffByDefault() {
|
||||||
|
// The modulation VALUE at every velocity, not a rendered approximation of it: the default
|
||||||
|
// bipolar curve must yield the identity ratio exactly, so nothing detunes by a hair.
|
||||||
|
const PlayParams def;
|
||||||
|
for (int v = 0; v <= 127; ++v) {
|
||||||
|
CHECK(velocityPitchRatio(def.pitchVelocityCurve, v) == 1.0);
|
||||||
|
}
|
||||||
|
// And at the render: two strikes of very different velocity read the ramp identically.
|
||||||
|
SampleData s = rampSample(4096, 60);
|
||||||
|
Voice soft;
|
||||||
|
Voice hard;
|
||||||
|
soft.start(60, 1, s);
|
||||||
|
hard.start(60, 127, s);
|
||||||
|
for (int i = 0; i < 64; ++i) CHECK(soft.renderFrame() == hard.renderFrame());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testDrawnVelocityPitchCurveTransposesBothWays() {
|
||||||
|
using instrument::engine::CurveDomain;
|
||||||
|
SampleData s = rampSample(4096, 60);
|
||||||
|
const double full = std::pow(2.0, kVelocityPitchRangeSemitones / 12.0);
|
||||||
|
|
||||||
|
// A curve pinned at +1 across the domain: every velocity transposes UP by the full scale.
|
||||||
|
s.play.pitchVelocityCurve =
|
||||||
|
VelocityCurve::fromPoints({{0.0, 1.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
|
||||||
|
Voice up;
|
||||||
|
up.start(60, 100, s);
|
||||||
|
CHECK(approx(static_cast<double>(up.renderFrame()), 0.0, 1e-9));
|
||||||
|
CHECK(approx(static_cast<double>(up.renderFrame()), full, 1e-4));
|
||||||
|
|
||||||
|
// Pinned at -1: DOWN by the same scale — the half of the domain the old unipolar curve
|
||||||
|
// could not express at all.
|
||||||
|
s.play.pitchVelocityCurve =
|
||||||
|
VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, -1.0}}, CurveDomain::Bipolar);
|
||||||
|
Voice down;
|
||||||
|
down.start(60, 100, s);
|
||||||
|
down.renderFrame();
|
||||||
|
CHECK(approx(static_cast<double>(down.renderFrame()), 1.0 / full, 1e-4));
|
||||||
|
|
||||||
|
// And it follows the curve: a rising ramp gives a soft hit less transpose than a hard one.
|
||||||
|
s.play.pitchVelocityCurve =
|
||||||
|
VelocityCurve::fromPoints({{0.0, 0.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
|
||||||
|
Voice q;
|
||||||
|
Voice f;
|
||||||
|
q.start(60, 20, s);
|
||||||
|
f.start(60, 120, s);
|
||||||
|
q.renderFrame();
|
||||||
|
f.renderFrame();
|
||||||
|
const double quiet = static_cast<double>(q.renderFrame());
|
||||||
|
const double loud = static_cast<double>(f.renderFrame());
|
||||||
|
CHECK(quiet > 1.0);
|
||||||
|
CHECK(loud > quiet);
|
||||||
|
CHECK(loud < full); // velocity 120 is short of the +1 endpoint
|
||||||
|
}
|
||||||
|
|
||||||
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
|
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
|
||||||
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
|
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
|
||||||
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
|
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
|
||||||
@@ -2801,6 +2861,8 @@ int main() {
|
|||||||
testRepitchObservedPeriod();
|
testRepitchObservedPeriod();
|
||||||
testKeyTrackVarispeedObservedPeriod();
|
testKeyTrackVarispeedObservedPeriod();
|
||||||
testKeyTrackPreserveShiftCollapsesAtZero();
|
testKeyTrackPreserveShiftCollapsesAtZero();
|
||||||
|
testVelocityPitchIsExactlyOffByDefault();
|
||||||
|
testDrawnVelocityPitchCurveTransposesBothWays();
|
||||||
testAdsrShape();
|
testAdsrShape();
|
||||||
testAdsrReleaseBeforeSustain();
|
testAdsrReleaseBeforeSustain();
|
||||||
testAdsrZeroAttackDecay();
|
testAdsrZeroAttackDecay();
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() {
|
|||||||
stored.play.filter.settings.driveNorm = 1.0f;
|
stored.play.filter.settings.driveNorm = 1.0f;
|
||||||
stored.play.filter.modAmount = 1.0;
|
stored.play.filter.modAmount = 1.0;
|
||||||
stored.play.filter.velAmount = -1.0;
|
stored.play.filter.velAmount = -1.0;
|
||||||
|
stored.play.filter.velocityCurve = VelocityCurve::fromPoints(
|
||||||
|
{{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
|
||||||
stored.play.filter.keyTrack = 2.0;
|
stored.play.filter.keyTrack = 2.0;
|
||||||
const std::vector<double> inert = render(stored, 60, 100, 1500);
|
const std::vector<double> inert = render(stored, 60, 100, 1500);
|
||||||
for (std::size_t i = 0; i < inert.size(); ++i) CHECK(inert[i] == bare[i]);
|
for (std::size_t i = 0; i < inert.size(); ++i) CHECK(inert[i] == bare[i]);
|
||||||
@@ -329,40 +331,91 @@ static void testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter() {
|
|||||||
static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
|
static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
|
||||||
// Playback key-tracking off, so both notes read the source at the SAME rate and the only
|
// Playback key-tracking off, so both notes read the source at the SAME rate and the only
|
||||||
// note-dependent difference left is the filter's own key-tracking.
|
// note-dependent difference left is the filter's own key-tracking.
|
||||||
const auto tone = [](double velAmount, double keyTrack) {
|
const auto tone = [](double velAmount, double velTop, double keyTrack) {
|
||||||
SampleData s = periodicSine(8000, 64);
|
SampleData s = periodicSine(8000, 64);
|
||||||
s.play.adsr = flatAdsr();
|
s.play.adsr = flatAdsr();
|
||||||
s.keyTrack = 0.0;
|
s.keyTrack = 0.0;
|
||||||
s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
|
s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
|
||||||
s.play.filter.velAmount = velAmount;
|
s.play.filter.velAmount = velAmount;
|
||||||
|
if (velTop != 0.0) {
|
||||||
|
s.play.filter.velocityCurve = VelocityCurve::fromPoints(
|
||||||
|
{{0.0, 0.0}, {127.0, velTop}}, instrument::engine::CurveDomain::Bipolar);
|
||||||
|
}
|
||||||
s.play.filter.keyTrack = keyTrack;
|
s.play.filter.keyTrack = keyTrack;
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Velocity: the default linear curve rises with velocity, so a positive depth opens the
|
// Velocity: a curve rising to +1 at full depth opens the filter for a hard hit. The amp's
|
||||||
// filter for a hard hit. The amp's own velocity curve is flat, so amplitude is unaffected.
|
// own velocity curve is flat, so amplitude is unaffected.
|
||||||
SampleData vel = tone(1.0, 0.0);
|
SampleData vel = tone(1.0, 1.0, 0.0);
|
||||||
const std::vector<double> soft = render(vel, 60, 1, 6000);
|
const std::vector<double> soft = render(vel, 60, 1, 6000);
|
||||||
const std::vector<double> hard = render(vel, 60, 127, 6000);
|
const std::vector<double> hard = render(vel, 60, 127, 6000);
|
||||||
CHECK(rms(hard, 2000, 6000) > 2.0 * rms(soft, 2000, 6000));
|
CHECK(rms(hard, 2000, 6000) > 2.0 * rms(soft, 2000, 6000));
|
||||||
|
|
||||||
// Key tracking: two octaves up opens it by two octaves of cutoff.
|
// Key tracking: two octaves up opens it by two octaves of cutoff.
|
||||||
SampleData key = tone(0.0, 1.0);
|
SampleData key = tone(0.0, 0.0, 1.0);
|
||||||
const std::vector<double> low = render(key, 60, 100, 6000);
|
const std::vector<double> low = render(key, 60, 100, 6000);
|
||||||
const std::vector<double> high = render(key, 84, 100, 6000);
|
const std::vector<double> high = render(key, 84, 100, 6000);
|
||||||
CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000));
|
CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000));
|
||||||
|
|
||||||
// Both neutral: neither velocity nor note may move the filter.
|
// Both neutral: neither velocity nor note may move the filter. `tone(_,0,0)` leaves the
|
||||||
SampleData neutral = tone(0.0, 0.0);
|
// DEFAULT velocity curve, so this is the off-by-default contract itself — and it holds at
|
||||||
const std::vector<double> a = render(neutral, 60, 1, 6000);
|
// EVERY depth setting, since the curve is what says "nothing", not the knob.
|
||||||
const std::vector<double> b = render(neutral, 60, 127, 6000);
|
for (const double depth : {-1.0, -0.5, 0.0, 0.5, 1.0}) {
|
||||||
const std::vector<double> c = render(neutral, 84, 100, 6000);
|
SampleData neutral = tone(depth, 0.0, 0.0);
|
||||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
const std::vector<double> a = render(neutral, 60, 1, 6000);
|
||||||
CHECK(a[i] == b[i]);
|
const std::vector<double> b = render(neutral, 60, 127, 6000);
|
||||||
CHECK(a[i] == c[i]);
|
const std::vector<double> c = render(neutral, 84, 100, 6000);
|
||||||
|
for (std::size_t i = 0; i < a.size(); ++i) {
|
||||||
|
CHECK(a[i] == b[i]);
|
||||||
|
CHECK(a[i] == c[i]);
|
||||||
|
}
|
||||||
|
// And the modulation VALUE itself is exactly zero at every velocity, not merely small
|
||||||
|
// enough that the render came out equal — the render check alone would still pass
|
||||||
|
// under a cutoff offset too small to survive the coefficient solve's float rounding.
|
||||||
|
const FilterParams def;
|
||||||
|
for (int v = 0; v <= 127; ++v) CHECK(depth * def.velocityCurve.eval(v) == 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The depth knob and the bipolar curve BOTH apply, as a product: flipping the depth's sign is
|
||||||
|
// the same modulation as flipping the curve's, and it flips the audible sense with it.
|
||||||
|
static void testFilterVelocityDepthAndCurveComposeMultiplicatively() {
|
||||||
|
using instrument::engine::CurveDomain;
|
||||||
|
using instrument::engine::VelocityPoint;
|
||||||
|
const std::vector<VelocityPoint> rising = {{0.0, 0.0}, {64.0, 0.4}, {127.0, 1.0}};
|
||||||
|
std::vector<VelocityPoint> mirrored = rising;
|
||||||
|
for (VelocityPoint& p : mirrored) p.value = -p.value;
|
||||||
|
|
||||||
|
const auto rig = [](double depth, const std::vector<VelocityPoint>& pts) {
|
||||||
|
SampleData s = periodicSine(8000, 64);
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
s.keyTrack = 0.0;
|
||||||
|
s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
|
||||||
|
s.play.filter.velAmount = depth;
|
||||||
|
s.play.filter.velocityCurve = VelocityCurve::fromPoints(pts, CurveDomain::Bipolar);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A negative depth over a rising curve is the SAME cutoff offset as a positive depth over
|
||||||
|
// the curve's mirror — frame for frame, not approximately.
|
||||||
|
SampleData byDepth = rig(-0.75, rising);
|
||||||
|
SampleData byCurve = rig(0.75, mirrored);
|
||||||
|
const std::vector<double> viaDepth = render(byDepth, 60, 120, 6000);
|
||||||
|
const std::vector<double> viaCurve = render(byCurve, 60, 120, 6000);
|
||||||
|
for (std::size_t i = 0; i < viaDepth.size(); ++i) CHECK(viaDepth[i] == viaCurve[i]);
|
||||||
|
|
||||||
|
// And the sense really inverts: over the same rising curve a positive depth opens the
|
||||||
|
// filter for a hard hit while the negative one closes it.
|
||||||
|
SampleData up = rig(0.75, rising);
|
||||||
|
const std::vector<double> upSoft = render(up, 60, 1, 6000);
|
||||||
|
const std::vector<double> upHard = render(up, 60, 120, 6000);
|
||||||
|
CHECK(rms(upHard, 2000, 6000) > 2.0 * rms(upSoft, 2000, 6000));
|
||||||
|
const std::vector<double> downSoft = render(byDepth, 60, 1, 6000);
|
||||||
|
const std::vector<double> downHard = render(byDepth, 60, 120, 6000);
|
||||||
|
CHECK(rms(downSoft, 2000, 6000) > 2.0 * rms(downHard, 2000, 6000));
|
||||||
|
}
|
||||||
|
|
||||||
static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() {
|
static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() {
|
||||||
SampleData s = periodicSine(8000, 64);
|
SampleData s = periodicSine(8000, 64);
|
||||||
s.play.adsr = flatAdsr();
|
s.play.adsr = flatAdsr();
|
||||||
@@ -432,6 +485,7 @@ int main() {
|
|||||||
testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice();
|
testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice();
|
||||||
testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter();
|
testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter();
|
||||||
testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
|
testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
|
||||||
|
testFilterVelocityDepthAndCurveComposeMultiplicatively();
|
||||||
testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
|
testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
|
||||||
testStereoRenderOfAMonoSampleMirrorsTheMonoResultExactly();
|
testStereoRenderOfAMonoSampleMirrorsTheMonoResultExactly();
|
||||||
testStereoFilterChannel1MatchesChannel0ForIdenticalLRInput();
|
testStereoFilterChannel1MatchesChannel0ForIdenticalLRInput();
|
||||||
|
|||||||
+140
-36
@@ -1,20 +1,24 @@
|
|||||||
// Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast
|
// Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast
|
||||||
// assert loop as the sibling pure tests. Assert the S-VIEW-9 velocity->amp transfer curve HARD:
|
// assert loop as the sibling pure tests. Assert the velocity transfer curve HARD:
|
||||||
//
|
//
|
||||||
// * eval — flat y=1 default (R10-F1 Option A: EVERY velocity -> 1.0), linear ramp, curved shape
|
// * eval — flat y=1 unipolar default (EVERY velocity -> 1.0), linear ramp, curved shape
|
||||||
// between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain.
|
// between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain.
|
||||||
|
// * the BIPOLAR domain — zero() is exactly 0 everywhere, the negative half evaluates and clamps
|
||||||
|
// at -1, knots inside [0,1] evaluate identically in either domain (what the codec's v12
|
||||||
|
// re-tag rests on), and the pixel maps put value 0 on the box's centre line, not its floor.
|
||||||
// * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its
|
// * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its
|
||||||
// neighbours (can't cross) and box-clamps amp; endpoints are X-pinned (velocity 0 / 127) with
|
// neighbours (can't cross) and box-clamps the value; endpoints are X-pinned (velocity 0 / 127)
|
||||||
// only amp mobile; deletePoint removes interior points but REFUSES the two endpoints.
|
// with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints.
|
||||||
// * hit-test + inverse map — pointAtPixel grabs a drawn node; resolvePointDrag maps pixel delta to
|
// * hit-test + inverse map — pointAtPixel grabs a drawn node; resolvePointDrag maps pixel delta to
|
||||||
// a clamped point (endpoint X-pinned; interior clamped to neighbours); degenerate box -> no motion.
|
// a clamped point (endpoint X-pinned; interior clamped to neighbours); degenerate box -> no motion.
|
||||||
// * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls
|
// * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls
|
||||||
// back to flat() for a sub-2-point list.
|
// back to each domain's OWN neutral for a sub-2-point list.
|
||||||
|
|
||||||
#include "../src/core/instrument/engine/velocity_curve.h"
|
#include "../src/core/instrument/engine/velocity_curve.h"
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
using namespace reasampler;
|
using namespace reasampler;
|
||||||
using namespace reasampler::instrument::engine;
|
using namespace reasampler::instrument::engine;
|
||||||
@@ -27,6 +31,17 @@ static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b
|
|||||||
|
|
||||||
using Box = VelocityCurve::Box;
|
using Box = VelocityCurve::Box;
|
||||||
|
|
||||||
|
// The pixel maps are members (the y domain lives on the curve), so a mapping test speaks
|
||||||
|
// through a curve of the domain under test rather than a free function.
|
||||||
|
static const VelocityCurve& uni() {
|
||||||
|
static const VelocityCurve c = VelocityCurve::flat();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
static const VelocityCurve& bip() {
|
||||||
|
static const VelocityCurve c = VelocityCurve::zero();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
// --- eval ---------------------------------------------------------------------
|
// --- eval ---------------------------------------------------------------------
|
||||||
|
|
||||||
static void testFlatIsUnityEverywhere() {
|
static void testFlatIsUnityEverywhere() {
|
||||||
@@ -151,11 +166,11 @@ static void testAddPointKeepsXOrderAndClamps() {
|
|||||||
const std::size_t i = c.addPoint(60.0, 0.3);
|
const std::size_t i = c.addPoint(60.0, 0.3);
|
||||||
CHECK(i == 1); // inserted between the two endpoints
|
CHECK(i == 1); // inserted between the two endpoints
|
||||||
CHECK(c.size() == 3);
|
CHECK(c.size() == 3);
|
||||||
CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].amp, 0.3));
|
CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].value, 0.3));
|
||||||
// Out-of-box add clamps into [0,127] x [0,1].
|
// Out-of-box add clamps into [0,127] x [0,1].
|
||||||
c.addPoint(500.0, 5.0);
|
c.addPoint(500.0, 5.0);
|
||||||
const VelocityPoint& last = c.points().back();
|
const VelocityPoint& last = c.points().back();
|
||||||
CHECK(near(last.velocity, 127.0) && near(last.amp, 1.0));
|
CHECK(near(last.velocity, 127.0) && near(last.value, 1.0));
|
||||||
// Points remain X-ordered.
|
// Points remain X-ordered.
|
||||||
for (std::size_t k = 1; k < c.size(); ++k)
|
for (std::size_t k = 1; k < c.size(); ++k)
|
||||||
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
|
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
|
||||||
@@ -171,7 +186,7 @@ static void testMoveInteriorClampsToNeighbours() {
|
|||||||
// Try to drag idx 1 PAST idx 2 (velocity 200): clamps to idx 2's velocity (80), not beyond.
|
// Try to drag idx 1 PAST idx 2 (velocity 200): clamps to idx 2's velocity (80), not beyond.
|
||||||
const VelocityPoint r = c.movePoint(1, 200.0, 0.5);
|
const VelocityPoint r = c.movePoint(1, 200.0, 0.5);
|
||||||
CHECK(near(r.velocity, 80.0));
|
CHECK(near(r.velocity, 80.0));
|
||||||
CHECK(near(r.amp, 0.5)); // amp is free (box-clamped only)
|
CHECK(near(r.value, 0.5)); // amp is free (box-clamped only)
|
||||||
// Try to drag idx 1 BELOW idx 0 (velocity -5): clamps to idx 0's velocity (0).
|
// Try to drag idx 1 BELOW idx 0 (velocity -5): clamps to idx 0's velocity (0).
|
||||||
const VelocityPoint r2 = c.movePoint(1, -5.0, 0.5);
|
const VelocityPoint r2 = c.movePoint(1, -5.0, 0.5);
|
||||||
CHECK(near(r2.velocity, 0.0));
|
CHECK(near(r2.velocity, 0.0));
|
||||||
@@ -182,18 +197,18 @@ static void testMoveEndpointsArePinnedInX() {
|
|||||||
// Move the first endpoint: velocity argument ignored (pinned at 0), amp moves.
|
// Move the first endpoint: velocity argument ignored (pinned at 0), amp moves.
|
||||||
const VelocityPoint f = c.movePoint(0, 50.0, 0.25);
|
const VelocityPoint f = c.movePoint(0, 50.0, 0.25);
|
||||||
CHECK(near(f.velocity, 0.0));
|
CHECK(near(f.velocity, 0.0));
|
||||||
CHECK(near(f.amp, 0.25));
|
CHECK(near(f.value, 0.25));
|
||||||
// Move the last endpoint: pinned at 127, amp moves, and amp box-clamps.
|
// Move the last endpoint: pinned at 127, amp moves, and amp box-clamps.
|
||||||
const VelocityPoint l = c.movePoint(1, 10.0, 5.0);
|
const VelocityPoint l = c.movePoint(1, 10.0, 5.0);
|
||||||
CHECK(near(l.velocity, 127.0));
|
CHECK(near(l.velocity, 127.0));
|
||||||
CHECK(near(l.amp, 1.0));
|
CHECK(near(l.value, 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testMoveOutOfRangeIndexIsNoOp() {
|
static void testMoveOutOfRangeIndexIsNoOp() {
|
||||||
VelocityCurve c = VelocityCurve::linear();
|
VelocityCurve c = VelocityCurve::linear();
|
||||||
c.movePoint(99, 50.0, 0.5);
|
c.movePoint(99, 50.0, 0.5);
|
||||||
CHECK(c.size() == 2);
|
CHECK(c.size() == 2);
|
||||||
CHECK(near(c.points()[0].amp, 0.0) && near(c.points()[1].amp, 1.0)); // unchanged
|
CHECK(near(c.points()[0].value, 0.0) && near(c.points()[1].value, 1.0)); // unchanged
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- editing: deletePoint -----------------------------------------------------
|
// --- editing: deletePoint -----------------------------------------------------
|
||||||
@@ -237,11 +252,11 @@ static void testResolveDragMovesAndClamps() {
|
|||||||
// Drag idx 1 right 10px, up 10px: velocity +10 (->70), amp +0.10 (up = higher amp -> 0.60).
|
// Drag idx 1 right 10px, up 10px: velocity +10 (->70), amp +0.10 (up = higher amp -> 0.60).
|
||||||
const VelocityCurve moved = VelocityCurve::resolvePointDrag(grab, 1, b, 10, -10);
|
const VelocityCurve moved = VelocityCurve::resolvePointDrag(grab, 1, b, 10, -10);
|
||||||
CHECK(near(moved.points()[1].velocity, 70.0, 1e-6));
|
CHECK(near(moved.points()[1].velocity, 70.0, 1e-6));
|
||||||
CHECK(near(moved.points()[1].amp, 0.60, 1e-6));
|
CHECK(near(moved.points()[1].value, 0.60, 1e-6));
|
||||||
// Dragging the first endpoint horizontally does not move it in X (pinned), only amp.
|
// Dragging the first endpoint horizontally does not move it in X (pinned), only amp.
|
||||||
const VelocityCurve movedEnd = VelocityCurve::resolvePointDrag(grab, 0, b, 40, -20);
|
const VelocityCurve movedEnd = VelocityCurve::resolvePointDrag(grab, 0, b, 40, -20);
|
||||||
CHECK(near(movedEnd.points()[0].velocity, 0.0));
|
CHECK(near(movedEnd.points()[0].velocity, 0.0));
|
||||||
CHECK(near(movedEnd.points()[0].amp, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0
|
CHECK(near(movedEnd.points()[0].value, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testResolveDragDegenerateBoxNoMotion() {
|
static void testResolveDragDegenerateBoxNoMotion() {
|
||||||
@@ -255,7 +270,7 @@ static void testResolveDragDegenerateBoxNoMotion() {
|
|||||||
static void testFromPointsSortsClampsAndForcesEndpoints() {
|
static void testFromPointsSortsClampsAndForcesEndpoints() {
|
||||||
// Unsorted, out-of-box, missing endpoints -> repaired to a valid curve.
|
// Unsorted, out-of-box, missing endpoints -> repaired to a valid curve.
|
||||||
std::vector<VelocityPoint> raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}};
|
std::vector<VelocityPoint> raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}};
|
||||||
const VelocityCurve c = VelocityCurve::fromPoints(raw);
|
const VelocityCurve c = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
|
||||||
// X-ordered.
|
// X-ordered.
|
||||||
for (std::size_t k = 1; k < c.size(); ++k)
|
for (std::size_t k = 1; k < c.size(); ++k)
|
||||||
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
|
CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity);
|
||||||
@@ -264,15 +279,98 @@ static void testFromPointsSortsClampsAndForcesEndpoints() {
|
|||||||
CHECK(near(c.points().back().velocity, 127.0));
|
CHECK(near(c.points().back().velocity, 127.0));
|
||||||
// Interior amps box-clamped (the -1 became 0, the 2 became 1).
|
// Interior amps box-clamped (the -1 became 0, the 2 became 1).
|
||||||
for (const VelocityPoint& p : c.points()) {
|
for (const VelocityPoint& p : c.points()) {
|
||||||
CHECK(p.amp >= 0.0 - 1e-12 && p.amp <= 1.0 + 1e-12);
|
CHECK(p.value >= 0.0 - 1e-12 && p.value <= 1.0 + 1e-12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testFromPointsSubTwoFallsBackToFlat() {
|
static void testFromPointsSubTwoFallsBackToFlat() {
|
||||||
const VelocityCurve c0 = VelocityCurve::fromPoints({});
|
const VelocityCurve c0 = VelocityCurve::fromPoints({}, CurveDomain::Unipolar);
|
||||||
CHECK(c0.equals(VelocityCurve::flat()));
|
CHECK(c0.equals(VelocityCurve::flat()));
|
||||||
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}});
|
const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Unipolar);
|
||||||
CHECK(c1.equals(VelocityCurve::flat()));
|
CHECK(c1.equals(VelocityCurve::flat()));
|
||||||
|
// The bipolar fallback is the domain's OWN neutral, not the unipolar one: degrading a
|
||||||
|
// corrupt pitch/filter curve to flat-at-unity would transpose or open the filter fully.
|
||||||
|
const VelocityCurve b0 = VelocityCurve::fromPoints({}, CurveDomain::Bipolar);
|
||||||
|
CHECK(b0.equals(VelocityCurve::zero()));
|
||||||
|
const VelocityCurve b1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Bipolar);
|
||||||
|
CHECK(b1.equals(VelocityCurve::zero()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the bipolar domain -------------------------------------------------------
|
||||||
|
|
||||||
|
static void testZeroIsExactlyZeroAtEveryVelocity() {
|
||||||
|
// The off-by-default contract: not "approximately zero" — EXACTLY zero, so a pitch or
|
||||||
|
// cutoff offset derived from it cannot nudge anything.
|
||||||
|
const VelocityCurve c = VelocityCurve::zero();
|
||||||
|
CHECK(c.domain() == CurveDomain::Bipolar);
|
||||||
|
for (int v = -20; v <= 200; ++v) CHECK(c.eval(v) == 0.0);
|
||||||
|
CHECK(c.size() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testBipolarEvalSpansTheNegativeHalf() {
|
||||||
|
// A ramp from -1 at velocity 0 to +1 at 127: collinear knots, so the spline is the exact
|
||||||
|
// straight line through zero — the whole point of the widened domain.
|
||||||
|
const VelocityCurve c =
|
||||||
|
VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, 1.0}}, CurveDomain::Bipolar);
|
||||||
|
CHECK(near(c.eval(0), -1.0));
|
||||||
|
CHECK(near(c.eval(127), 1.0));
|
||||||
|
CHECK(near(c.eval(63.5), 0.0, 1e-12));
|
||||||
|
for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 2.0 * v / 127.0 - 1.0, 1e-12));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testUnipolarClampsAtZeroWhereBipolarDoesNot() {
|
||||||
|
// The same negative knot, read in the two domains: unipolar floors it at 0 (an amp gain
|
||||||
|
// cannot be negative), bipolar keeps it.
|
||||||
|
const std::vector<VelocityPoint> raw = {{0.0, -0.5}, {127.0, 0.5}};
|
||||||
|
const VelocityCurve u = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar);
|
||||||
|
const VelocityCurve b = VelocityCurve::fromPoints(raw, CurveDomain::Bipolar);
|
||||||
|
CHECK(near(u.eval(0), 0.0));
|
||||||
|
CHECK(near(b.eval(0), -0.5));
|
||||||
|
// Out-of-domain magnitudes clamp to each domain's own floor.
|
||||||
|
const VelocityCurve b2 =
|
||||||
|
VelocityCurve::fromPoints({{0.0, -9.0}, {127.0, 9.0}}, CurveDomain::Bipolar);
|
||||||
|
CHECK(near(b2.eval(0), -1.0));
|
||||||
|
CHECK(near(b2.eval(127), 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTheSameKnotsEvaluateIdenticallyInEitherDomain() {
|
||||||
|
// What the codec's v12 domain re-tag rests on: a curve whose y values all lie in [0,1] is
|
||||||
|
// read the same way in either domain — the domain governs the CLAMP, not the evaluation.
|
||||||
|
// Asserted against a CURVED (non-collinear) knot set, where the tangents are doing work,
|
||||||
|
// and with ==: the re-tag is bit-identical, not merely close.
|
||||||
|
const std::vector<VelocityPoint> knots = {
|
||||||
|
{0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}};
|
||||||
|
const VelocityCurve u = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
|
||||||
|
const VelocityCurve b = VelocityCurve::fromPoints(knots, CurveDomain::Bipolar);
|
||||||
|
for (int v = 0; v <= 127; ++v) CHECK(b.eval(v) == u.eval(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testBipolarPixelMapPutsZeroOnTheCentreLine() {
|
||||||
|
// The same box, read in the two domains: value 0 sits at the vertical centre for a bipolar
|
||||||
|
// curve and at the bottom row for a unipolar one — the one mapping difference the shared
|
||||||
|
// popup code path has to get right.
|
||||||
|
const Box box{10, 20, 100, 101}; // 100 value rows: centre is 50 rows down
|
||||||
|
CHECK(bip().pixelFromPoint(box, {0.0, 0.0}).y == 70);
|
||||||
|
CHECK(uni().pixelFromPoint(box, {0.0, 0.0}).y == 120);
|
||||||
|
// Each domain's floor lands on the bottom row, its ceiling on the top.
|
||||||
|
CHECK(bip().pixelFromPoint(box, {0.0, -1.0}).y == 120);
|
||||||
|
CHECK(bip().pixelFromPoint(box, {0.0, 1.0}).y == 20);
|
||||||
|
// And the inverse agrees: the centre row reads back as 0 in bipolar, mid-scale in unipolar.
|
||||||
|
CHECK(near(bip().pointFromPixel(box, 10, 70).value, 0.0, 1e-12));
|
||||||
|
CHECK(near(uni().pointFromPixel(box, 10, 70).value, 0.5, 1e-12));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testBipolarDragCoversTwiceTheValueRange() {
|
||||||
|
// A drag of N pixels moves twice as much value in bipolar as in unipolar over the same box
|
||||||
|
// — the domain spans 2.0, not 1.0. Both still land inside their own domain.
|
||||||
|
const Box box{0, 0, 127, 101}; // 100 value rows
|
||||||
|
VelocityCurve u = VelocityCurve::flat();
|
||||||
|
u.movePoint(0, 0.0, 0.5);
|
||||||
|
VelocityCurve b = VelocityCurve::zero();
|
||||||
|
const VelocityCurve uMoved = VelocityCurve::resolvePointDrag(u, 0, box, 0, -10);
|
||||||
|
const VelocityCurve bMoved = VelocityCurve::resolvePointDrag(b, 0, box, 0, -10);
|
||||||
|
CHECK(near(uMoved.points()[0].value, 0.60, 1e-6));
|
||||||
|
CHECK(near(bMoved.points()[0].value, 0.20, 1e-6));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- S-VIEW-10 pixel maps (the editor draw/add seam) -----------------------------
|
// --- S-VIEW-10 pixel maps (the editor draw/add seam) -----------------------------
|
||||||
@@ -282,29 +380,29 @@ static void testPixelFromPointMapsCornersAndMidpoint() {
|
|||||||
// (h - 1) rows with amp 1 at the top — assert the drawn corners land where the module's own
|
// (h - 1) rows with amp 1 at the top — assert the drawn corners land where the module's own
|
||||||
// hit-test mapping puts them.
|
// hit-test mapping puts them.
|
||||||
const Box box{10, 20, 100, 51};
|
const Box box{10, 20, 100, 51};
|
||||||
const auto tl = VelocityCurve::pixelFromPoint(box, {0.0, 1.0});
|
const auto tl = uni().pixelFromPoint(box, {0.0, 1.0});
|
||||||
CHECK(tl.x == 10 && tl.y == 20);
|
CHECK(tl.x == 10 && tl.y == 20);
|
||||||
const auto br = VelocityCurve::pixelFromPoint(box, {127.0, 0.0});
|
const auto br = uni().pixelFromPoint(box, {127.0, 0.0});
|
||||||
CHECK(br.x == 110 && br.y == 70);
|
CHECK(br.x == 110 && br.y == 70);
|
||||||
const auto mid = VelocityCurve::pixelFromPoint(box, {63.5, 0.5});
|
const auto mid = uni().pixelFromPoint(box, {63.5, 0.5});
|
||||||
CHECK(mid.x == 60 && mid.y == 45);
|
CHECK(mid.x == 60 && mid.y == 45);
|
||||||
// Out-of-box values are clamped by the mapping (velocity 200 draws at the right edge).
|
// Out-of-box values are clamped by the mapping (velocity 200 draws at the right edge).
|
||||||
const auto clamped = VelocityCurve::pixelFromPoint(box, {200.0, 2.0});
|
const auto clamped = uni().pixelFromPoint(box, {200.0, 2.0});
|
||||||
CHECK(clamped.x == 110 && clamped.y == 20);
|
CHECK(clamped.x == 110 && clamped.y == 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPointFromPixelInvertsAndClamps() {
|
static void testPointFromPixelInvertsAndClamps() {
|
||||||
const Box box{10, 20, 100, 51};
|
const Box box{10, 20, 100, 51};
|
||||||
// Exact corners invert exactly.
|
// Exact corners invert exactly.
|
||||||
const VelocityPoint tl = VelocityCurve::pointFromPixel(box, 10, 20);
|
const VelocityPoint tl = uni().pointFromPixel(box, 10, 20);
|
||||||
CHECK(near(tl.velocity, 0.0) && near(tl.amp, 1.0));
|
CHECK(near(tl.velocity, 0.0) && near(tl.value, 1.0));
|
||||||
const VelocityPoint br = VelocityCurve::pointFromPixel(box, 110, 70);
|
const VelocityPoint br = uni().pointFromPixel(box, 110, 70);
|
||||||
CHECK(near(br.velocity, 127.0) && near(br.amp, 0.0));
|
CHECK(near(br.velocity, 127.0) && near(br.value, 0.0));
|
||||||
// A pixel OUTSIDE the box clamps into the domain (never an invariant-violating point).
|
// A pixel OUTSIDE the box clamps into the domain (never an invariant-violating point).
|
||||||
const VelocityPoint out = VelocityCurve::pointFromPixel(box, -50, 500);
|
const VelocityPoint out = uni().pointFromPixel(box, -50, 500);
|
||||||
CHECK(near(out.velocity, 0.0) && near(out.amp, 0.0));
|
CHECK(near(out.velocity, 0.0) && near(out.value, 0.0));
|
||||||
const VelocityPoint out2 = VelocityCurve::pointFromPixel(box, 500, -50);
|
const VelocityPoint out2 = uni().pointFromPixel(box, 500, -50);
|
||||||
CHECK(near(out2.velocity, 127.0) && near(out2.amp, 1.0));
|
CHECK(near(out2.velocity, 127.0) && near(out2.value, 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPixelMapsRoundTripWithinOnePixelQuantum() {
|
static void testPixelMapsRoundTripWithinOnePixelQuantum() {
|
||||||
@@ -315,10 +413,10 @@ static void testPixelMapsRoundTripWithinOnePixelQuantum() {
|
|||||||
const double ampQuantum = 1.0 / 119.0;
|
const double ampQuantum = 1.0 / 119.0;
|
||||||
const VelocityPoint pts[] = {{0.0, 1.0}, {127.0, 0.0}, {40.0, 0.25}, {90.5, 0.66}, {63.5, 0.5}};
|
const VelocityPoint pts[] = {{0.0, 1.0}, {127.0, 0.0}, {40.0, 0.25}, {90.5, 0.66}, {63.5, 0.5}};
|
||||||
for (const VelocityPoint& p : pts) {
|
for (const VelocityPoint& p : pts) {
|
||||||
const auto px = VelocityCurve::pixelFromPoint(box, p);
|
const auto px = uni().pixelFromPoint(box, p);
|
||||||
const VelocityPoint back = VelocityCurve::pointFromPixel(box, px.x, px.y);
|
const VelocityPoint back = uni().pointFromPixel(box, px.x, px.y);
|
||||||
CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum);
|
CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum);
|
||||||
CHECK(std::fabs(back.amp - p.amp) <= ampQuantum);
|
CHECK(std::fabs(back.value - p.value) <= ampQuantum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,15 +426,15 @@ static void testPixelFromPointAgreesWithPointAtPixel() {
|
|||||||
VelocityCurve c = VelocityCurve::linear();
|
VelocityCurve c = VelocityCurve::linear();
|
||||||
const std::size_t idx = c.addPoint(70.0, 0.3);
|
const std::size_t idx = c.addPoint(70.0, 0.3);
|
||||||
const Box box{0, 0, 200, 100};
|
const Box box{0, 0, 200, 100};
|
||||||
const auto px = VelocityCurve::pixelFromPoint(box, c.points()[idx]);
|
const auto px = uni().pixelFromPoint(box, c.points()[idx]);
|
||||||
CHECK(c.pointAtPixel(box, px.x, px.y) == static_cast<int>(idx));
|
CHECK(c.pointAtPixel(box, px.x, px.y) == static_cast<int>(idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testPointFromPixelDegenerateBox() {
|
static void testPointFromPixelDegenerateBox() {
|
||||||
// Zero width -> velocity 0; height <= 1 -> amp 1 (mirrors the forward map's degenerate pins).
|
// Zero width -> velocity 0; height <= 1 -> amp 1 (mirrors the forward map's degenerate pins).
|
||||||
const Box flat{5, 5, 0, 0};
|
const Box flat{5, 5, 0, 0};
|
||||||
const VelocityPoint p = VelocityCurve::pointFromPixel(flat, 50, 50);
|
const VelocityPoint p = uni().pointFromPixel(flat, 50, 50);
|
||||||
CHECK(near(p.velocity, 0.0) && near(p.amp, 1.0));
|
CHECK(near(p.velocity, 0.0) && near(p.value, 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testFromPointsRoundTripsAValidCurve() {
|
static void testFromPointsRoundTripsAValidCurve() {
|
||||||
@@ -344,7 +442,7 @@ static void testFromPointsRoundTripsAValidCurve() {
|
|||||||
orig.addPoint(40.0, 0.2);
|
orig.addPoint(40.0, 0.2);
|
||||||
orig.addPoint(90.0, 0.7);
|
orig.addPoint(90.0, 0.7);
|
||||||
// fromPoints over its OWN points reproduces it exactly (already valid, sort is stable no-op).
|
// fromPoints over its OWN points reproduces it exactly (already valid, sort is stable no-op).
|
||||||
const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points());
|
const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points(), CurveDomain::Unipolar);
|
||||||
CHECK(rebuilt.equals(orig));
|
CHECK(rebuilt.equals(orig));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +465,12 @@ int main() {
|
|||||||
testResolveDragDegenerateBoxNoMotion();
|
testResolveDragDegenerateBoxNoMotion();
|
||||||
testFromPointsSortsClampsAndForcesEndpoints();
|
testFromPointsSortsClampsAndForcesEndpoints();
|
||||||
testFromPointsSubTwoFallsBackToFlat();
|
testFromPointsSubTwoFallsBackToFlat();
|
||||||
|
testZeroIsExactlyZeroAtEveryVelocity();
|
||||||
|
testBipolarEvalSpansTheNegativeHalf();
|
||||||
|
testUnipolarClampsAtZeroWhereBipolarDoesNot();
|
||||||
|
testTheSameKnotsEvaluateIdenticallyInEitherDomain();
|
||||||
|
testBipolarPixelMapPutsZeroOnTheCentreLine();
|
||||||
|
testBipolarDragCoversTwiceTheValueRange();
|
||||||
testPixelFromPointMapsCornersAndMidpoint();
|
testPixelFromPointMapsCornersAndMidpoint();
|
||||||
testPointFromPixelInvertsAndClamps();
|
testPointFromPixelInvertsAndClamps();
|
||||||
testPixelMapsRoundTripWithinOnePixelQuantum();
|
testPixelMapsRoundTripWithinOnePixelQuantum();
|
||||||
|
|||||||
Reference in New Issue
Block a user