S-VIEW-6: per-zone keyTrack scalar (0-200%, default 100%) with pure ratio math in both repitch engines

keyTrackedRatio is bit-identical to pitchRatio at 100%; keyTrack threads through PerformanceZone->ResolvedZone->KeyZone into baseRatio_, so Varispeed and Preserve both inherit it. Zones payload bumped to v6; older blobs lift to 1.0.
This commit is contained in:
2026-07-27 13:25:53 -04:00
parent 3b9b78b82c
commit 42dd30b2fe
6 changed files with 252 additions and 6 deletions
+94
View File
@@ -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<std::uint8_t> 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<std::uint8_t>((bits >> (i * 8)) & 0xFF));
};
auto i64 = [&](std::int64_t v) {
std::uint64_t bits = static_cast<std::uint64_t>(v);
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((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<std::uint32_t>(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();