instrument: the filter's velocity depth knob returns and multiplies the bipolar curve; the pre-v12 lift is a pure domain re-tag
This commit is contained in:
@@ -262,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` — 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 depth for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve is BOTH the shape and the amount — there is no depth control behind it, which is why the filter's `velAmount` retired into it. eval's homogeneity in y (see `velocity_curve.h`) is what the codec's pre-v12 lift rests on.
|
- `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…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-INTERPRETS two frozen slots inside the v9 filter tail (its velocity curve is now bipolar and self-scaling; the retired velAmount slot carries a constant 1.0) — same bytes, version-keyed meaning, with the pre-v12 fold documented in `component_state_io.h`.
|
- `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.
|
||||||
|
|||||||
@@ -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{};
|
||||||
|
|||||||
@@ -110,21 +110,23 @@ struct PitchEnvParams {
|
|||||||
// normalized control positions verbatim rather than a parallel set, so no control range is
|
// normalized control positions verbatim rather than a parallel set, so no control range is
|
||||||
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
|
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
|
||||||
//
|
//
|
||||||
// The modulation depths below land in that same normalized cutoff domain and sum before a
|
// The three modulation depths below land in that same normalized cutoff domain and sum
|
||||||
// single clamp; all are zero/neutral by default.
|
// before a single clamp; all three are zero/neutral by default.
|
||||||
struct FilterParams {
|
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], 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
|
||||||
// Velocity -> cutoff, in the normalized cutoff domain. BIPOLAR, so the curve is both the
|
// Velocity -> cutoff, in the normalized cutoff domain. The contribution is
|
||||||
// shape and the amount — there is no separate depth knob behind it (the retired velAmount
|
// velAmount * velocityCurve.eval(velocity): the BIPOLAR curve carries the shape (and its
|
||||||
// was exactly that, and a signed depth multiplying a signed curve made the sign unreadable).
|
// own sign), the depth knob scales it, and BOTH apply. The curve is flat at 0 by default,
|
||||||
|
// so no depth setting produces velocity modulation until a curve is drawn.
|
||||||
VelocityCurve velocityCurve = VelocityCurve::zero();
|
VelocityCurve velocityCurve = VelocityCurve::zero();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +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 for linear()-style input. Homogeneous of degree 1
|
// makes the spline reproduce a straight line for linear()-style input.
|
||||||
// in the secants, which is what makes eval homogeneous in y (see the header).
|
|
||||||
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;
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ inline constexpr double kVelMax = 127.0;
|
|||||||
inline constexpr double kCurveYMax = 1.0;
|
inline constexpr double kCurveYMax = 1.0;
|
||||||
|
|
||||||
// The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing
|
// 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 depth — the pitch and filter
|
// 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
|
// domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A
|
||||||
// bipolar curve is therefore both the shape and the amount: there is no separate depth
|
// bipolar curve does not preclude a depth control beside it: the filter has one, and the two
|
||||||
// control behind it.
|
// compose multiplicatively (play_params.h).
|
||||||
enum class CurveDomain { Unipolar, Bipolar };
|
enum class CurveDomain { Unipolar, Bipolar };
|
||||||
|
|
||||||
constexpr double curveYMin(CurveDomain d) { return d == CurveDomain::Bipolar ? -1.0 : 0.0; }
|
constexpr double curveYMin(CurveDomain d) { return d == CurveDomain::Bipolar ? -1.0 : 0.0; }
|
||||||
@@ -46,11 +46,7 @@ inline constexpr int kCurveNodeGrabRadius = 6;
|
|||||||
// overshoots a segment's value range. For collinear knots the tangents reduce to the secant
|
// overshoots a segment's value range. For collinear knots the tangents reduce to the secant
|
||||||
// slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints
|
// slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints
|
||||||
// (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never
|
// (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never
|
||||||
// deletable. eval is HOMOGENEOUS in y — scaling every knot's value by k scales the whole curve
|
// deletable.
|
||||||
// by k TO WITHIN DOUBLE ROUNDING (the Hermite basis and the Fritsch-Carlson tangent are exactly
|
|
||||||
// degree-1 homogeneous in real arithmetic; `fl(k*b) - fl(k*a)` isn't bit-identical to
|
|
||||||
// `k*(b-a)`), which is what lets the codec (component_state_io.h's v12 pre-lift) fold a retired
|
|
||||||
// depth control into stored knots and still sound identical.
|
|
||||||
class VelocityCurve {
|
class VelocityCurve {
|
||||||
public:
|
public:
|
||||||
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
|
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
|
||||||
|
|||||||
@@ -131,7 +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_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
filterVelCurve_ = 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));
|
||||||
@@ -248,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) {
|
||||||
|
|||||||
@@ -602,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; // 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
|
||||||
|
|||||||
@@ -93,18 +93,16 @@ namespace reasampler::instrument::map {
|
|||||||
// the same shape as v7's), appended after the loop crossfade. Its y is a normalized fraction
|
// 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
|
// 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
|
// this frozen ladder, so retuning it re-tunes every saved v12 project's pitch-curve throw. It
|
||||||
// also RE-INTERPRETS two frozen slots inside the v9 filter tail — the byte shape is untouched,
|
// also RE-TAGS the DOMAIN of one frozen slot inside the v9 filter tail: that curve's y is read
|
||||||
// only the meaning at v12+:
|
// as BIPOLAR [-1,+1] from v12 on, having been UNIPOLAR [0,1] before. Every other filter slot,
|
||||||
// * the filter's velocity curve is now BIPOLAR [-1,+1] and is the whole velocity->cutoff
|
// velAmount included, keeps its meaning — the cutoff contribution is still
|
||||||
// amount, not a [0,1] shape scaled by a separate depth;
|
// velAmount * curve(velocity).
|
||||||
// * the retired filter velAmount slot is written as a constant 1.0 and ignored on read.
|
// PRE-v12 LIFT: a domain re-tag and nothing more. A pre-v12 curve's stored y values all lie in
|
||||||
// PRE-v12 LIFT: the stored [0,1] filter curve has every knot's y multiplied by that blob's
|
// [0,1], which is inside [-1,+1], so the widened box-clamp alters no knot and eval is unchanged
|
||||||
// velAmount and is re-read as bipolar. eval is homogeneous in y to within double rounding (see
|
// at every velocity — a pre-v12 project sounds identical without any rounding argument. A
|
||||||
// velocity_curve.h), so the lifted curve evaluates to velAmount * oldCurve(v) — the product the
|
// pre-v12 blob carries no pitch curve at all and lifts to the bipolar flat-at-zero default,
|
||||||
// voice used to compute per note — and a pre-v12 project sounds identical. A pre-v12 blob
|
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
|
||||||
// carries no pitch curve at all and lifts to the bipolar flat-at-zero default, which transposes
|
// drawn into the negative half comes back with that half clamped to 0.
|
||||||
// nothing. A DOWNGRADE to a pre-v12 binary reads the constant 1.0 depth against a curve whose
|
|
||||||
// negative half clamps away, so it reproduces the curve's positive half only.
|
|
||||||
//
|
//
|
||||||
// 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
|
||||||
@@ -156,9 +154,9 @@ 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, and the version from which the filter's velocity curve is
|
// v11 + the velocity->pitch curve; the appended tail branches on THIS, never on
|
||||||
// bipolar and self-scaling. Both the appended tail and the filter-tail lift branch on THIS,
|
// kParamsPayloadVersion. The filter curve's v12 domain re-tag needs no branch of its own — a
|
||||||
// never on kParamsPayloadVersion.
|
// pre-v12 curve's y values are already valid bipolar ones.
|
||||||
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
|
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
|
||||||
|
|||||||
@@ -93,12 +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`, interpreting its y values in `domain` and scaling
|
// Read a velocity curve tail into `curve`, interpreting its y values in `domain` — the domain
|
||||||
// them by `yScale` (the pre-v12 filter lift folds a retired depth in that way — see
|
// is not on the wire, it is a property of the slot. fromPoints repairs the X-order/endpoint
|
||||||
// component_state_io.h). fromPoints repairs the X-order/endpoint invariant defensively; a
|
// invariant defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||||
// truncated read leaves `curve` at whatever default it came in with.
|
|
||||||
void readCurveTail(ByteReader& r, VelocityCurve& curve,
|
void readCurveTail(ByteReader& r, VelocityCurve& curve,
|
||||||
reasampler::instrument::engine::CurveDomain domain, double yScale) {
|
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
|
||||||
@@ -108,7 +107,7 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve,
|
|||||||
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 value = bitsToDouble(r.u64());
|
const double value = bitsToDouble(r.u64());
|
||||||
pts.push_back(VelocityPoint{vel, value * yScale});
|
pts.push_back(VelocityPoint{vel, value});
|
||||||
}
|
}
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
|
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
|
||||||
@@ -116,10 +115,10 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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. `preVelocityVersion`
|
// which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
|
||||||
// selects the pre-v12 lift: the frozen velAmount slot is folded into the curve's knots instead
|
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
|
||||||
// of being kept as a separate depth.
|
// v12 domain re-tag needs no version branch (see component_state_io.h).
|
||||||
void readFilterTail(ByteReader& r, InstrumentParams& p, bool preVelocityVersion) {
|
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);
|
||||||
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
@@ -128,27 +127,20 @@ void readFilterTail(ByteReader& r, InstrumentParams& p, bool preVelocityVersion)
|
|||||||
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
||||||
: engine::filter::MorphLaw::HighBandLow;
|
: engine::filter::MorphLaw::HighBandLow;
|
||||||
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these reach
|
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
|
||||||
// Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
|
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
|
||||||
double modAmount = bitsToDouble(r.u64());
|
double modAmount = bitsToDouble(r.u64());
|
||||||
double velAmount = bitsToDouble(r.u64());
|
double velAmount = bitsToDouble(r.u64());
|
||||||
double keyTrack = bitsToDouble(r.u64());
|
double keyTrack = bitsToDouble(r.u64());
|
||||||
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
||||||
|
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
|
||||||
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
||||||
f.env.attackSeconds = bitsToDouble(r.u64());
|
f.env.attackSeconds = bitsToDouble(r.u64());
|
||||||
f.env.holdSeconds = bitsToDouble(r.u64());
|
f.env.holdSeconds = bitsToDouble(r.u64());
|
||||||
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());
|
||||||
// velAmount feeds a MULTIPLIER on the stored curve's knots (below), not a param the engine
|
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
// clamps on its own — the UI never dials it outside [-1,1] (deckBipolarFromNorm), so a
|
|
||||||
// corrupt-but-finite blob value outside that range must clamp here rather than silently
|
|
||||||
// scaling the lifted curve past what fromPoints' own [-1,1] box-clamp would then truncate.
|
|
||||||
const double velFold =
|
|
||||||
preVelocityVersion ? std::clamp(std::isfinite(velAmount) ? velAmount : 0.0, -1.0, 1.0)
|
|
||||||
: 1.0;
|
|
||||||
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar,
|
|
||||||
velFold);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -255,7 +247,7 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
|
|||||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||||
if (curveTail) {
|
if (curveTail) {
|
||||||
readCurveTail(r, p.velocityCurve,
|
readCurveTail(r, p.velocityCurve,
|
||||||
reasampler::instrument::engine::CurveDomain::Unipolar, 1.0);
|
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.
|
||||||
@@ -315,9 +307,7 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
|
|||||||
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
||||||
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
||||||
putLE(out, doubleToBits(f.modAmount));
|
putLE(out, doubleToBits(f.modAmount));
|
||||||
// The retired filter velAmount's frozen slot: a constant 1.0 so a pre-v12 binary reading
|
putLE(out, doubleToBits(f.velAmount));
|
||||||
// this blob scales the curve by unity rather than silencing it (see component_state_io.h).
|
|
||||||
putLE(out, doubleToBits(1.0));
|
|
||||||
putLE(out, doubleToBits(f.keyTrack));
|
putLE(out, doubleToBits(f.keyTrack));
|
||||||
putLE(out, doubleToBits(f.env.attackSeconds));
|
putLE(out, doubleToBits(f.env.attackSeconds));
|
||||||
putLE(out, doubleToBits(f.env.holdSeconds));
|
putLE(out, doubleToBits(f.env.holdSeconds));
|
||||||
@@ -370,10 +360,8 @@ 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, reasampler::instrument::engine::CurveDomain::Unipolar, 1.0);
|
readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||||
if (pv >= kParamsFilterVersion) {
|
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||||
readFilterTail(r, p, /*preVelocityVersion=*/pv < kParamsVelocityVersion);
|
|
||||||
}
|
|
||||||
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
|
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
|
||||||
if (pv >= kParamsLoopVersion) {
|
if (pv >= kParamsLoopVersion) {
|
||||||
// A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing
|
// A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing
|
||||||
@@ -383,7 +371,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
|||||||
}
|
}
|
||||||
if (pv >= kParamsVelocityVersion) {
|
if (pv >= kParamsVelocityVersion) {
|
||||||
readCurveTail(r, p.play.pitchVelocityCurve,
|
readCurveTail(r, p.play.pitchVelocityCurve,
|
||||||
reasampler::instrument::engine::CurveDomain::Bipolar, 1.0);
|
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.
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
|||||||
out.filter.enabled = stored.filter.enabled;
|
out.filter.enabled = stored.filter.enabled;
|
||||||
out.filter.settings = stored.filter.settings;
|
out.filter.settings = stored.filter.settings;
|
||||||
out.filter.modAmount = stored.filter.modAmount;
|
out.filter.modAmount = stored.filter.modAmount;
|
||||||
|
out.filter.velAmount = stored.filter.velAmount;
|
||||||
out.filter.keyTrack = stored.filter.keyTrack;
|
out.filter.keyTrack = stored.filter.keyTrack;
|
||||||
out.filter.velocityCurve = stored.filter.velocityCurve;
|
out.filter.velocityCurve = stored.filter.velocityCurve;
|
||||||
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
|
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ struct FilterSeconds {
|
|||||||
bool enabled = false;
|
bool enabled = false;
|
||||||
engine::filter::FilterSettings settings;
|
engine::filter::FilterSettings settings;
|
||||||
double modAmount = 0.0;
|
double modAmount = 0.0;
|
||||||
|
double velAmount = 0.0;
|
||||||
double keyTrack = 0.0;
|
double keyTrack = 0.0;
|
||||||
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
|
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
|
||||||
AhdSeconds trigEnv; // Trigger
|
AhdSeconds trigEnv; // Trigger
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
|||||||
id(DeckParam::kFilterQ),
|
id(DeckParam::kFilterQ),
|
||||||
id(DeckParam::kFilterDrive),
|
id(DeckParam::kFilterDrive),
|
||||||
id(DeckParam::kFilterModAmt),
|
id(DeckParam::kFilterModAmt),
|
||||||
|
id(DeckParam::kFilterVel),
|
||||||
id(DeckParam::kFilterKeyTrack)};
|
id(DeckParam::kFilterKeyTrack)};
|
||||||
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
|
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
|
||||||
out.push_back(std::move(filter));
|
out.push_back(std::move(filter));
|
||||||
@@ -164,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:
|
||||||
@@ -250,6 +252,7 @@ bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
|
|||||||
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:
|
||||||
// The filter's velocity curve sits in the VELOCITY group but is a filter parameter:
|
// 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.
|
// it goes inert with every other one, so no surface can reach a param the knobs can't.
|
||||||
|
|||||||
@@ -40,6 +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 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)
|
||||||
@@ -128,7 +129,9 @@ DeckParam curveParamFor(DeckParam knob);
|
|||||||
// the decoded PCM;
|
// the decoded PCM;
|
||||||
// - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by
|
// - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by
|
||||||
// design (the pitch ratio and the curve results), so live delivery would retune or re-gain
|
// design (the pitch ratio and the curve results), so live delivery would retune or re-gain
|
||||||
// a note 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
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
|
|||||||
case ParamControl::kFilterQ: return clamp01(play.filter.settings.resonanceNorm);
|
case ParamControl::kFilterQ: return clamp01(play.filter.settings.resonanceNorm);
|
||||||
case ParamControl::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
|
case ParamControl::kFilterDrive: return clamp01(play.filter.settings.driveNorm);
|
||||||
case ParamControl::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
case ParamControl::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount);
|
||||||
|
case ParamControl::kFilterVel: return deckNormFromBipolar(play.filter.velAmount);
|
||||||
case ParamControl::kFilterKeyTrack:return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
case ParamControl::kFilterKeyTrack:return clamp01(play.filter.keyTrack / kKeyTrackMax);
|
||||||
case ParamControl::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
|
case ParamControl::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds);
|
||||||
case ParamControl::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
|
case ParamControl::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds);
|
||||||
@@ -217,6 +218,7 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
|||||||
case ParamControl::kFilterDrive:
|
case ParamControl::kFilterDrive:
|
||||||
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
|
play.filter.settings.driveNorm = static_cast<float>(clamp01(value)); break;
|
||||||
case ParamControl::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
|
case ParamControl::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break;
|
||||||
|
case ParamControl::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break;
|
||||||
case ParamControl::kFilterKeyTrack:
|
case ParamControl::kFilterKeyTrack:
|
||||||
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break;
|
||||||
case ParamControl::kFilterEnvAttack:
|
case ParamControl::kFilterEnvAttack:
|
||||||
@@ -364,6 +366,8 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
|||||||
break;
|
break;
|
||||||
case ParamControl::kFilterModAmt:
|
case ParamControl::kFilterModAmt:
|
||||||
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break;
|
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break;
|
||||||
|
case ParamControl::kFilterVel:
|
||||||
|
snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break;
|
||||||
case ParamControl::kFilterKeyTrack:
|
case ParamControl::kFilterKeyTrack:
|
||||||
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break;
|
snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break;
|
||||||
case ParamControl::kFilterEnvAttack:
|
case ParamControl::kFilterEnvAttack:
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
|||||||
case ParamControl::kFilterQ: return "Res";
|
case ParamControl::kFilterQ: return "Res";
|
||||||
case ParamControl::kFilterDrive: return "Drive";
|
case ParamControl::kFilterDrive: return "Drive";
|
||||||
case ParamControl::kFilterModAmt: return "Mod";
|
case ParamControl::kFilterModAmt: return "Mod";
|
||||||
|
case ParamControl::kFilterVel: return "Vel";
|
||||||
case ParamControl::kFilterKeyTrack: return "Key Trk";
|
case ParamControl::kFilterKeyTrack: return "Key Trk";
|
||||||
case ParamControl::kAmpVelCurve: return "Amp";
|
case ParamControl::kAmpVelCurve: return "Amp";
|
||||||
case ParamControl::kPitchVelCurve: return "Pitch";
|
case ParamControl::kPitchVelCurve: return "Pitch";
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
#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 <algorithm>
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -475,7 +474,7 @@ static void testGoldenFullBlobFixture() {
|
|||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0
|
||||||
0x00, // morphLaw = HighBandLow
|
0x00, // morphLaw = HighBandLow
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // velAmount slot: frozen constant 1.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velAmount 0.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0
|
||||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0
|
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0
|
||||||
@@ -638,6 +637,7 @@ 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.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;
|
||||||
@@ -662,6 +662,7 @@ static void testFilterTailRoundTripsLosslessly() {
|
|||||||
CHECK(g.settings.driveNorm == f.settings.driveNorm);
|
CHECK(g.settings.driveNorm == f.settings.driveNorm);
|
||||||
CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
|
||||||
CHECK(g.modAmount == f.modAmount);
|
CHECK(g.modAmount == f.modAmount);
|
||||||
|
CHECK(g.velAmount == f.velAmount);
|
||||||
CHECK(g.keyTrack == f.keyTrack);
|
CHECK(g.keyTrack == f.keyTrack);
|
||||||
CHECK(g.env.attackSeconds == f.env.attackSeconds);
|
CHECK(g.env.attackSeconds == f.env.attackSeconds);
|
||||||
CHECK(g.env.holdSeconds == f.env.holdSeconds);
|
CHECK(g.env.holdSeconds == f.env.holdSeconds);
|
||||||
@@ -714,6 +715,7 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
|
|||||||
FilterSeconds& f = in.params.play.filter;
|
FilterSeconds& f = in.params.play.filter;
|
||||||
f.enabled = true;
|
f.enabled = true;
|
||||||
f.modAmount = std::numeric_limits<double>::quiet_NaN();
|
f.modAmount = std::numeric_limits<double>::quiet_NaN();
|
||||||
|
f.velAmount = std::numeric_limits<double>::infinity();
|
||||||
f.keyTrack = -std::numeric_limits<double>::infinity();
|
f.keyTrack = -std::numeric_limits<double>::infinity();
|
||||||
|
|
||||||
const ComponentState out =
|
const ComponentState out =
|
||||||
@@ -721,6 +723,7 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
|
|||||||
const FilterSeconds& g = out.params.play.filter;
|
const FilterSeconds& g = out.params.play.filter;
|
||||||
const FilterSeconds def;
|
const FilterSeconds def;
|
||||||
CHECK(g.modAmount == def.modAmount);
|
CHECK(g.modAmount == def.modAmount);
|
||||||
|
CHECK(g.velAmount == def.velAmount);
|
||||||
CHECK(g.keyTrack == def.keyTrack);
|
CHECK(g.keyTrack == def.keyTrack);
|
||||||
// The fallback is per-field, not per-record: the untouched fields still round-trip.
|
// The fallback is per-field, not per-record: the untouched fields still round-trip.
|
||||||
CHECK(g.enabled);
|
CHECK(g.enabled);
|
||||||
@@ -815,25 +818,16 @@ static std::vector<std::uint8_t> payloadDowngradedTo(const ComponentState& state
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overwrite the frozen filter velAmount slot: the writer emits a constant 1.0 there now, so a
|
// Every knot of `lifted` equals `stored`'s BIT for bit — the whole claim of a domain re-tag,
|
||||||
// pre-v12 fixture has to plant its own depth. Located by the DISTINCT modAmount immediately
|
// which is why this compares with == rather than a tolerance.
|
||||||
// preceding it rather than by a byte offset, so a tail growing ahead of it cannot rot this.
|
static bool knotsAreIdentical(const reasampler::instrument::engine::VelocityCurve& lifted,
|
||||||
static void plantPreV12FilterDepth(std::vector<std::uint8_t>& bytes, double modAmount,
|
const reasampler::instrument::engine::VelocityCurve& stored) {
|
||||||
double velAmount) {
|
if (lifted.size() != stored.size()) return false;
|
||||||
std::vector<std::uint8_t> needle;
|
for (std::size_t i = 0; i < lifted.size(); ++i) {
|
||||||
legacy::f64v(needle, modAmount);
|
if (lifted.points()[i].velocity != stored.points()[i].velocity) return false;
|
||||||
std::size_t at = 0;
|
if (lifted.points()[i].value != stored.points()[i].value) return false;
|
||||||
int hits = 0;
|
|
||||||
for (std::size_t i = 0; i + 2 * needle.size() <= bytes.size(); ++i) {
|
|
||||||
if (std::equal(needle.begin(), needle.end(), bytes.begin() + static_cast<long>(i))) {
|
|
||||||
at = i;
|
|
||||||
++hits;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
CHECK(hits == 1); // an ambiguous anchor would plant the depth in the wrong slot
|
return true;
|
||||||
std::vector<std::uint8_t> depth;
|
|
||||||
legacy::f64v(depth, velAmount);
|
|
||||||
for (std::size_t k = 0; k < depth.size(); ++k) bytes[at + needle.size() + k] = depth[k];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A project saved before this change reopens sounding identical: its loop span still applies,
|
// A project saved before this change reopens sounding identical: its loop span still applies,
|
||||||
@@ -859,17 +853,18 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
|||||||
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
{VelocityPoint{0.0, 0.5}, VelocityPoint{127.0, 1.0}},
|
{VelocityPoint{0.0, 0.5}, VelocityPoint{127.0, 1.0}},
|
||||||
reasampler::instrument::engine::CurveDomain::Bipolar);
|
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
// The filter's pre-v12 depth fold (see testPreV12FilterVelocityDepthFoldsIntoTheCurve for
|
// The filter's velocity pair, so EVERY version that carries a filter tail (v9..v11) walks
|
||||||
// the single-version proof); planted here too so EVERY version that carries a filter tail
|
// the v12 domain re-tag, not just v11 (see testPreV12FilterVelocityLiftsAsAPureDomainReTag
|
||||||
// (v9..v11) is walked, not just v11 — the fold's branch condition is pv < kParamsVelocityVersion.
|
// 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.enabled = true;
|
||||||
in.params.play.filter.modAmount = -0.6251953125; // distinct, exactly representable anchor
|
in.params.play.filter.modAmount = -0.6251953125;
|
||||||
|
in.params.play.filter.velAmount = -0.75;
|
||||||
const reasampler::instrument::engine::VelocityCurve filterShape =
|
const reasampler::instrument::engine::VelocityCurve filterShape =
|
||||||
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
{VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}},
|
{VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}},
|
||||||
reasampler::instrument::engine::CurveDomain::Bipolar);
|
reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
in.params.play.filter.velocityCurve = filterShape;
|
in.params.play.filter.velocityCurve = filterShape;
|
||||||
constexpr double kPlantedDepth = -0.75;
|
|
||||||
|
|
||||||
struct Case {
|
struct Case {
|
||||||
std::uint32_t pv;
|
std::uint32_t pv;
|
||||||
@@ -884,10 +879,7 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
|||||||
{8, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false, false},
|
{8, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false, false},
|
||||||
};
|
};
|
||||||
for (const Case& c : cases) {
|
for (const Case& c : cases) {
|
||||||
std::vector<std::uint8_t> bytes = payloadDowngradedTo(in, c.pv, c.cut);
|
const std::vector<std::uint8_t> bytes = payloadDowngradedTo(in, c.pv, c.cut);
|
||||||
if (c.keepsFilterTail) {
|
|
||||||
plantPreV12FilterDepth(bytes, in.params.play.filter.modAmount, kPlantedDepth);
|
|
||||||
}
|
|
||||||
const ComponentState out = deserializeComponentState(bytes, 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);
|
||||||
@@ -904,16 +896,22 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
|||||||
// 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 own velocity curve: folded by the planted depth where the tail survives
|
// The filter's velocity pair where the tail survives (v9..v11): the depth carries
|
||||||
// (v9..v11), the off/neutral default where it was cut away entirely (v8).
|
// 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) {
|
if (c.keepsFilterTail) {
|
||||||
CHECK(out.params.play.filter.enabled);
|
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) {
|
for (int v = 0; v <= 127; ++v) {
|
||||||
CHECK(std::fabs(out.params.play.filter.velocityCurve.eval(v) -
|
CHECK(out.params.play.filter.velAmount *
|
||||||
kPlantedDepth * filterShape.eval(v)) < 1e-12);
|
out.params.play.filter.velocityCurve.eval(v) ==
|
||||||
|
in.params.play.filter.velAmount * filterShape.eval(v));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CHECK(!out.params.play.filter.enabled);
|
CHECK(!out.params.play.filter.enabled);
|
||||||
|
CHECK(out.params.play.filter.velAmount == 0.0);
|
||||||
for (int v = 0; v <= 127; ++v) {
|
for (int v = 0; v <= 127; ++v) {
|
||||||
CHECK(out.params.play.filter.velocityCurve.eval(v) == 0.0);
|
CHECK(out.params.play.filter.velocityCurve.eval(v) == 0.0);
|
||||||
}
|
}
|
||||||
@@ -921,44 +919,59 @@ static void testPriorPayloadVersionsLiftToAHardSeam() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sharp edge of the bipolar change: a pre-v12 blob stored the filter's velocity response
|
// The sharp edge of the bipolar change: v12 widened the filter curve's y domain, and the lift
|
||||||
// as a [0,1] SHAPE times a separate depth, and the lift folds that depth into the knots. The
|
// is a pure DOMAIN RE-TAG — no rescaling, no rounding. A pre-v12 curve's y values all lie in
|
||||||
// lifted curve must evaluate to exactly the product the pre-change voice computed.
|
// [0,1], which is inside [-1,+1], so every knot must come back bit-identical, the depth beside
|
||||||
static void testPreV12FilterVelocityDepthFoldsIntoTheCurve() {
|
// it untouched, and the cutoff contribution equal to the pre-change product at every velocity.
|
||||||
ComponentState in;
|
static void testPreV12FilterVelocityLiftsAsAPureDomainReTag() {
|
||||||
in.selectionId = "pad";
|
// A shape confined to [0,1] — what a pre-v12 unipolar curve could actually store. The
|
||||||
FilterSeconds& f = in.params.play.filter;
|
// reference reads the SAME knots through the old domain, so the comparison is against what
|
||||||
f.enabled = true;
|
// the pre-change reader built, not against another read of the new one.
|
||||||
f.modAmount = -0.6251953125; // distinct and exactly representable: the planting anchor
|
const std::vector<VelocityPoint> knots = {VelocityPoint{0.0, 0.0}, VelocityPoint{40.0, 0.125},
|
||||||
// What an old blob's curve looked like: a shape confined to [0,1], with the sign and the
|
VelocityPoint{64.0, 0.75}, VelocityPoint{127.0, 1.0}};
|
||||||
// amount living in the depth beside it.
|
const reasampler::instrument::engine::VelocityCurve asStored =
|
||||||
const reasampler::instrument::engine::VelocityCurve shape =
|
|
||||||
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||||
{VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}},
|
knots, reasampler::instrument::engine::CurveDomain::Unipolar);
|
||||||
reasampler::instrument::engine::CurveDomain::Bipolar);
|
|
||||||
f.velocityCurve = shape;
|
|
||||||
|
|
||||||
for (const double depth : {-0.75, 0.5, 0.0}) {
|
for (const double depth : {-0.75, 0.5, 0.0}) {
|
||||||
std::vector<std::uint8_t> bytes =
|
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);
|
payloadDowngradedTo(in, kParamsLoopVersion, kVelocityTailBytes);
|
||||||
plantPreV12FilterDepth(bytes, f.modAmount, depth);
|
|
||||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||||
const reasampler::instrument::engine::VelocityCurve& lifted =
|
const reasampler::instrument::engine::VelocityCurve& lifted =
|
||||||
out.params.play.filter.velocityCurve;
|
out.params.play.filter.velocityCurve;
|
||||||
|
|
||||||
CHECK(lifted.domain() == reasampler::instrument::engine::CurveDomain::Bipolar);
|
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) {
|
for (int v = 0; v <= 127; ++v) {
|
||||||
CHECK(std::fabs(lifted.eval(v) - depth * shape.eval(v)) < 1e-12);
|
CHECK(out.params.play.filter.velAmount * lifted.eval(v) == depth * asStored.eval(v));
|
||||||
}
|
}
|
||||||
CHECK(out.params.play.filter.modAmount == f.modAmount); // the anchor itself survives
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// At v12 the same slot is ignored: the curve is read verbatim, whatever sits in it.
|
// The v12 blob of the same state reads back the same way — the re-tag is what the reader
|
||||||
std::vector<std::uint8_t> current = serializeComponentState(in);
|
// does at EVERY version, so the pre-v12 and current paths cannot diverge.
|
||||||
plantPreV12FilterDepth(current, f.modAmount, 0.0);
|
ComponentState now;
|
||||||
const ComponentState now = deserializeComponentState(current, 48000.0);
|
now.selectionId = "pad";
|
||||||
for (int v = 0; v <= 127; ++v) {
|
now.params.play.filter.enabled = true;
|
||||||
CHECK(std::fabs(now.params.play.filter.velocityCurve.eval(v) - shape.eval(v)) < 1e-12);
|
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
|
||||||
@@ -1534,7 +1547,7 @@ int main() {
|
|||||||
testLoopSpanAndCrossfadeRoundTrip();
|
testLoopSpanAndCrossfadeRoundTrip();
|
||||||
testNegativeCrossfadeOnTheWireLiftsToZero();
|
testNegativeCrossfadeOnTheWireLiftsToZero();
|
||||||
testPriorPayloadVersionsLiftToAHardSeam();
|
testPriorPayloadVersionsLiftToAHardSeam();
|
||||||
testPreV12FilterVelocityDepthFoldsIntoTheCurve();
|
testPreV12FilterVelocityLiftsAsAPureDomainReTag();
|
||||||
testWriterEmitsCurrentPayloadVersion();
|
testWriterEmitsCurrentPayloadVersion();
|
||||||
testSingleZoneMigrationIsLossless();
|
testSingleZoneMigrationIsLossless();
|
||||||
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
|
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ static void testFilterGroupCarriesItsToneControlsPlusModulation() {
|
|||||||
const std::vector<int> expected = {
|
const std::vector<int> expected = {
|
||||||
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
|
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
|
||||||
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
|
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
|
||||||
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterKeyTrack)};
|
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
|
||||||
|
cell(DeckParam::kFilterKeyTrack)};
|
||||||
CHECK(f.cellIds == expected);
|
CHECK(f.cellIds == expected);
|
||||||
// Off by default is a state question, but reachability is a layout one: the enable
|
// Off by default is a state question, but reachability is a layout one: the enable
|
||||||
// toggle is in the caption row and the morph law in the knob row.
|
// toggle is in the caption row and the morph law in the knob row.
|
||||||
@@ -237,9 +238,10 @@ 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 three rows: PITCH + PITCH ENV + FILTER fill
|
// At the floor (== default) 840 the deck takes three rows: PITCH + PITCH ENV + FILTER fill
|
||||||
// the first, FILTER ENV + AMP + VELOCITY the second, VOICE + MASTER the third. The eight
|
// the first (818 of the 824 available — six px of headroom, so one more FILTER cell would
|
||||||
// groups total more than two rows can hold at this width — the VELOCITY group's three
|
// wrap the group and reflow everything under it), FILTER ENV + AMP + VELOCITY the second,
|
||||||
// cells are ~150 px more than the FILTER group gave back when its velocity depth retired.
|
// 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(deckRowCount(g, kAvailAtMinWidth) == 3);
|
||||||
CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap);
|
CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap);
|
||||||
|
|
||||||
@@ -285,7 +287,7 @@ static void testHitTestResolvesTheNewFilterControls() {
|
|||||||
CHECK(hit.kind == DeckHitKind::Knob);
|
CHECK(hit.kind == DeckHitKind::Knob);
|
||||||
CHECK(hit.id == c.id);
|
CHECK(hit.id == c.id);
|
||||||
}
|
}
|
||||||
CHECK(f.cells.size() == 6);
|
CHECK(f.cells.size() == 7);
|
||||||
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
|
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
|
||||||
|
|
||||||
// The enable toggle's two segments and the morph-law row toggle's two.
|
// The enable toggle's two segments and the morph-law row toggle's two.
|
||||||
@@ -333,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -638,6 +638,7 @@ 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.375; // distinct from every neighbouring field, so a mis-wire shows
|
||||||
st.filter.velocityCurve = VelocityCurve::fromPoints(
|
st.filter.velocityCurve = VelocityCurve::fromPoints(
|
||||||
{{0.0, 0.0}, {127.0, 0.25}}, reasampler::instrument::engine::CurveDomain::Bipolar);
|
{{0.0, 0.0}, {127.0, 0.25}}, reasampler::instrument::engine::CurveDomain::Bipolar);
|
||||||
st.filter.keyTrack = 1.25;
|
st.filter.keyTrack = 1.25;
|
||||||
@@ -655,6 +656,7 @@ 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.375);
|
||||||
// The transfer curve is dimensionless, so it crosses unchanged — asserted against the
|
// The transfer curve is dimensionless, so it crosses unchanged — asserted against the
|
||||||
// straight line the two stored knots describe, not against the stored object.
|
// straight line the two stored knots describe, not against the stored object.
|
||||||
CHECK(at48.filter.velocityCurve.eval(0.0) == 0.0);
|
CHECK(at48.filter.velocityCurve.eval(0.0) == 0.0);
|
||||||
@@ -677,6 +679,7 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
|
|||||||
const PlayParams bare = resolvePlay(PlaySeconds{}, 48000);
|
const PlayParams bare = resolvePlay(PlaySeconds{}, 48000);
|
||||||
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);
|
||||||
for (int v = 0; v <= 127; ++v) CHECK(bare.filter.velocityCurve.eval(v) == 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);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() {
|
|||||||
stored.play.filter.enabled = false;
|
stored.play.filter.enabled = false;
|
||||||
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.velocityCurve = VelocityCurve::fromPoints(
|
stored.play.filter.velocityCurve = VelocityCurve::fromPoints(
|
||||||
{{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
|
{{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar);
|
||||||
stored.play.filter.keyTrack = 2.0;
|
stored.play.filter.keyTrack = 2.0;
|
||||||
@@ -330,11 +331,12 @@ 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 velTop, 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;
|
||||||
if (velTop != 0.0) {
|
if (velTop != 0.0) {
|
||||||
s.play.filter.velocityCurve = VelocityCurve::fromPoints(
|
s.play.filter.velocityCurve = VelocityCurve::fromPoints(
|
||||||
{{0.0, 0.0}, {127.0, velTop}}, instrument::engine::CurveDomain::Bipolar);
|
{{0.0, 0.0}, {127.0, velTop}}, instrument::engine::CurveDomain::Bipolar);
|
||||||
@@ -343,34 +345,75 @@ static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
|
|||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Velocity: a curve rising to +1 opens the filter for a hard hit. The amp's own velocity
|
// Velocity: a curve rising to +1 at full depth opens the filter for a hard hit. The amp's
|
||||||
// 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. `tone(0,0)` leaves the
|
// Both neutral: neither velocity nor note may move the filter. `tone(_,0,0)` leaves the
|
||||||
// DEFAULT velocity curve, so this is the off-by-default contract itself.
|
// DEFAULT velocity curve, so this is the off-by-default contract itself — and it holds at
|
||||||
SampleData neutral = tone(0.0, 0.0);
|
// EVERY depth setting, since the curve is what says "nothing", not the knob.
|
||||||
const std::vector<double> a = render(neutral, 60, 1, 6000);
|
for (const double depth : {-1.0, -0.5, 0.0, 0.5, 1.0}) {
|
||||||
const std::vector<double> b = render(neutral, 60, 127, 6000);
|
SampleData neutral = tone(depth, 0.0, 0.0);
|
||||||
const std::vector<double> c = render(neutral, 84, 100, 6000);
|
const std::vector<double> a = render(neutral, 60, 1, 6000);
|
||||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
const std::vector<double> b = render(neutral, 60, 127, 6000);
|
||||||
CHECK(a[i] == b[i]);
|
const std::vector<double> c = render(neutral, 84, 100, 6000);
|
||||||
CHECK(a[i] == c[i]);
|
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);
|
||||||
}
|
}
|
||||||
// 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.
|
// The depth knob and the bipolar curve BOTH apply, as a product: flipping the depth's sign is
|
||||||
const FilterParams def;
|
// the same modulation as flipping the curve's, and it flips the audible sense with it.
|
||||||
for (int v = 0; v <= 127; ++v) CHECK(def.velocityCurve.eval(v) == 0.0);
|
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() {
|
||||||
@@ -442,6 +485,7 @@ int main() {
|
|||||||
testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice();
|
testAModulationTooSmallToCrossTheRetiredQuantumStillMovesTheVoice();
|
||||||
testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter();
|
testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter();
|
||||||
testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
|
testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
|
||||||
|
testFilterVelocityDepthAndCurveComposeMultiplicatively();
|
||||||
testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
|
testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
|
||||||
testStereoRenderOfAMonoSampleMirrorsTheMonoResultExactly();
|
testStereoRenderOfAMonoSampleMirrorsTheMonoResultExactly();
|
||||||
testStereoFilterChannel1MatchesChannel0ForIdenticalLRInput();
|
testStereoFilterChannel1MatchesChannel0ForIdenticalLRInput();
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// * eval — flat y=1 unipolar default (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
|
// * the BIPOLAR domain — zero() is exactly 0 everywhere, the negative half evaluates and clamps
|
||||||
// at -1, eval is homogeneous in y (what the pre-v12 filter lift rests on), and the pixel maps
|
// at -1, knots inside [0,1] evaluate identically in either domain (what the codec's v12
|
||||||
// put value 0 on the box's centre line rather than its floor.
|
// 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 the value; endpoints are X-pinned (velocity 0 / 127)
|
// neighbours (can't cross) and box-clamps the value; endpoints are X-pinned (velocity 0 / 127)
|
||||||
// with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints.
|
// with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints.
|
||||||
@@ -333,19 +333,16 @@ static void testUnipolarClampsAtZeroWhereBipolarDoesNot() {
|
|||||||
CHECK(near(b2.eval(127), 1.0));
|
CHECK(near(b2.eval(127), 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testEvalIsHomogeneousInY() {
|
static void testTheSameKnotsEvaluateIdenticallyInEitherDomain() {
|
||||||
// The property the pre-v12 filter lift rests on: scaling every knot's y by k scales the
|
// What the codec's v12 domain re-tag rests on: a curve whose y values all lie in [0,1] is
|
||||||
// whole evaluated curve by k. Asserted against a CURVED (non-collinear) knot set, where
|
// read the same way in either domain — the domain governs the CLAMP, not the evaluation.
|
||||||
// the Fritsch-Carlson tangents are actually doing work.
|
// 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 = {
|
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}};
|
{0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}};
|
||||||
const VelocityCurve base = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
|
const VelocityCurve u = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar);
|
||||||
for (const double k : {0.75, -0.4, 1.0}) {
|
const VelocityCurve b = VelocityCurve::fromPoints(knots, CurveDomain::Bipolar);
|
||||||
std::vector<VelocityPoint> scaled = knots;
|
for (int v = 0; v <= 127; ++v) CHECK(b.eval(v) == u.eval(v));
|
||||||
for (VelocityPoint& p : scaled) p.value *= k;
|
|
||||||
const VelocityCurve s = VelocityCurve::fromPoints(scaled, CurveDomain::Bipolar);
|
|
||||||
for (int v = 0; v <= 127; ++v) CHECK(near(s.eval(v), k * base.eval(v), 1e-12));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testBipolarPixelMapPutsZeroOnTheCentreLine() {
|
static void testBipolarPixelMapPutsZeroOnTheCentreLine() {
|
||||||
@@ -471,7 +468,7 @@ int main() {
|
|||||||
testZeroIsExactlyZeroAtEveryVelocity();
|
testZeroIsExactlyZeroAtEveryVelocity();
|
||||||
testBipolarEvalSpansTheNegativeHalf();
|
testBipolarEvalSpansTheNegativeHalf();
|
||||||
testUnipolarClampsAtZeroWhereBipolarDoesNot();
|
testUnipolarClampsAtZeroWhereBipolarDoesNot();
|
||||||
testEvalIsHomogeneousInY();
|
testTheSameKnotsEvaluateIdenticallyInEitherDomain();
|
||||||
testBipolarPixelMapPutsZeroOnTheCentreLine();
|
testBipolarPixelMapPutsZeroOnTheCentreLine();
|
||||||
testBipolarDragCoversTwiceTheValueRange();
|
testBipolarDragCoversTwiceTheValueRange();
|
||||||
testPixelFromPointMapsCornersAndMidpoint();
|
testPixelFromPointMapsCornersAndMidpoint();
|
||||||
|
|||||||
Reference in New Issue
Block a user