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
+92
View File
@@ -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();