diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 5f07b99..3c671ad 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -216,6 +216,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // Effective root: override beats bank intrinsic beats middle-C default. rz.rootNote = z.rootOverride ? *z.rootOverride : (found->rootNote ? *found->rootNote : 60); + // S-VIEW-6: the key-tracking scalar is instrument state (not a bank fact) — carried + // straight through to the resolved zone and applied in the repitch math at play time. + rz.keyTrack = z.keyTrack; // Effective loop / start (S11): the instrument's per-zone override wins over the // bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is // never mutated — this only shapes what the core plays for THIS instance (D-B). @@ -260,6 +263,7 @@ Keymap buildZonedKeymap(const std::vector& zones, zone.lowNote = zones[i].lowNote; zone.highNote = zones[i].highNote; zone.rootNote = zones[i].rootNote; + zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time zone.sampleIndex = sampleIndex; km.zones.push_back(zone); } @@ -403,6 +407,8 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putU64le(out, doubleToBits(pp.adsr.decaySeconds)); putU64le(out, doubleToBits(pp.adsr.sustainLevel)); putU64le(out, doubleToBits(pp.adsr.releaseSeconds)); + // PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar, appended last (1.0 = 100% ET). + putU64le(out, doubleToBits(z.keyTrack)); } } @@ -423,7 +429,8 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { extended = (pv >= 2); // v2+ carries the loop/start tail } const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames - const bool secondsPlay = (pv >= 5); // current: full play params, wall-clock in seconds + const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds + const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar appended last const std::uint32_t count = r.u32(); for (std::uint32_t i = 0; i < count && r.ok; ++i) { // z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A @@ -482,6 +489,10 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { z.play.adsr.sustainLevel = bitsToDouble(r.u64()); z.play.adsr.releaseSeconds = bitsToDouble(r.u64()); } + // PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6 + // payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an + // already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine. + if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index fac8cf4..46b2460 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -198,6 +198,14 @@ struct PerformanceZone { std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional startPoint; // instrument-owned initial read frame; absent -> 0 + // S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far + // playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the + // DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved + // instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double. + // NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by + // resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines. + double keyTrack = 1.0; + // S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the // loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build @@ -227,6 +235,7 @@ struct ResolvedZone { int lowNote = 0; int highNote = 127; int rootNote = 60; // effective: override, else bank intrinsic, else 60 + double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET) SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build) @@ -363,7 +372,13 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2; // play tail with wall-clock frame counts) for back-compat, lifting missing fields to defaults. // v4 was never shipped and is not read. The marker is a high sentinel that a legitimate zone // count (bounded by 128 MIDI zones in practice, always tiny) can never collide with. -inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS +// * PAYLOAD v6 (S-VIEW-6 — CURRENT WRITE FORMAT): identical to v5, PLUS one field appended to +// each zone record after the full v5 play-params tail: +// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). +// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone +// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the +// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. +inline constexpr std::uint32_t kZonesPayloadVersion = 6; // S-VIEW-6: + per-zone keyTrack scalar inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; // (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index afb3361..370a49c 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -17,6 +17,16 @@ double pitchRatio(int note, int rootNote) { return std::pow(2.0, static_cast(note - rootNote) / 12.0); } +double keyTrackedRatio(int note, int rootNote, double keyTrack) { + // Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields + // (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument + // to std::pow is bit-identical to pitchRatio(note, rootNote) — the 100% default is byte-for- + // byte unchanged from the pre-S-VIEW-6 engine. keyTrack == 0.0 -> offset 0 -> ratio 1.0 on + // every key (no tracking); keyTrack == 2.0 -> doubled offset. Root note stays unity always. + const double semis = static_cast(note - rootNote) * keyTrack; + return std::pow(2.0, semis / 12.0); +} + // --------------------------------------------------------------------------- // Keymap // --------------------------------------------------------------------------- @@ -249,7 +259,8 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) { shiftR_.configure(windowFrames); } -void Voice::start(int note, int velocity, const SampleData& sample, int rootNote) { +void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, + double keyTrack) { active_ = true; releasing_ = false; amplitudeDone_ = false; @@ -259,7 +270,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote if (v < 0) v = 0; if (v > 127) v = 127; velocityGain_ = static_cast(v) / 127.0; - baseRatio_ = pitchRatio(note, rootNote); + // S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed + // read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is + // the pre-S-VIEW-6 pitchRatio bit-for-bit. + baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack); sample_ = &sample; const ZonePlayParams& p = sample.play; @@ -558,7 +572,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) { // The voice's Preserve shifters were pre-sized at engine construction (off-thread), so // start() only reset()s + warm()s them — no allocation on this audio-thread path. const std::size_t v = allocateVoice(); - voices_[v].start(note, velocity, sample, zone.rootNote); + voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack); voices_[v].setStartOrder(nextStartOrder_++); return v; } diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 3027f78..fa2f31b 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -194,6 +194,11 @@ struct KeyZone { int lowNote = 0; int highNote = 127; int rootNote = 60; // repitch reference for this zone + // S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is + // standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every + // key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset + // in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_. + double keyTrack = 1.0; std::size_t sampleIndex = 0; // index into Keymap::samples }; @@ -227,6 +232,18 @@ struct Keymap { // one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed. double pitchRatio(int note, int rootNote); +// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The +// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how +// far playback pitch tracks the keyboard around the root: +// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) — +// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call). +// keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes). +// keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch. +// At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity. +// Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via +// the voice's baseRatio_. +double keyTrackedRatio(int note, int rootNote, double keyTrack); + // --------------------------------------------------------------------------- // AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based // (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters @@ -351,7 +368,10 @@ public: // thread inside process(). The warm silence pass settles the OLA taps before the first // output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play // is default (Gate + Varispeed + no pitch env). - void start(int note, int velocity, const SampleData& sample, int rootNote); + // `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio; + // 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_. + void start(int note, int velocity, const SampleData& sample, int rootNote, + double keyTrack = 1.0); // Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in // TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length). diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index f5e28fb..4c1423f 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1201,6 +1201,96 @@ static void testPlayParamsComposeWithLoopStart() { CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); } +// --- S-VIEW-6 key-tracking scalar: v6 round-trip + resolve-through + back-compat lift ---------- + +static void testKeyTrackRoundTrip() { + // A per-zone keyTrack survives the payload-v6 round trip losslessly (exact double). A second + // zone left at the default proves the field is per-record and the default is 1.0. + PerformanceMap m; + PerformanceZone z = zone("lead", 20, 100, /*override=*/55); + z.keyTrack = 0.5; + m.zones.push_back(z); + m.zones.push_back(zone("pad", 0, 19)); // default keyTrack (1.0) + const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); + CHECK(back.zones.size() == 2); + if (back.zones.size() != 2) return; + CHECK(back.zones[0].keyTrack == 0.5); // exact double round-trip + CHECK(back.zones[1].keyTrack == 1.0); // untouched zone keeps the 100% default +} + +static void testKeyTrackThroughComponentEnvelope() { + // keyTrack round-trips through the ComponentState envelope too (the composition property: + // the zones payload is envelope-independent, so it carries the v6 tail unchanged). + ComponentState s; + s.selectionId = "pick"; + PerformanceZone z = zone("pick", 0, 127); + z.keyTrack = 2.0; + s.map.zones.push_back(z); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); + CHECK(back.map.zones.size() == 1); + if (back.map.zones.size() != 1) return; + CHECK(back.map.zones[0].keyTrack == 2.0); +} + +static void testKeyTrackResolvesToZone() { + // resolvePerformance carries keyTrack from the PerformanceZone through to the ResolvedZone, + // so the keymap build (and thus the repitch engine) sees the authored scalar. + const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); + PerformanceMap m; + PerformanceZone z = zone("a", 0, 127); + z.keyTrack = 0.0; // no tracking + m.zones.push_back(z); + const ResolvedPerformance r = resolvePerformance(json, m); + CHECK(r.zones.size() == 1); + if (r.zones.size() != 1) return; + CHECK(r.zones[0].keyTrack == 0.0); +} + +static void testKeyTrackV5BackCompatLiftsToUnity() { + // A v5 PAYLOAD blob (marker + version 5 + full play tail but NO keyTrack field) lifts every + // zone to keyTrack == 1.0 (the PerformanceZone default) — so an instance saved BEFORE S-VIEW-6 + // repitches BIT-IDENTICALLY (100% ET). Hand-build the exact v5 record shape. + std::vector b; + auto u32 = [&](std::uint32_t v) { + b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); + b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); + }; + auto f64 = [&](double d) { + std::uint64_t bits; std::memcpy(&bits, &d, sizeof(bits)); + for (int i = 0; i < 8; ++i) b.push_back(static_cast((bits >> (i * 8)) & 0xFF)); + }; + auto i64 = [&](std::int64_t v) { + std::uint64_t bits = static_cast(v); + for (int i = 0; i < 8; ++i) b.push_back(static_cast((bits >> (i * 8)) & 0xFF)); + }; + u32(kPerformanceStateVersion); // envelope version (2) + u32(kZonesFormatMarker); // marker -> a versioned payload + u32(5); // PAYLOAD VERSION 5 (pre-S-VIEW-6, no keyTrack tail) + u32(1); // zone count 1 + const std::string id = "v5saved"; + u32(static_cast(id.size())); + b.insert(b.end(), id.begin(), id.end()); + u32(10); u32(70); // low/high + b.push_back(0); // hasRootOverride = 0 + b.push_back(0); // hasLoopOverride = 0 + b.push_back(0); // hasStartPoint = 0 + // v5 play tail (order matches putZonesPayload): playMode, hold, len, fadeIn, fadeOut, engine, + // envEnabled, envAttack, envDecay, peak, attack, decay, sustain, release. + b.push_back(0); // playMode = Gate + f64(0.0); // adsr.holdSeconds + f64(1.0); // trigger.lengthFraction + i64(0); i64(0); // trigger fades (source frames) + b.push_back(1); // pitchEngine = Preserve + b.push_back(0); // pitchEnv.enabled = false + f64(0.0); f64(0.0); f64(0.0); // pitchEnv attack/decay/peak + f64(0.003); f64(0.0); f64(1.0); f64(0.060); // adsr A/D/S/R (tier-0 seconds) + const PerformanceMap back = deserializePerformance(b, 44100.0); + CHECK(back.zones.size() == 1); + if (back.zones.size() != 1) return; + CHECK(back.zones[0].sampleId == "v5saved"); + CHECK(back.zones[0].keyTrack == 1.0); // no keyTrack tail -> default 1.0 (bit-identical repitch) +} + static void testPlayParamsV2BackCompatLiftsToDefaults() { // A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail) // lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the @@ -1446,6 +1536,10 @@ int main() { testPerformanceStateNegativeNotesRoundTrip(); testPlayParamsRoundTrip(); testPlayParamsComposeWithLoopStart(); + testKeyTrackRoundTrip(); + testKeyTrackThroughComponentEnvelope(); + testKeyTrackResolvesToZone(); + testKeyTrackV5BackCompatLiftsToUnity(); testPlayParamsV2BackCompatLiftsToDefaults(); testPlayParamsThroughComponentEnvelope(); testFullAdsrSecondsRoundTrip(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 66153b2..c6b41b9 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -123,6 +123,34 @@ static void testPitchRatioMath() { CHECK(approx(pitchRatio(61, 60), std::pow(2.0, 1.0 / 12.0), 1e-9)); // +1 semitone } +// --- S-VIEW-6 key-tracking ratio math (pure), asserted at 0 / 100 / 200% + off-root. --- +static void testKeyTrackedRatioMath() { + // 100% (keyTrack == 1.0) is standard 12-tone-ET and BIT-IDENTICAL to pitchRatio: the argument + // to std::pow is (note-root)*1.0, exact in IEEE-754, so the same call yields the same bits. + for (int note = 0; note <= 127; ++note) { + CHECK(keyTrackedRatio(note, 60, 1.0) == pitchRatio(note, 60)); // exact equality, not approx + } + CHECK(approx(keyTrackedRatio(72, 60, 1.0), 2.0, 1e-9)); // +1 octave tracked normally + CHECK(approx(keyTrackedRatio(48, 60, 1.0), 0.5, 1e-9)); // -1 octave tracked normally + + // 0% (keyTrack == 0.0): no tracking. Every key — including off-root ones — plays root pitch. + CHECK(approx(keyTrackedRatio(60, 60, 0.0), 1.0, 1e-12)); // at root: unity (trivially) + CHECK(approx(keyTrackedRatio(72, 60, 0.0), 1.0, 1e-12)); // an octave up STILL plays root pitch + CHECK(approx(keyTrackedRatio(48, 60, 0.0), 1.0, 1e-12)); // an octave down STILL plays root pitch + CHECK(approx(keyTrackedRatio(67, 60, 0.0), 1.0, 1e-12)); // an off-root 5th STILL plays root pitch + + // 200% (keyTrack == 2.0): double-rate tracking. The semitone offset is doubled, so a +12 key + // plays as if +24 (two octaves, ratio 4.0), a -12 key as -24 (ratio 0.25). + CHECK(approx(keyTrackedRatio(72, 60, 2.0), 4.0, 1e-9)); // +12 -> +24 semis -> 4.0 + CHECK(approx(keyTrackedRatio(48, 60, 2.0), 0.25, 1e-9)); // -12 -> -24 semis -> 0.25 + CHECK(approx(keyTrackedRatio(60, 60, 2.0), 1.0, 1e-12)); // root is unity at ANY keyTrack + + // Off-root at 200% for a single semitone: +1 semi -> +2 semis -> 2^(2/12). + CHECK(approx(keyTrackedRatio(61, 60, 2.0), std::pow(2.0, 2.0 / 12.0), 1e-9)); + // An arbitrary intermediate scalar (50%): +12 key tracks as +6 semis -> 2^(6/12) = sqrt(2). + CHECK(approx(keyTrackedRatio(72, 60, 0.5), std::pow(2.0, 6.0 / 12.0), 1e-9)); +} + // Observe repitch on the rendered signal: a voice played an octave above root should // advance through the sample twice as fast, so a sine's observed period halves. We // measure the period by counting the interval between positive-going zero crossings. @@ -178,6 +206,67 @@ static void testRepitchObservedPeriod() { } } +// --- S-VIEW-6 keyTrack reaches the VARISPEED engine: observed period tracks the scalar. --- +static void testKeyTrackVarispeedObservedPeriod() { + const std::size_t frames = 8000; + const double cycles = 20.0; + const double nativePeriod = static_cast(frames) / cycles; // 400 at unity + + auto periodAt = [&](int note, double keyTrack) -> double { + SampleData s = sineSample(frames, cycles, 60); + s.play.pitchEngine = PitchEngine::Varispeed; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + // The single zone spans the keyboard from root 60; stamp the key-track scalar on it. + km.zones[0].keyTrack = keyTrack; + VoiceEngine eng(4, km); + eng.noteOn(note, 127); + std::vector out; + eng.render(out, frames); + return observedPeriodFrames(out); + }; + + // note 72 (+1 octave). At 100% it plays an octave up (period halves ~200). At 0% it plays at + // ROOT pitch (period ~native 400 — no tracking). The observed periods must differ by ~2x, which + // proves the scalar drove the Varispeed read rate. + const double at100 = periodAt(72, 1.0); + const double at0 = periodAt(72, 0.0); + CHECK(approx(at100, nativePeriod / 2.0, 3.0)); // 100%: tracked an octave up + CHECK(approx(at0, nativePeriod, 3.0)); // 0%: no tracking, plays root pitch +} + +// --- S-VIEW-6 keyTrack reaches the PRESERVE engine: at 0% an off-root note collapses to the root +// shift (unity), producing output identical to playing the root note. Proves keyTrack feeds +// baseRatio_ -> the Preserve shift amount (not merely the Varispeed read rate). --- +static void testKeyTrackPreserveShiftCollapsesAtZero() { + const std::size_t frames = 2000; + const std::size_t window = 512; + const double cycles = 40.0; + + auto renderPreserve = [&](int note, double keyTrack) -> std::vector { + SampleData s = sineSample(frames, cycles, 60); + s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to sample end + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + km.zones[0].keyTrack = keyTrack; + VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast(window)); + eng.noteOn(note, 127); + std::vector out; + eng.render(out, frames); + return out; + }; + + // An off-root note (+7) at keyTrack 0.0 sets the Preserve shift to the root ratio (1.0) — the + // shifter is pass-through, so the output must be BIT-IDENTICAL to playing the ROOT note (whose + // offset is 0, also shift 1.0). If keyTrack only touched Varispeed, these would differ. + const std::vector offRootNoTrack = renderPreserve(67, 0.0); + const std::vector rootRef = renderPreserve(60, 1.0); + CHECK(offRootNoTrack.size() == rootRef.size()); + bool identical = offRootNoTrack.size() == rootRef.size(); + for (std::size_t i = 0; i < offRootNoTrack.size() && identical; ++i) { + if (offRootNoTrack[i] != rootRef[i]) identical = false; + } + CHECK(identical); // 0% tracking collapses the Preserve shift to unity, exactly like the root +} + // --------------------------------------------------------------------------- // 3. ADSR envelope shape vs a known signal. // --------------------------------------------------------------------------- @@ -1238,7 +1327,10 @@ int main() { testZonedRangesBoundaries(); testFirstMatchOnOverlap(); testPitchRatioMath(); + testKeyTrackedRatioMath(); testRepitchObservedPeriod(); + testKeyTrackVarispeedObservedPeriod(); + testKeyTrackPreserveShiftCollapsesAtZero(); testAdsrShape(); testAdsrReleaseBeforeSustain(); testAdsrZeroAttackDecay();