instrument: latch the note done at the read-head run-off, fit the migrated fades, and lift the curve dial and overlay selection into pure modules
This commit is contained in:
@@ -283,7 +283,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); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
|
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
|
||||||
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
|
||||||
- `envelope_overlay` — pure 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.
|
||||||
|
|||||||
@@ -90,8 +90,16 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
|
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
|
||||||
// stages lay 1:1 over the waveform from the start point.
|
// stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count
|
||||||
pitchEnv_.configure(postStart, p.pitchEnv);
|
// and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes
|
||||||
|
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
|
||||||
|
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
|
||||||
|
// rate, so its two domains already coincide.
|
||||||
|
const double pitchSpan =
|
||||||
|
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
|
||||||
|
? static_cast<double>(postStart)
|
||||||
|
: static_cast<double>(postStart) / baseRatio_;
|
||||||
|
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), p.pitchEnv);
|
||||||
pitchEnv_.noteOn();
|
pitchEnv_.noteOn();
|
||||||
|
|
||||||
// A restart lands every live glide back on the new note's own values, at a step derived
|
// A restart lands every live glide back on the new note's own values, at a step derived
|
||||||
|
|||||||
@@ -348,6 +348,12 @@ private:
|
|||||||
// play span ends within the ramp). With no declick (the common case) this is
|
// play span ends within the ramp). With no declick (the common case) this is
|
||||||
// byte-identical to the plain idle-out.
|
// byte-identical to the plain idle-out.
|
||||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||||
|
// The NOTE is over the moment the read head leaves its span, whether or not a ramp
|
||||||
|
// still rings: no later frame can carry envelope output. Latching here is what keeps
|
||||||
|
// a ringing-out voice out of soundingNote() — the Preserve cap would otherwise
|
||||||
|
// refuse a new onset, and mono legato would retune a voice already past its end
|
||||||
|
// (silencing the new note) for the whole ~4 ms ramp.
|
||||||
|
amplitudeDone_ = true;
|
||||||
if (declickPending_) seedDeclick();
|
if (declickPending_) seedDeclick();
|
||||||
if (!declickActive_) seedTerminalDeclick();
|
if (!declickActive_) seedTerminalDeclick();
|
||||||
if (declickActive_) {
|
if (declickActive_) {
|
||||||
@@ -376,8 +382,11 @@ private:
|
|||||||
// Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE
|
// Peer of the read-head exhaustion path above: a Trigger AHD whose stages end BEFORE
|
||||||
// the play span (a zero decay, which the shape deliberately keeps expressible) cuts the
|
// the play span (a zero decay, which the shape deliberately keeps expressible) cuts the
|
||||||
// same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which
|
// same synthetic Preserve tail at whatever level it was at. Seeded from lastOut, which
|
||||||
// still holds the PREVIOUS frame — this one is already silent. Gate is left out on
|
// still holds the PREVIOUS frame — this one is already silent. Gate is left out of THIS
|
||||||
// purpose: its amplitude reaches zero through a release, so there is no cut to ring out.
|
// site only: its amplitude reaches zero through a release, so nothing here is cut
|
||||||
|
// mid-level. The exhaustion path above deliberately does NOT exclude Gate — a held Gate
|
||||||
|
// note whose source runs out with no loop is cut at its sustain level, and under
|
||||||
|
// Preserve that cut lands on the same recycled synthetic tail.
|
||||||
if (amplitudeDone_ && amp == 0.0 && !declickActive_ &&
|
if (amplitudeDone_ && amp == 0.0 && !declickActive_ &&
|
||||||
playMode_ == PlayMode::Trigger) {
|
playMode_ == PlayMode::Trigger) {
|
||||||
seedTerminalDeclick();
|
seedTerminalDeclick();
|
||||||
|
|||||||
@@ -91,8 +91,26 @@ namespace reasampler::instrument::map {
|
|||||||
// seconds at the project rate the reader is handed. v10 writes ZERO into both — the values
|
// seconds at the project rate the reader is handed. v10 writes ZERO into both — the values
|
||||||
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
|
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
|
||||||
//
|
//
|
||||||
|
// LOSSY UNDER A RATE MISMATCH. The fades were SOURCE frames and the AHD stores wall-clock
|
||||||
|
// seconds, so the lift divides by the PROJECT rate while the build later multiplies by the
|
||||||
|
// DECODE rate: a file whose own rate differs from the project's comes back scaled by that
|
||||||
|
// ratio (a 441-frame fade on a 44.1 kHz file in a 48 kHz project resolves to 405 source
|
||||||
|
// frames, ~8% short). The codec cannot close this — it never sees the file — and deferring the
|
||||||
|
// lift to build time would mean carrying the retired fade pair through the parameter set,
|
||||||
|
// reintroducing the mechanism the AHD replaced.
|
||||||
|
//
|
||||||
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
||||||
|
|
||||||
|
// The exponents the lifted fades take. The AHD's curve law is phi^p (core/util/curve_law.h),
|
||||||
|
// which cannot reproduce the retired pair's equal-power sin/cos exactly at ANY exponent — so
|
||||||
|
// the lift takes the MINIMAX fit rather than the linear neutral, which is free (one constant,
|
||||||
|
// written once here) and several times closer. The two differ because the two stages fit
|
||||||
|
// different forms: attack fits phi^p to sin(pi*phi/2), decay fits 1 - t^q to cos(pi*t/2).
|
||||||
|
// The measured bounds are asserted in tests/test_component_state_io.cpp. Every OTHER curve on
|
||||||
|
// a migrated blob still lifts to the neutral — only the fades had a prior shape to reproduce.
|
||||||
|
inline constexpr double kTriggerFadeLiftAttackCurve = 0.6133;
|
||||||
|
inline constexpr double kTriggerFadeLiftDecayCurve = 1.7437;
|
||||||
|
|
||||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||||
|
|
||||||
// The params-payload format version and its detection marker. The marker is a high sentinel
|
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||||
|
|||||||
@@ -61,15 +61,17 @@ void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
|
|||||||
}
|
}
|
||||||
// THE lift of the retired Trigger fade pair onto the AHD that replaced it: Attack takes the
|
// THE lift of the retired Trigger fade pair onto the AHD that replaced it: Attack takes the
|
||||||
// fade-in, Decay the fade-out, Hold the whole remainder — so a zero fade-out lands Decay = 0
|
// fade-in, Decay the fade-out, Hold the whole remainder — so a zero fade-out lands Decay = 0
|
||||||
// and the abrupt end an old instance could express stays representable. The fades were SOURCE
|
// and the abrupt end an old instance could express stays representable. The seconds conversion
|
||||||
// frames and the AHD stores wall-clock seconds, so the conversion goes through the same
|
// and its rate-mismatch bound, and the two fitted exponents, are documented in
|
||||||
// project rate the v3 lift already uses. A v10-or-newer blob overwrites this from its own tail.
|
// component_state_io.h. A v10-or-newer blob overwrites all five fields from its own tail.
|
||||||
void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, double projectRate,
|
void liftTriggerFades(std::int64_t fadeInFrames, std::int64_t fadeOutFrames, double projectRate,
|
||||||
AhdSeconds& out) {
|
AhdSeconds& out) {
|
||||||
const double rate = projectRate > 0.0 ? projectRate : 1.0;
|
const double rate = projectRate > 0.0 ? projectRate : 1.0;
|
||||||
out.attackSeconds = static_cast<double>(fadeInFrames > 0 ? fadeInFrames : 0) / rate;
|
out.attackSeconds = static_cast<double>(fadeInFrames > 0 ? fadeInFrames : 0) / rate;
|
||||||
out.decaySeconds = static_cast<double>(fadeOutFrames > 0 ? fadeOutFrames : 0) / rate;
|
out.decaySeconds = static_cast<double>(fadeOutFrames > 0 ? fadeOutFrames : 0) / rate;
|
||||||
out.holdFraction = 1.0;
|
out.holdFraction = 1.0;
|
||||||
|
out.attackCurve = kTriggerFadeLiftAttackCurve;
|
||||||
|
out.decayCurve = kTriggerFadeLiftDecayCurve;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||||
|
|||||||
@@ -191,6 +191,32 @@ bool isLiveDeckParam(DeckParam id) {
|
|||||||
return false; // unreachable for a valid enumerator; silences a warning.
|
return false; // unreachable for a valid enumerator; silences a warning.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OverlayEnv overlayEnvForRadio(int radioId) {
|
||||||
|
switch (static_cast<DeckParam>(radioId)) {
|
||||||
|
case DeckParam::kAmpEnvSelect: return OverlayEnv::kAmp;
|
||||||
|
case DeckParam::kPitchEnvSelect: return OverlayEnv::kPitch;
|
||||||
|
case DeckParam::kFilterEnvSelect: return OverlayEnv::kFilter;
|
||||||
|
default: return OverlayEnv::kNone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
|
||||||
|
const OverlayEnv picked = overlayEnvForRadio(radioId);
|
||||||
|
if (picked == OverlayEnv::kNone) return current; // not a radio: nothing selects
|
||||||
|
return (current == picked) ? OverlayEnv::kNone : picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) {
|
||||||
|
switch (env) {
|
||||||
|
case OverlayEnv::kPitch: return !pitchEnvEnabled;
|
||||||
|
case OverlayEnv::kFilter: return !filterEnabled;
|
||||||
|
case OverlayEnv::kAmp:
|
||||||
|
case OverlayEnv::kNone:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false; // unreachable for a valid enumerator; silences a warning.
|
||||||
|
}
|
||||||
|
|
||||||
bool liveCommitFor(LiveDragKind kind, int paramId) {
|
bool liveCommitFor(LiveDragKind kind, int paramId) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case LiveDragKind::kDeckKnob:
|
case LiveDragKind::kDeckKnob:
|
||||||
|
|||||||
@@ -133,6 +133,25 @@ enum class LiveDragKind { kOther, kDeckKnob, kEnvNode };
|
|||||||
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
|
// can reach — AHDSR or AHD, on any of the three envelopes — is itself live.
|
||||||
bool liveCommitFor(LiveDragKind kind, int paramId);
|
bool liveCommitFor(LiveDragKind kind, int paramId);
|
||||||
|
|
||||||
|
// Which envelope the waveform overlay draws and edits. Exclusive across the three envelope
|
||||||
|
// decks, and kNone is a valid resting state — the editor opens there. Transient view state:
|
||||||
|
// never persisted, never a parameter.
|
||||||
|
enum class OverlayEnv { kNone, kAmp, kPitch, kFilter };
|
||||||
|
|
||||||
|
// The envelope a deck's overlay-select radio picks; kNone for any other control id.
|
||||||
|
OverlayEnv overlayEnvForRadio(int radioId);
|
||||||
|
|
||||||
|
// The selection a click on `radioId` produces from `current`. Two rules, provable here rather
|
||||||
|
// than in the shell: picking another deck's radio switches to it (exclusivity), and clicking
|
||||||
|
// the ACTIVE one clears back to kNone — "no envelope shown" is a state the user can get back
|
||||||
|
// to, not an error. A non-radio id leaves the selection alone.
|
||||||
|
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
|
||||||
|
|
||||||
|
// Whether the overlay for `env` is INERT: its deck group's enable toggle is off, so its knobs
|
||||||
|
// are drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches
|
||||||
|
// a param a knob couldn't (envelope_edit.h). Amp has no enable toggle and is never inert.
|
||||||
|
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled);
|
||||||
|
|
||||||
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
|
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
|
||||||
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
|
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
|
||||||
// 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.
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ per-segment envelope curve law.
|
|||||||
- `file_bytes` (`core/util`) — the ONE whole-file byte loader (Q-W1), linked by both artifacts; blocking I/O, off-audio-thread only.
|
- `file_bytes` (`core/util`) — the ONE whole-file byte loader (Q-W1), linked by both artifacts; blocking I/O, off-audio-thread only.
|
||||||
- `clamp01` (`core/util`, header-only) — the ONE unit-interval clamp (Q-W1), replacing four per-module static copies; NaN passes through unchanged rather than collapsing to a bound.
|
- `clamp01` (`core/util`, header-only) — the ONE unit-interval clamp (Q-W1), replacing four per-module static copies; NaN passes through unchanged rather than collapsing to a bound.
|
||||||
- `curve_law` (`core/util`, header-only) — the ONE per-segment envelope curve law: the
|
- `curve_law` (`core/util`, header-only) — the ONE per-segment envelope curve law: the
|
||||||
exponent domain (0.1..10, neutral 1.0), the normalized-position -> normalized-level map, and
|
exponent domain (0.1..10, neutral 1.0), the normalized-position -> normalized-level map, the
|
||||||
the mid-segment inverse an overlay knot drags through. Header-only and dependency-free so
|
mid-segment inverse an overlay knot drags through, and the inner dial's own norm<->exponent
|
||||||
the engine's evaluator, the overlay's forward map, its inverse, and the deck's inner dial all
|
travel. Header-only and dependency-free so the engine's evaluator, the overlay's forward map,
|
||||||
read one law instead of four copies. **The neutral exponent is the IDENTITY, bit for bit** —
|
its inverse, and the deck's inner dial all read one law instead of four copies.
|
||||||
that is what makes an instance saved before curves existed play unchanged.
|
**The neutral exponent is the IDENTITY, bit for bit** — that is what makes an instance saved
|
||||||
|
before curves existed play unchanged, and what the knob law's centre detent exists to keep
|
||||||
|
reachable from the dial.
|
||||||
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
|
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ inline constexpr double kCurveMax = 10.0;
|
|||||||
// at-rest per-sample path pays one predicted branch instead of a transcendental; every
|
// at-rest per-sample path pays one predicted branch instead of a transcendental; every
|
||||||
// positive exponent maps 0 -> 0 and 1 -> 1, so a curved stage can never overshoot its own
|
// positive exponent maps 0 -> 0 and 1 -> 1, so a curved stage can never overshoot its own
|
||||||
// endpoint levels.
|
// endpoint levels.
|
||||||
|
//
|
||||||
|
// MEASURED (Release, MSVC 19.44 x64, one dev machine): a non-neutral exponent costs ~4.7 ns per
|
||||||
|
// evaluation. At the 16-voice default with all three envelopes in a sloped stage — three
|
||||||
|
// evaluations per voice per frame, the worst case — that is +224 ns per output frame: 3.1% of
|
||||||
|
// one core at 44.1 kHz becomes 4.1%. Affordable at the shipped voice ceiling; re-measure before
|
||||||
|
// putting a fourth per-voice curve on the frame.
|
||||||
inline double curveMap(double phi, double exponent) {
|
inline double curveMap(double phi, double exponent) {
|
||||||
if (exponent == kCurveNeutral) return phi;
|
if (exponent == kCurveNeutral) return phi;
|
||||||
return std::pow(phi, exponent);
|
return std::pow(phi, exponent);
|
||||||
@@ -29,6 +35,29 @@ inline double clampCurve(double exponent) {
|
|||||||
return exponent > kCurveMax ? kCurveMax : exponent;
|
return exponent > kCurveMax ? kCurveMax : exponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The exponent's KNOB travel: logarithmic, so the two halves of the throw are the reciprocal
|
||||||
|
// shaping directions. Written as kCurveMax^(2t-1) rather than as an interpolation between
|
||||||
|
// log(kCurveMin) and log(kCurveMax) so t == 0.5 evaluates exp(0) == 1.0 EXACTLY — an inexact
|
||||||
|
// centre would put std::pow on the per-sample path for a stage the user believes is parked at
|
||||||
|
// the identity. Requires kCurveMin == 1/kCurveMax, which the domain above satisfies.
|
||||||
|
//
|
||||||
|
// The detent is what makes the identity REACHABLE: a knob drag delivers start - dy/128, a grid
|
||||||
|
// that lands on 0.5 only by luck, so a band wider than one drag step (1/128) snaps to neutral
|
||||||
|
// and a dial swept through the centre cannot skip over it.
|
||||||
|
inline constexpr double kCurveKnobDetent = 0.01;
|
||||||
|
|
||||||
|
inline double curveFromKnobNorm(double norm) {
|
||||||
|
const double t = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : norm);
|
||||||
|
const double off = t - 0.5;
|
||||||
|
if (off < kCurveKnobDetent && off > -kCurveKnobDetent) return kCurveNeutral;
|
||||||
|
return clampCurve(std::exp((2.0 * t - 1.0) * std::log(kCurveMax)));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline double knobNormFromCurve(double exponent) {
|
||||||
|
const double t = 0.5 + std::log(clampCurve(exponent)) / (2.0 * std::log(kCurveMax));
|
||||||
|
return t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t);
|
||||||
|
}
|
||||||
|
|
||||||
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
|
// The normalized level at a segment's MIDPOINT (phi = 0.5) — where the overlay places the
|
||||||
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
|
// draggable curve knot — and its inverse. The pair is what keeps knot-drag and inner dial on
|
||||||
// one value: both resolve through this law, not through each other.
|
// one value: both resolve through this law, not through each other.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
#include "shell/instrument/reasampler_editor.h"
|
#include "shell/instrument/reasampler_editor.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath> // log/exp (the curve knob's logarithmic travel)
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio> // snprintf (deck value labels)
|
#include <cstdio> // snprintf (deck value labels)
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -49,23 +48,12 @@ namespace {
|
|||||||
// only 0..1). Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly
|
// only 0..1). Every stage-time knob spans [0, kEnvTimeMaxSeconds] seconds — rate-free, exactly
|
||||||
// what the parameter set stores; the build resolves seconds->frames at the live rate. No knob
|
// what the parameter set stores; the build resolves seconds->frames at the live rate. No knob
|
||||||
// on this surface stores a source-frame count any more, so none needs a rate to draw.
|
// on this surface stores a source-frame count any more, so none needs a rate to draw.
|
||||||
constexpr double kEnvTimeMaxSeconds = 2.0; // every stage-time knob's ceiling (seconds)
|
//
|
||||||
|
// 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.
|
||||||
|
constexpr double kEnvTimeMaxSeconds = instrument::ui::kGateStageMaxSeconds;
|
||||||
constexpr double kPitchDepthMaxSemis = 24.0; // pitch depth throw: +/-24 st, centered
|
constexpr double kPitchDepthMaxSemis = 24.0; // pitch depth throw: +/-24 st, centered
|
||||||
constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%)
|
constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling (0..200%)
|
||||||
|
|
||||||
// A curve exponent's knob travel is LOGARITHMIC: 0.5 is the linear neutral, so the two halves
|
|
||||||
// of the throw are the reciprocal shaping directions and the neutral sits at a centre detent.
|
|
||||||
double curveFromNorm(double norm) {
|
|
||||||
const double t = clamp01(norm);
|
|
||||||
return std::exp(std::log(util::kCurveMin) +
|
|
||||||
t * (std::log(util::kCurveMax) - std::log(util::kCurveMin)));
|
|
||||||
}
|
|
||||||
double normFromCurve(double curve) {
|
|
||||||
const double c = util::clampCurve(curve);
|
|
||||||
return clamp01((std::log(c) - std::log(util::kCurveMin)) /
|
|
||||||
(std::log(util::kCurveMax) - std::log(util::kCurveMin)));
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
|
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
|
||||||
@@ -91,23 +79,23 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
|
|||||||
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
|
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
|
||||||
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
|
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
|
||||||
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
|
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
|
||||||
case ParamControl::kAttackCurve: return normFromCurve(play.adsr.attackCurve);
|
case ParamControl::kAttackCurve: return util::knobNormFromCurve(play.adsr.attackCurve);
|
||||||
case ParamControl::kDecayCurve: return normFromCurve(play.adsr.decayCurve);
|
case ParamControl::kDecayCurve: return util::knobNormFromCurve(play.adsr.decayCurve);
|
||||||
case ParamControl::kReleaseCurve: return normFromCurve(play.adsr.releaseCurve);
|
case ParamControl::kReleaseCurve: return util::knobNormFromCurve(play.adsr.releaseCurve);
|
||||||
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
|
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
|
||||||
case ParamControl::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
|
case ParamControl::kTrigAttack: return secToNorm(play.trigAhd.attackSeconds);
|
||||||
case ParamControl::kTrigHold: return clamp01(play.trigAhd.holdFraction);
|
case ParamControl::kTrigHold: return clamp01(play.trigAhd.holdFraction);
|
||||||
case ParamControl::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
|
case ParamControl::kTrigDecay: return secToNorm(play.trigAhd.decaySeconds);
|
||||||
case ParamControl::kTrigAttackCurve: return normFromCurve(play.trigAhd.attackCurve);
|
case ParamControl::kTrigAttackCurve: return util::knobNormFromCurve(play.trigAhd.attackCurve);
|
||||||
case ParamControl::kTrigDecayCurve: return normFromCurve(play.trigAhd.decayCurve);
|
case ParamControl::kTrigDecayCurve: return util::knobNormFromCurve(play.trigAhd.decayCurve);
|
||||||
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
|
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
|
||||||
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.shape.attackSeconds);
|
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.shape.attackSeconds);
|
||||||
case ParamControl::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
|
case ParamControl::kPitchEnvHold: return clamp01(play.pitchEnv.shape.holdFraction);
|
||||||
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
|
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.shape.decaySeconds);
|
||||||
case ParamControl::kPitchEnvAttackCurve:
|
case ParamControl::kPitchEnvAttackCurve:
|
||||||
return normFromCurve(play.pitchEnv.shape.attackCurve);
|
return util::knobNormFromCurve(play.pitchEnv.shape.attackCurve);
|
||||||
case ParamControl::kPitchEnvDecayCurve:
|
case ParamControl::kPitchEnvDecayCurve:
|
||||||
return normFromCurve(play.pitchEnv.shape.decayCurve);
|
return util::knobNormFromCurve(play.pitchEnv.shape.decayCurve);
|
||||||
case ParamControl::kPitchEnvDepth:
|
case ParamControl::kPitchEnvDepth:
|
||||||
// Signed depth centered at 0.5 (0.5 == 0 semitones).
|
// Signed depth centered at 0.5 (0.5 == 0 semitones).
|
||||||
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
|
||||||
@@ -129,18 +117,18 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
|
|||||||
case ParamControl::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
|
case ParamControl::kFilterEnvSustain: return clamp01(play.filter.env.sustainLevel);
|
||||||
case ParamControl::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
|
case ParamControl::kFilterEnvRelease: return secToNorm(play.filter.env.releaseSeconds);
|
||||||
case ParamControl::kFilterEnvAttackCurve:
|
case ParamControl::kFilterEnvAttackCurve:
|
||||||
return normFromCurve(play.filter.env.attackCurve);
|
return util::knobNormFromCurve(play.filter.env.attackCurve);
|
||||||
case ParamControl::kFilterEnvDecayCurve:
|
case ParamControl::kFilterEnvDecayCurve:
|
||||||
return normFromCurve(play.filter.env.decayCurve);
|
return util::knobNormFromCurve(play.filter.env.decayCurve);
|
||||||
case ParamControl::kFilterEnvReleaseCurve:
|
case ParamControl::kFilterEnvReleaseCurve:
|
||||||
return normFromCurve(play.filter.env.releaseCurve);
|
return util::knobNormFromCurve(play.filter.env.releaseCurve);
|
||||||
case ParamControl::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
|
case ParamControl::kFilterTrigAttack: return secToNorm(play.filter.trigEnv.attackSeconds);
|
||||||
case ParamControl::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
|
case ParamControl::kFilterTrigHold: return clamp01(play.filter.trigEnv.holdFraction);
|
||||||
case ParamControl::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
|
case ParamControl::kFilterTrigDecay: return secToNorm(play.filter.trigEnv.decaySeconds);
|
||||||
case ParamControl::kFilterTrigAttackCurve:
|
case ParamControl::kFilterTrigAttackCurve:
|
||||||
return normFromCurve(play.filter.trigEnv.attackCurve);
|
return util::knobNormFromCurve(play.filter.trigEnv.attackCurve);
|
||||||
case ParamControl::kFilterTrigDecayCurve:
|
case ParamControl::kFilterTrigDecayCurve:
|
||||||
return normFromCurve(play.filter.trigEnv.decayCurve);
|
return util::knobNormFromCurve(play.filter.trigEnv.decayCurve);
|
||||||
default: return 0.0;
|
default: return 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,9 +148,9 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
|||||||
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
|
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
|
||||||
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
|
||||||
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
|
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
|
||||||
case ParamControl::kAttackCurve: play.adsr.attackCurve = curveFromNorm(value); break;
|
case ParamControl::kAttackCurve: play.adsr.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kDecayCurve: play.adsr.decayCurve = curveFromNorm(value); break;
|
case ParamControl::kDecayCurve: play.adsr.decayCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kReleaseCurve: play.adsr.releaseCurve = curveFromNorm(value); break;
|
case ParamControl::kReleaseCurve: play.adsr.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kTrigLength:
|
case ParamControl::kTrigLength:
|
||||||
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
|
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
|
||||||
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
|
||||||
@@ -171,9 +159,9 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
|||||||
case ParamControl::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
|
case ParamControl::kTrigHold: play.trigAhd.holdFraction = clamp01(value); break;
|
||||||
case ParamControl::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
|
case ParamControl::kTrigDecay: play.trigAhd.decaySeconds = normToSec(value); break;
|
||||||
case ParamControl::kTrigAttackCurve:
|
case ParamControl::kTrigAttackCurve:
|
||||||
play.trigAhd.attackCurve = curveFromNorm(value); break;
|
play.trigAhd.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kTrigDecayCurve:
|
case ParamControl::kTrigDecayCurve:
|
||||||
play.trigAhd.decayCurve = curveFromNorm(value); break;
|
play.trigAhd.decayCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kPitchEnvEnable:
|
case ParamControl::kPitchEnvEnable:
|
||||||
play.pitchEnv.enabled = (segment == 1);
|
play.pitchEnv.enabled = (segment == 1);
|
||||||
break;
|
break;
|
||||||
@@ -184,9 +172,9 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
|||||||
case ParamControl::kPitchEnvDecay:
|
case ParamControl::kPitchEnvDecay:
|
||||||
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
|
play.pitchEnv.shape.decaySeconds = normToSec(value); break;
|
||||||
case ParamControl::kPitchEnvAttackCurve:
|
case ParamControl::kPitchEnvAttackCurve:
|
||||||
play.pitchEnv.shape.attackCurve = curveFromNorm(value); break;
|
play.pitchEnv.shape.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kPitchEnvDecayCurve:
|
case ParamControl::kPitchEnvDecayCurve:
|
||||||
play.pitchEnv.shape.decayCurve = curveFromNorm(value); break;
|
play.pitchEnv.shape.decayCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kPitchEnvDepth:
|
case ParamControl::kPitchEnvDepth:
|
||||||
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
|
||||||
break;
|
break;
|
||||||
@@ -218,11 +206,11 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
|||||||
case ParamControl::kFilterEnvRelease:
|
case ParamControl::kFilterEnvRelease:
|
||||||
play.filter.env.releaseSeconds = normToSec(value); break;
|
play.filter.env.releaseSeconds = normToSec(value); break;
|
||||||
case ParamControl::kFilterEnvAttackCurve:
|
case ParamControl::kFilterEnvAttackCurve:
|
||||||
play.filter.env.attackCurve = curveFromNorm(value); break;
|
play.filter.env.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kFilterEnvDecayCurve:
|
case ParamControl::kFilterEnvDecayCurve:
|
||||||
play.filter.env.decayCurve = curveFromNorm(value); break;
|
play.filter.env.decayCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kFilterEnvReleaseCurve:
|
case ParamControl::kFilterEnvReleaseCurve:
|
||||||
play.filter.env.releaseCurve = curveFromNorm(value); break;
|
play.filter.env.releaseCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kFilterTrigAttack:
|
case ParamControl::kFilterTrigAttack:
|
||||||
play.filter.trigEnv.attackSeconds = normToSec(value); break;
|
play.filter.trigEnv.attackSeconds = normToSec(value); break;
|
||||||
case ParamControl::kFilterTrigHold:
|
case ParamControl::kFilterTrigHold:
|
||||||
@@ -230,9 +218,9 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
|||||||
case ParamControl::kFilterTrigDecay:
|
case ParamControl::kFilterTrigDecay:
|
||||||
play.filter.trigEnv.decaySeconds = normToSec(value); break;
|
play.filter.trigEnv.decaySeconds = normToSec(value); break;
|
||||||
case ParamControl::kFilterTrigAttackCurve:
|
case ParamControl::kFilterTrigAttackCurve:
|
||||||
play.filter.trigEnv.attackCurve = curveFromNorm(value); break;
|
play.filter.trigEnv.attackCurve = util::curveFromKnobNorm(value); break;
|
||||||
case ParamControl::kFilterTrigDecayCurve:
|
case ParamControl::kFilterTrigDecayCurve:
|
||||||
play.filter.trigEnv.decayCurve = curveFromNorm(value); break;
|
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,7 +373,7 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
|||||||
case ParamControl::kFilterEnvReleaseCurve:
|
case ParamControl::kFilterEnvReleaseCurve:
|
||||||
case ParamControl::kFilterTrigAttackCurve:
|
case ParamControl::kFilterTrigAttackCurve:
|
||||||
case ParamControl::kFilterTrigDecayCurve:
|
case ParamControl::kFilterTrigDecayCurve:
|
||||||
snprintf(buf, sizeof(buf), "^%.2f", curveFromNorm(controlValue(id, play)));
|
snprintf(buf, sizeof(buf), "^%.2f", util::curveFromKnobNorm(controlValue(id, play)));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
|
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
|
||||||
@@ -406,15 +394,6 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
ReaSamplerEditor::OverlayEnv ReaSamplerEditor::overlayEnvForRadio(int radioId) {
|
|
||||||
switch (static_cast<ParamControl>(radioId)) {
|
|
||||||
case ParamControl::kAmpEnvSelect: return OverlayEnv::kAmp;
|
|
||||||
case ParamControl::kPitchEnvSelect: return OverlayEnv::kPitch;
|
|
||||||
case ParamControl::kFilterEnvSelect: return OverlayEnv::kFilter;
|
|
||||||
default: return OverlayEnv::kNone;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
|
// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
|
||||||
// one place rather than two.
|
// one place rather than two.
|
||||||
|
|||||||
@@ -51,10 +51,7 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
|||||||
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
|
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
|
||||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||||
if (hit.kind == DeckHitKind::CaptionRadio) {
|
if (hit.kind == DeckHitKind::CaptionRadio) {
|
||||||
// Exclusive across the three envelope decks, and clicking the active one clears it —
|
overlayEnv_ = nextOverlaySelection(overlayEnv_, hit.id);
|
||||||
// "no envelope shown" is a state the user can get back to, not an error.
|
|
||||||
const OverlayEnv picked = overlayEnvForRadio(hit.id);
|
|
||||||
overlayEnv_ = (overlayEnv_ == picked) ? OverlayEnv::kNone : picked;
|
|
||||||
invalidate(); // view state only: no parameter write, no reload
|
invalidate(); // view state only: no parameter write, no reload
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,13 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
|||||||
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
|
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
|
||||||
|
|
||||||
// Envelope nodes first (they sit on top of the markers), then the wave markers. With no
|
// Envelope nodes first (they sit on top of the markers), then the wave markers. With no
|
||||||
// envelope overlay-active there are no nodes at all and the markers take every grab.
|
// envelope overlay-active — or with its deck group's enable toggle off, which makes the
|
||||||
|
// same params' knobs inert — there are no grabbable nodes and the markers take every grab.
|
||||||
const double rate = liveSampleRate();
|
const double rate = liveSampleRate();
|
||||||
if (rate > 0.0 && overlayEnv_ != OverlayEnv::kNone) {
|
const bool nodesLive =
|
||||||
|
overlayEnv_ != OverlayEnv::kNone &&
|
||||||
|
!overlayEnvInert(overlayEnv_, params_.play.pitchEnv.enabled, params_.play.filter.enabled);
|
||||||
|
if (rate > 0.0 && nodesLive) {
|
||||||
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
||||||
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
|
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
|
||||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ private:
|
|||||||
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
|
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
|
||||||
kCurveNode, kDeckKnob };
|
kCurveNode, kDeckKnob };
|
||||||
|
|
||||||
// Which envelope the waveform overlay is drawing and editing. Exclusive, and kNone is a
|
// Which envelope the waveform overlay is drawing and editing. The selection type and its
|
||||||
// valid resting state — the editor opens there. Transient view state: never persisted,
|
// whole state machine are the pure deck_groups module's; this alias keeps the shell's
|
||||||
// never a parameter.
|
// spelling.
|
||||||
enum class OverlayEnv { kNone, kAmp, kPitch, kFilter };
|
using OverlayEnv = instrument::ui::OverlayEnv;
|
||||||
|
|
||||||
// Controls on the setup surface. The int value is the opaque control id the pure
|
// Controls on the setup surface. The int value is the opaque control id the pure
|
||||||
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
|
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
|
||||||
@@ -335,10 +335,6 @@ private:
|
|||||||
// UNPACK (commit): an edited StageEnvelope -> the play params, in place.
|
// UNPACK (commit): an edited StageEnvelope -> the play params, in place.
|
||||||
void unpackEnvelope(OverlayEnv which, const StageEnvelope& env, PlaySeconds& play) const;
|
void unpackEnvelope(OverlayEnv which, const StageEnvelope& env, PlaySeconds& play) const;
|
||||||
|
|
||||||
// The radio control id that selects `which`, and its inverse. One table, so the deck's
|
|
||||||
// radio and the overlay can never drift apart.
|
|
||||||
static OverlayEnv overlayEnvForRadio(int radioId);
|
|
||||||
|
|
||||||
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
|
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
|
||||||
// never produce a param a slider couldn't.
|
// never produce a param a slider couldn't.
|
||||||
EnvClampBounds envClampBounds() const;
|
EnvClampBounds envClampBounds() const;
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
// link is a regression.
|
// link is a regression.
|
||||||
|
|
||||||
#include "../src/core/instrument/map/component_state_io.h"
|
#include "../src/core/instrument/map/component_state_io.h"
|
||||||
|
#include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec
|
||||||
|
// links no engine, and this adds none)
|
||||||
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
|
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
|
||||||
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
|
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
@@ -767,10 +770,94 @@ static void testSingleZoneMigrationIsLossless() {
|
|||||||
CHECK(p.play.adsr.attackCurve == util::kCurveNeutral);
|
CHECK(p.play.adsr.attackCurve == util::kCurveNeutral);
|
||||||
CHECK(p.play.adsr.decayCurve == util::kCurveNeutral);
|
CHECK(p.play.adsr.decayCurve == util::kCurveNeutral);
|
||||||
CHECK(p.play.adsr.releaseCurve == util::kCurveNeutral);
|
CHECK(p.play.adsr.releaseCurve == util::kCurveNeutral);
|
||||||
CHECK(p.play.trigAhd.attackCurve == util::kCurveNeutral);
|
|
||||||
CHECK(p.play.trigAhd.decayCurve == util::kCurveNeutral);
|
|
||||||
CHECK(p.play.pitchEnv.shape.holdFraction == 0.0);
|
CHECK(p.play.pitchEnv.shape.holdFraction == 0.0);
|
||||||
CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
|
CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
|
||||||
|
// The ONE exception, and the reason it is one: the fades had a prior SHAPE to reproduce,
|
||||||
|
// so they lift to the fitted exponents rather than to the neutral (see the contour test).
|
||||||
|
CHECK(p.play.trigAhd.attackCurve == kTriggerFadeLiftAttackCurve);
|
||||||
|
CHECK(p.play.trigAhd.decayCurve == kTriggerFadeLiftDecayCurve);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The migrated Trigger amp shape against the retired EQUAL-POWER fade pair it replaced. The
|
||||||
|
// AHD's law is phi^p and cannot reproduce sin/cos at any exponent, so the claim is a bound —
|
||||||
|
// and the bound the fitted exponents reach is several times tighter than the linear neutral's,
|
||||||
|
// which is what makes the fit worth a constant.
|
||||||
|
static void testMigratedFadeContourTracksTheRetiredEqualPowerShape() {
|
||||||
|
const double rate = 48000.0;
|
||||||
|
const std::int64_t fadeIn = 200;
|
||||||
|
const std::int64_t fadeOut = 300;
|
||||||
|
const std::int64_t span = 1000;
|
||||||
|
|
||||||
|
legacy::Zone z;
|
||||||
|
z.sampleId = "kick";
|
||||||
|
z.trigger = true;
|
||||||
|
z.lengthFraction = 1.0;
|
||||||
|
z.fadeIn = fadeIn;
|
||||||
|
z.fadeOut = fadeOut;
|
||||||
|
const ComponentState st =
|
||||||
|
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), rate);
|
||||||
|
const AhdSeconds& lifted = st.params.play.trigAhd;
|
||||||
|
|
||||||
|
// Resolve the lifted seconds back to frames at the SAME rate the lift used, which is the
|
||||||
|
// matched-rate case (the mismatched one is asserted in sample_map_tests).
|
||||||
|
const auto toFrames = [rate](double sec) {
|
||||||
|
return static_cast<std::int64_t>(sec * rate + 0.5);
|
||||||
|
};
|
||||||
|
AhdParams migrated;
|
||||||
|
migrated.attackFrames = toFrames(lifted.attackSeconds);
|
||||||
|
migrated.decayFrames = toFrames(lifted.decaySeconds);
|
||||||
|
migrated.holdFraction = lifted.holdFraction;
|
||||||
|
migrated.attackCurve = lifted.attackCurve;
|
||||||
|
migrated.decayCurve = lifted.decayCurve;
|
||||||
|
// Stage LENGTHS are exact: the fades land on the same frames they always did.
|
||||||
|
AhdEnvelope ahd;
|
||||||
|
ahd.configure(span, migrated);
|
||||||
|
CHECK(ahd.stages().attack == fadeIn);
|
||||||
|
CHECK(ahd.stages().decay == fadeOut);
|
||||||
|
CHECK(ahd.stages().total == span);
|
||||||
|
|
||||||
|
// The pre-change evaluator, written out so the comparison is against a stated reference
|
||||||
|
// rather than against whatever the code now does.
|
||||||
|
const double pi = 3.14159265358979323846;
|
||||||
|
const auto retired = [&](double off) {
|
||||||
|
if (off < 0.0 || off >= static_cast<double>(span)) return 0.0;
|
||||||
|
if (off < static_cast<double>(fadeIn)) {
|
||||||
|
return std::sin(off / static_cast<double>(fadeIn) * (pi / 2.0));
|
||||||
|
}
|
||||||
|
const double foStart = static_cast<double>(span - fadeOut);
|
||||||
|
if (off >= foStart) {
|
||||||
|
return std::cos((off - foStart) / static_cast<double>(fadeOut) * (pi / 2.0));
|
||||||
|
}
|
||||||
|
return 1.0;
|
||||||
|
};
|
||||||
|
const auto worstAgainstRetired = [&](AhdEnvelope& env) {
|
||||||
|
double worst = 0.0;
|
||||||
|
for (std::int64_t i = 0; i < span; ++i) {
|
||||||
|
const double d = env.amplitudeAt(static_cast<double>(i)) -
|
||||||
|
retired(static_cast<double>(i));
|
||||||
|
worst = worst > std::fabs(d) ? worst : std::fabs(d);
|
||||||
|
}
|
||||||
|
return worst;
|
||||||
|
};
|
||||||
|
|
||||||
|
const double fitted = worstAgainstRetired(ahd);
|
||||||
|
CHECK(fitted <= 0.0876); // the measured minimax bound of phi^p against sin(pi*phi/2)
|
||||||
|
|
||||||
|
// The rejected alternative, evaluated rather than asserted about: the same lift at the
|
||||||
|
// linear neutral. If the fitted exponents were ever dropped this comparison inverts.
|
||||||
|
AhdParams neutralLift = migrated;
|
||||||
|
neutralLift.attackCurve = util::kCurveNeutral;
|
||||||
|
neutralLift.decayCurve = util::kCurveNeutral;
|
||||||
|
AhdEnvelope neutral;
|
||||||
|
neutral.configure(span, neutralLift);
|
||||||
|
const double neutralWorst = worstAgainstRetired(neutral);
|
||||||
|
CHECK(neutralWorst > 0.21);
|
||||||
|
CHECK(fitted < neutralWorst * 0.5);
|
||||||
|
|
||||||
|
// Both agree exactly where it matters structurally: the onset, the plateau, and the end.
|
||||||
|
CHECK(ahd.amplitudeAt(0.0) == retired(0.0));
|
||||||
|
CHECK(ahd.amplitudeAt(600.0) == retired(600.0));
|
||||||
|
CHECK(ahd.amplitudeAt(static_cast<double>(span)) == retired(static_cast<double>(span)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A prior ZERO fade-out lands Decay = 0: the abrupt end an old Trigger instance could express
|
// A prior ZERO fade-out lands Decay = 0: the abrupt end an old Trigger instance could express
|
||||||
@@ -1165,6 +1252,7 @@ int main() {
|
|||||||
testEnvelopePrefixBytesFrozen();
|
testEnvelopePrefixBytesFrozen();
|
||||||
testWriterEmitsCurrentPayloadVersion();
|
testWriterEmitsCurrentPayloadVersion();
|
||||||
testSingleZoneMigrationIsLossless();
|
testSingleZoneMigrationIsLossless();
|
||||||
|
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
|
||||||
testZeroFadeOutMigratesToZeroDecay();
|
testZeroFadeOutMigratesToZeroDecay();
|
||||||
testSingleZoneMigrationLiftsLoopDisablingOverride();
|
testSingleZoneMigrationLiftsLoopDisablingOverride();
|
||||||
testLiftedStateReSavesInCurrentFormat();
|
testLiftedStateReSavesInCurrentFormat();
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
// makes a pre-existing instance play unchanged); endpoint exactness at every exponent (no
|
// makes a pre-existing instance play unchanged); endpoint exactness at every exponent (no
|
||||||
// segment can overshoot its own endpoint levels); monotonicity and finiteness across the full
|
// segment can overshoot its own endpoint levels); monotonicity and finiteness across the full
|
||||||
// 0.1..10 domain including both endpoints; the mid-level inverse the overlay knot drags
|
// 0.1..10 domain including both endpoints; the mid-level inverse the overlay knot drags
|
||||||
// through, and its round trip against the exponent.
|
// through, and its round trip against the exponent; and the inner dial's own travel — exact at
|
||||||
|
// the neutral centre, and reachable there from a real drag grid.
|
||||||
|
|
||||||
#include "../src/core/util/curve_law.h"
|
#include "../src/core/util/curve_law.h"
|
||||||
|
|
||||||
@@ -106,6 +107,72 @@ static void testMidLevelInverseSaturates() {
|
|||||||
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
|
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The inner dial's travel ---------------------------------------------------
|
||||||
|
|
||||||
|
// The knob drag delivers `start - dy/kKnobDragRangePixels`. param_slider owns that constant and
|
||||||
|
// this module deliberately does not link it, so the step is restated here; the structural
|
||||||
|
// assertion below is what keeps the detent wide enough for whatever it is.
|
||||||
|
static constexpr double kKnobStep = 1.0 / 128.0;
|
||||||
|
|
||||||
|
// The dial's centre must reach the neutral EXACTLY, in both directions — an exponent a hair off
|
||||||
|
// 1.0 costs a std::pow per sample per voice forever on a stage the user believes is at rest.
|
||||||
|
static void testKnobLawIsExactAtTheNeutralCentre() {
|
||||||
|
CHECK(knobNormFromCurve(kCurveNeutral) == 0.5);
|
||||||
|
CHECK(curveFromKnobNorm(0.5) == kCurveNeutral);
|
||||||
|
// And the identity that exactness buys: curveMap takes its bit-identical fast path.
|
||||||
|
for (int i = 0; i <= 100; ++i) {
|
||||||
|
const double phi = static_cast<double>(i) / 100.0;
|
||||||
|
CHECK(curveMap(phi, curveFromKnobNorm(0.5)) == phi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dial swept THROUGH the centre has to land on the identity. The raw logarithmic travel does
|
||||||
|
// not — the drag grid steps by 1/128 and only touches 0.5 by luck — so this is the detent's own
|
||||||
|
// property, asserted against that raw travel as the reference.
|
||||||
|
static void testADialSweptThroughNeutralLandsOnTheIdentity() {
|
||||||
|
const auto rawTravel = [](double t) {
|
||||||
|
return std::exp((2.0 * t - 1.0) * std::log(kCurveMax));
|
||||||
|
};
|
||||||
|
// A real drag: grabbed at a shaped value, dragged 40 steps down through the centre.
|
||||||
|
const double grab = 0.5 + 17.0 * kKnobStep + 0.003; // deliberately off the grid
|
||||||
|
int detented = 0;
|
||||||
|
int rawHits = 0;
|
||||||
|
for (int step = 0; step <= 40; ++step) {
|
||||||
|
const double t = grab - step * kKnobStep;
|
||||||
|
if (curveFromKnobNorm(t) == kCurveNeutral) ++detented;
|
||||||
|
if (rawTravel(t) == kCurveNeutral) ++rawHits;
|
||||||
|
}
|
||||||
|
CHECK(detented >= 1); // the sweep reaches the identity
|
||||||
|
CHECK(rawHits == 0); // and would not have without the detent
|
||||||
|
// The structural reason it cannot be skipped: the band is wider than one drag step.
|
||||||
|
CHECK(kCurveKnobDetent > kKnobStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside the detent the pair are inverses, so the dial reads back what it wrote and the
|
||||||
|
// endpoints saturate on the domain rather than past it.
|
||||||
|
static void testKnobLawRoundTripsOutsideTheDetent() {
|
||||||
|
const double exps[] = {kCurveMin, 0.2, 0.5, 0.8, 1.3, 2.0, 5.0, kCurveMax};
|
||||||
|
for (double e : exps) {
|
||||||
|
const double back = curveFromKnobNorm(knobNormFromCurve(e));
|
||||||
|
CHECK(std::fabs(back - e) < 1e-9);
|
||||||
|
}
|
||||||
|
CHECK(curveFromKnobNorm(0.0) == kCurveMin);
|
||||||
|
CHECK(curveFromKnobNorm(-3.0) == kCurveMin); // out-of-range norm saturates
|
||||||
|
CHECK(std::fabs(curveFromKnobNorm(1.0) - kCurveMax) < 1e-12);
|
||||||
|
// The ENDS need only land on the domain, not on an exact norm — 0.1 is not exactly 1/10 in
|
||||||
|
// binary, so log(kCurveMin) is a hair off -log(kCurveMax). Only the centre carries an
|
||||||
|
// exactness requirement, and only because the neutral is a bit-identity.
|
||||||
|
CHECK(std::fabs(knobNormFromCurve(kCurveMin)) < 1e-12);
|
||||||
|
CHECK(knobNormFromCurve(kCurveMax) == 1.0);
|
||||||
|
// Monotone rising across the whole travel, so the dial has one unambiguous direction.
|
||||||
|
double prev = 0.0;
|
||||||
|
for (int i = 0; i <= 500; ++i) {
|
||||||
|
const double v = curveFromKnobNorm(static_cast<double>(i) / 500.0);
|
||||||
|
CHECK(v >= prev);
|
||||||
|
prev = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testNeutralExponentIsTheIdentity();
|
testNeutralExponentIsTheIdentity();
|
||||||
testEndpointsAreExactAtEveryExponent();
|
testEndpointsAreExactAtEveryExponent();
|
||||||
@@ -114,6 +181,9 @@ int main() {
|
|||||||
testClampCurveHoldsTheDomain();
|
testClampCurveHoldsTheDomain();
|
||||||
testMidLevelRoundTripsAgainstTheExponent();
|
testMidLevelRoundTripsAgainstTheExponent();
|
||||||
testMidLevelInverseSaturates();
|
testMidLevelInverseSaturates();
|
||||||
|
testKnobLawIsExactAtTheNeutralCentre();
|
||||||
|
testADialSweptThroughNeutralLandsOnTheIdentity();
|
||||||
|
testKnobLawRoundTripsOutsideTheDetent();
|
||||||
if (g_fail == 0) std::printf("curve_law: all tests passed\n");
|
if (g_fail == 0) std::printf("curve_law: all tests passed\n");
|
||||||
else std::printf("curve_law: %d FAILED\n", g_fail);
|
else std::printf("curve_law: %d FAILED\n", g_fail);
|
||||||
return g_fail == 0 ? 0 : 1;
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
|
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
|
||||||
// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit
|
// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit
|
||||||
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
|
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
|
||||||
// law's inverse pair, and the commit-tier routing — which controls are live, and which drags
|
// law's inverse pair, the commit-tier routing — which controls are live, and which drags take
|
||||||
// take the live tier.
|
// the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
|
||||||
|
// and which selections are inert).
|
||||||
|
|
||||||
#include "../src/core/instrument/ui/deck_groups.h"
|
#include "../src/core/instrument/ui/deck_groups.h"
|
||||||
#include "../src/core/instrument/ui/sample_bands.h"
|
#include "../src/core/instrument/ui/sample_bands.h"
|
||||||
@@ -341,7 +342,66 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
|||||||
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)));
|
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The overlay selection state machine ---------------------------------------
|
||||||
|
|
||||||
|
static int radio(DeckParam p) { return static_cast<int>(p); }
|
||||||
|
|
||||||
|
// EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never
|
||||||
|
// be overlay-active at once, whatever the previous selection was.
|
||||||
|
static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() {
|
||||||
|
const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch,
|
||||||
|
OverlayEnv::kFilter};
|
||||||
|
for (OverlayEnv from : states) {
|
||||||
|
if (from != OverlayEnv::kAmp) {
|
||||||
|
CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp);
|
||||||
|
}
|
||||||
|
if (from != OverlayEnv::kPitch) {
|
||||||
|
CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) ==
|
||||||
|
OverlayEnv::kPitch);
|
||||||
|
}
|
||||||
|
if (from != OverlayEnv::kFilter) {
|
||||||
|
CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) ==
|
||||||
|
OverlayEnv::kFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kNone is a RESTING STATE the user can get back to: clicking the active radio clears it.
|
||||||
|
static void testClickingTheActiveOverlayRadioClearsToNone() {
|
||||||
|
CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) ==
|
||||||
|
OverlayEnv::kNone);
|
||||||
|
CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) ==
|
||||||
|
OverlayEnv::kNone);
|
||||||
|
CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) ==
|
||||||
|
OverlayEnv::kNone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A control that is not one of the three radios selects nothing and clears nothing.
|
||||||
|
static void testANonRadioIdLeavesTheOverlaySelectionAlone() {
|
||||||
|
CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone);
|
||||||
|
CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone);
|
||||||
|
CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) ==
|
||||||
|
OverlayEnv::kFilter);
|
||||||
|
CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on
|
||||||
|
// the same params: a node drag must not reach a value the knob refuses.
|
||||||
|
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
|
||||||
|
CHECK(overlayEnvInert(OverlayEnv::kPitch, /*pitchEnv=*/false, /*filter=*/true));
|
||||||
|
CHECK(!overlayEnvInert(OverlayEnv::kPitch, true, true));
|
||||||
|
CHECK(overlayEnvInert(OverlayEnv::kFilter, true, /*filter=*/false));
|
||||||
|
CHECK(!overlayEnvInert(OverlayEnv::kFilter, true, true));
|
||||||
|
// Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
|
||||||
|
CHECK(!overlayEnvInert(OverlayEnv::kAmp, false, false));
|
||||||
|
CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
|
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
|
||||||
|
testClickingTheActiveOverlayRadioClearsToNone();
|
||||||
|
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
||||||
|
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
||||||
testEveryDeckControlIsClassifiedLiveOrReloading();
|
testEveryDeckControlIsClassifiedLiveOrReloading();
|
||||||
testOnlyALiveControlsDragTakesTheLiveTier();
|
testOnlyALiveControlsDragTakesTheLiveTier();
|
||||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||||
|
|||||||
@@ -688,6 +688,28 @@ static void testResolvePlayRoundsAndFloorsNegatives() {
|
|||||||
CHECK(p.adsr.releaseFrames == 0);
|
CHECK(p.adsr.releaseFrames == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The retired Trigger fade pair was SOURCE frames; the AHD that replaced it stores wall-clock
|
||||||
|
// seconds, and the codec's lift can only divide by the PROJECT rate. This is the far end of
|
||||||
|
// that seam: the build multiplies by the DECODE rate, so a migrated fade comes back scaled by
|
||||||
|
// decodeRate/projectRate whenever a file's own rate differs from the project's. The bound is
|
||||||
|
// documented at the lift in component_state_io.h; this is its measured size.
|
||||||
|
static void testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate() {
|
||||||
|
PlaySeconds st;
|
||||||
|
st.trigAhd.attackSeconds = 441.0 / 44100.0; // a 441-SOURCE-frame fade lifted at 44.1k
|
||||||
|
st.trigAhd.decaySeconds = 882.0 / 44100.0;
|
||||||
|
|
||||||
|
// Matched rates are EXACT: the round trip through seconds loses nothing.
|
||||||
|
const PlayParams matched = resolvePlay(st, 44100);
|
||||||
|
CHECK(matched.trigAhd.attackFrames == 441);
|
||||||
|
CHECK(matched.trigAhd.decayFrames == 882);
|
||||||
|
|
||||||
|
// A 44.1 kHz file opened in a 48 kHz project: 441 * 48000/44100 = 480 source frames, ~8.8%
|
||||||
|
// longer than the fade the saved instance actually had.
|
||||||
|
const PlayParams stretched = resolvePlay(st, 48000);
|
||||||
|
CHECK(stretched.trigAhd.attackFrames == 480);
|
||||||
|
CHECK(stretched.trigAhd.decayFrames == 960);
|
||||||
|
}
|
||||||
|
|
||||||
// --- resolveCapture: the ONE override-beats-intrinsic fold ---------------------
|
// --- resolveCapture: the ONE override-beats-intrinsic fold ---------------------
|
||||||
|
|
||||||
static SelectedSample ref(const std::string& rel, int root, bool hasLoop = false,
|
static SelectedSample ref(const std::string& rel, int root, bool hasLoop = false,
|
||||||
@@ -914,6 +936,7 @@ int main() {
|
|||||||
testResolvePlayConvertsWallClockAtTheRate();
|
testResolvePlayConvertsWallClockAtTheRate();
|
||||||
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
|
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
|
||||||
testResolvePlayRoundsAndFloorsNegatives();
|
testResolvePlayRoundsAndFloorsNegatives();
|
||||||
|
testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate();
|
||||||
testResolveCaptureUsesIntrinsicsWhenNoOverride();
|
testResolveCaptureUsesIntrinsicsWhenNoOverride();
|
||||||
testResolveCaptureOverridesBeatIntrinsics();
|
testResolveCaptureOverridesBeatIntrinsics();
|
||||||
testResolveCaptureLoopOverrideCanDisableTheLoop();
|
testResolveCaptureLoopOverrideCanDisableTheLoop();
|
||||||
|
|||||||
+163
-47
@@ -8,8 +8,12 @@
|
|||||||
// (finite, monotone within a stage, never past the stage's endpoint levels); the AHD span split
|
// (finite, monotone within a stage, never past the stage's endpoint levels); the AHD span split
|
||||||
// (A+H+D can never exceed the span, for any triple, with no clamp on the sum; hold at 0% and
|
// (A+H+D can never exceed the span, for any triple, with no clamp on the sum; hold at 0% and
|
||||||
// 100%); the Gate/Trigger shape switch on both the amp and the filter envelope, with each
|
// 100%); the Gate/Trigger shape switch on both the amp and the filter envelope, with each
|
||||||
// mode's stage values surviving the other; and the Trigger tail's terminal behaviour under
|
// mode's stage values surviving the other; the terminal ring-out under Preserve — its shape,
|
||||||
// Preserve in both voice modes, against a Varispeed render that must not change.
|
// what it must NOT hold up (the Preserve cap, mono legato), and the Varispeed render it must
|
||||||
|
// leave alone; and the pitch envelope's span domain under a transposed Varispeed voice.
|
||||||
|
//
|
||||||
|
// The migration contour of the retired fade pair is asserted where the lift lives, in
|
||||||
|
// component_state_io_tests.
|
||||||
|
|
||||||
#include "../src/core/instrument/engine/voice_engine.h"
|
#include "../src/core/instrument/engine/voice_engine.h"
|
||||||
|
|
||||||
@@ -438,55 +442,163 @@ static void testVarispeedTailIsUntouched() {
|
|||||||
for (std::size_t i = 2000; i < out.size(); ++i) CHECK(out[i] == 0.0f);
|
for (std::size_t i = 2000; i < out.size(); ++i) CHECK(out[i] == 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Migration contour --------------------------------------------------------
|
// --- The ring-out must not hold the voice's slot -------------------------------
|
||||||
|
|
||||||
// The retired fade pair was an EQUAL-POWER ramp (sin/cos); the AHD that replaced it is the
|
// A 4000-frame sine one-shot at unity Preserve read rate: the read head leaves the span at
|
||||||
// curve law's neutral, which is LINEAR. Migration preserves the stage LENGTHS exactly, so the
|
// output frame 4000 and the ~185-frame ring-out runs from there. `kPastEnd` sits inside that
|
||||||
// contour tracks the old one to within the fixed sin(x)-vs-x gap — max |sin(t*pi/2) - t| over
|
// window, so a note-on at that point is the exact case both tests below need.
|
||||||
// [0,1], which is ~0.2105 at t ~= 0.4. Stated as the measured bound rather than judged: whether
|
static constexpr std::size_t kRingSpanFrames = 4000;
|
||||||
// that difference matters is Daniel's call, not this test's.
|
static constexpr std::size_t kPastEnd = 4120;
|
||||||
static void testMigratedFadeContourMatchesTheRetiredShapeWithinTheStatedBound() {
|
|
||||||
const std::int64_t span = 1000;
|
|
||||||
const std::int64_t fadeIn = 200;
|
|
||||||
const std::int64_t fadeOut = 300;
|
|
||||||
|
|
||||||
AhdParams migrated;
|
static SampleData ringOutSample() {
|
||||||
migrated.attackFrames = fadeIn; // Attack <- fade-in
|
SampleData s;
|
||||||
migrated.decayFrames = fadeOut; // Decay <- fade-out
|
s.frames.resize(kRingSpanFrames);
|
||||||
migrated.holdFraction = 1.0; // Hold <- the whole remainder
|
for (std::size_t i = 0; i < s.frames.size(); ++i) {
|
||||||
AhdEnvelope ahd;
|
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
|
||||||
ahd.configure(span, migrated);
|
|
||||||
// Stage LENGTHS are exact: the fades land on the same frames they always did.
|
|
||||||
CHECK(ahd.stages().attack == fadeIn);
|
|
||||||
CHECK(ahd.stages().decay == fadeOut);
|
|
||||||
CHECK(ahd.stages().total == span);
|
|
||||||
|
|
||||||
// The pre-change evaluator, written out so the comparison is against a stated reference
|
|
||||||
// rather than against whatever the code now does.
|
|
||||||
const auto retired = [&](double off) {
|
|
||||||
if (off < 0.0 || off >= static_cast<double>(span)) return 0.0;
|
|
||||||
if (off < static_cast<double>(fadeIn)) {
|
|
||||||
return std::sin(off / static_cast<double>(fadeIn) * (kPi / 2.0));
|
|
||||||
}
|
|
||||||
const double foStart = static_cast<double>(span - fadeOut);
|
|
||||||
if (off >= foStart) {
|
|
||||||
return std::cos((off - foStart) / static_cast<double>(fadeOut) * (kPi / 2.0));
|
|
||||||
}
|
|
||||||
return 1.0;
|
|
||||||
};
|
|
||||||
|
|
||||||
double worst = 0.0;
|
|
||||||
for (std::int64_t i = 0; i < span; ++i) {
|
|
||||||
worst = std::max(worst, std::fabs(ahd.amplitudeAt(static_cast<double>(i)) -
|
|
||||||
retired(static_cast<double>(i))));
|
|
||||||
}
|
}
|
||||||
CHECK(worst <= 0.2106); // the sin-vs-linear bound, and nothing beyond it
|
s.sampleRate = 48000;
|
||||||
// Both agree exactly where it matters structurally: the onset, the plateau, and the end.
|
s.rootNote = 60;
|
||||||
CHECK(ahd.amplitudeAt(0.0) == retired(0.0));
|
s.play.playMode = PlayMode::Trigger;
|
||||||
CHECK(ahd.amplitudeAt(600.0) == retired(600.0));
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
CHECK(ahd.amplitudeAt(static_cast<double>(span)) == retired(static_cast<double>(span)));
|
s.play.trigger.lengthFraction = 1.0;
|
||||||
|
s.play.trigAhd.attackFrames = 0;
|
||||||
|
s.play.trigAhd.decayFrames = 0;
|
||||||
|
s.play.trigAhd.holdFraction = 1.0;
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static double peakOf(const std::vector<AudioSample>& out, std::size_t from) {
|
||||||
|
double peak = 0.0;
|
||||||
|
for (std::size_t i = from; i < out.size(); ++i) {
|
||||||
|
peak = std::max(peak, std::fabs(static_cast<double>(out[i])));
|
||||||
|
}
|
||||||
|
return peak;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Preserve CAP counts sounding notes, not ring-outs. With the cap at one, a second onset
|
||||||
|
// fired while the first voice is past its end but still ramping must still be admitted —
|
||||||
|
// otherwise every Preserve one-shot silently swallows the next hit for ~4 ms.
|
||||||
|
static void testPreserveCapAdmitsANewOnsetDuringTheRingOut() {
|
||||||
|
const SampleData s = ringOutSample();
|
||||||
|
const auto run = [&](bool fireSecondNote) {
|
||||||
|
VoiceEngine eng(4, s, /*preserveCap=*/1, /*window=*/512);
|
||||||
|
eng.noteOn(67, 127);
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, kPastEnd);
|
||||||
|
if (fireSecondNote) eng.noteOn(72, 127);
|
||||||
|
eng.render(out, 400);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
// Precondition: the first voice really is still ringing at kPastEnd, so the second onset
|
||||||
|
// meets the cap while a past-end voice is alive. Without it the test proves nothing.
|
||||||
|
{
|
||||||
|
VoiceEngine probe(4, s, /*preserveCap=*/1, /*window=*/512);
|
||||||
|
probe.noteOn(67, 127);
|
||||||
|
std::vector<AudioSample> discard;
|
||||||
|
probe.render(discard, kPastEnd);
|
||||||
|
CHECK(probe.activeVoiceCount() == 1);
|
||||||
|
}
|
||||||
|
// The discriminator is rendered signal, not a voice count: a refused onset leaves the two
|
||||||
|
// renders identical from kPastEnd on (the decaying ramp and nothing else).
|
||||||
|
const std::vector<AudioSample> withSecond = run(true);
|
||||||
|
const std::vector<AudioSample> ringOnly = run(false);
|
||||||
|
CHECK(peakOf(ringOnly, kPastEnd) < 0.02); // the ramp alone is already well down
|
||||||
|
CHECK(peakOf(withSecond, kPastEnd) > 0.5); // the admitted note is at full amplitude
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mono LEGATO takes a voice over by retuning it — which moves the pitch of a dying ramp and
|
||||||
|
// produces nothing if the voice is already past its own end. A note-on during the ring-out
|
||||||
|
// must restart instead.
|
||||||
|
static void testMonoLegatoRestartsRatherThanRetunesDuringTheRingOut() {
|
||||||
|
const SampleData s = ringOutSample();
|
||||||
|
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512, VoiceMode::Mono,
|
||||||
|
MonoTrigger::Legato);
|
||||||
|
eng.noteOn(67, 127); // held: never released, so the second press is a legato takeover
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, kPastEnd);
|
||||||
|
CHECK(eng.activeVoiceCount() == 1); // precondition: still ringing out
|
||||||
|
eng.noteOn(72, 127);
|
||||||
|
eng.render(out, 400);
|
||||||
|
// A retune of the past-end voice re-enters the run-off branch every frame and emits only
|
||||||
|
// the decaying ramp; a restart plays the source from the top.
|
||||||
|
CHECK(peakOf(out, kPastEnd) > 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The read-head exhaustion path is NOT scoped to Trigger: a held Gate note whose source runs
|
||||||
|
// out with no sustain loop is cut at its sustain level, and under Preserve that cut lands on
|
||||||
|
// the same recycled synthetic tail the Trigger one-shot's does.
|
||||||
|
static void testGatePreserveRunOffRingsOutToo() {
|
||||||
|
SampleData s = ringOutSample();
|
||||||
|
s.play.playMode = PlayMode::Gate;
|
||||||
|
s.play.adsr.attackFrames = 0;
|
||||||
|
s.play.adsr.decayFrames = 0;
|
||||||
|
s.play.adsr.sustainLevel = 1.0; // held at full level when the source runs out
|
||||||
|
s.loop.hasLoop = false;
|
||||||
|
|
||||||
|
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512);
|
||||||
|
eng.noteOn(67, 127); // held; never released
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
std::size_t sounding = 0;
|
||||||
|
for (std::size_t f = 0; f < 8000; ++f) {
|
||||||
|
eng.render(out, 1);
|
||||||
|
if (eng.activeVoiceCount() == 0) break;
|
||||||
|
sounding = f + 1;
|
||||||
|
}
|
||||||
|
CHECK(sounding > kRingSpanFrames); // it rings past the source rather than stopping dead
|
||||||
|
// Same measurable bar as the Trigger tail: no step larger than the source waveform's own
|
||||||
|
// steepest slope. (The audible judgement is Daniel's; this is the proxy.)
|
||||||
|
double worst = 0.0;
|
||||||
|
const std::size_t end = std::min(sounding + 1, out.size());
|
||||||
|
for (std::size_t i = 1; i < end; ++i) {
|
||||||
|
worst = std::max(worst, std::fabs(static_cast<double>(out[i]) -
|
||||||
|
static_cast<double>(out[i - 1])));
|
||||||
|
}
|
||||||
|
CHECK(worst <= (0.8 * 2.0 * kPi / 40.0) * 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The pitch envelope's span domain ------------------------------------------
|
||||||
|
|
||||||
|
// The pitch envelope counts OUTPUT frames while the playable span is a SOURCE-frame count, so
|
||||||
|
// a transposed Varispeed voice must have its span converted. Measured through the read rate the
|
||||||
|
// envelope itself drives: at +12 semitones (ratio 2) with a full-span hold of -12 semitones the
|
||||||
|
// voice reads at unity while the envelope holds and at double speed after it, so the note's
|
||||||
|
// OUTPUT length is a direct readout of the envelope's span.
|
||||||
|
// +12 st, span/2 = 2000 output frames of hold: 2000 + 2000/2 = 3000 output frames
|
||||||
|
// +24 st, span/4 = 1000 output frames of hold: 1000 + 3000/4 = 1750 output frames
|
||||||
|
// The un-converted source-frame span holds for 4000 output frames in BOTH cases, so the note
|
||||||
|
// runs exactly 4000 at either transposition — the length stops tracking the ratio at all.
|
||||||
|
static void testPitchEnvelopeSpanIsOutputFramesUnderVarispeed() {
|
||||||
|
const auto soundingFrames = [](int note, double peakSemis) {
|
||||||
|
SampleData s = dcSample(4000);
|
||||||
|
s.play.playMode = PlayMode::Trigger;
|
||||||
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||||
|
s.play.trigger.lengthFraction = 1.0;
|
||||||
|
s.play.trigAhd.attackFrames = 0;
|
||||||
|
s.play.trigAhd.decayFrames = 0;
|
||||||
|
s.play.trigAhd.holdFraction = 1.0;
|
||||||
|
s.play.pitchEnv.enabled = true;
|
||||||
|
s.play.pitchEnv.peakSemitones = peakSemis; // cancels the transposition while it holds
|
||||||
|
s.play.pitchEnv.shape.attackFrames = 0;
|
||||||
|
s.play.pitchEnv.shape.decayFrames = 0;
|
||||||
|
s.play.pitchEnv.shape.holdFraction = 1.0;
|
||||||
|
|
||||||
|
VoiceEngine eng(1, s);
|
||||||
|
eng.noteOn(note, 127);
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
std::size_t sounding = 0;
|
||||||
|
for (std::size_t f = 0; f < 8000; ++f) {
|
||||||
|
eng.render(out, 1);
|
||||||
|
if (eng.activeVoiceCount() == 0) break;
|
||||||
|
sounding = f + 1;
|
||||||
|
}
|
||||||
|
return sounding;
|
||||||
|
};
|
||||||
|
CHECK(soundingFrames(72, -12.0) == 3000);
|
||||||
|
CHECK(soundingFrames(84, -24.0) == 1750);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Migration shape ----------------------------------------------------------
|
||||||
|
|
||||||
// A prior ZERO fade-out migrates to Decay = 0 and keeps the abrupt end the old controls could
|
// A prior ZERO fade-out migrates to Decay = 0 and keeps the abrupt end the old controls could
|
||||||
// express — nothing the retired mechanism could say is lost.
|
// express — nothing the retired mechanism could say is lost.
|
||||||
static void testZeroFadeOutMigratesToAnAbruptEnd() {
|
static void testZeroFadeOutMigratesToAnAbruptEnd() {
|
||||||
@@ -516,8 +628,12 @@ int main() {
|
|||||||
testTriggerPreserveEndsWithoutATerminalDiscontinuity();
|
testTriggerPreserveEndsWithoutATerminalDiscontinuity();
|
||||||
testTriggerPreserveAhdEndingEarlyAlsoRingsOut();
|
testTriggerPreserveAhdEndingEarlyAlsoRingsOut();
|
||||||
testVarispeedTailIsUntouched();
|
testVarispeedTailIsUntouched();
|
||||||
|
testPreserveCapAdmitsANewOnsetDuringTheRingOut();
|
||||||
|
testMonoLegatoRestartsRatherThanRetunesDuringTheRingOut();
|
||||||
|
testGatePreserveRunOffRingsOutToo();
|
||||||
|
|
||||||
|
testPitchEnvelopeSpanIsOutputFramesUnderVarispeed();
|
||||||
|
|
||||||
testMigratedFadeContourMatchesTheRetiredShapeWithinTheStatedBound();
|
|
||||||
testZeroFadeOutMigratesToAnAbruptEnd();
|
testZeroFadeOutMigratesToAnAbruptEnd();
|
||||||
|
|
||||||
if (g_fail == 0) {
|
if (g_fail == 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user