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:
@@ -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();
|
||||
|
||||
@@ -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<double>(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<AudioSample> 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<AudioSample> {
|
||||
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<std::int64_t>(window));
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> 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<AudioSample> offRootNoTrack = renderPreserve(67, 0.0);
|
||||
const std::vector<AudioSample> 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();
|
||||
|
||||
Reference in New Issue
Block a user