Merge pS-w1-t2-keytrk: per-zone keyTrack scalar + ratio math in both repitch engines (S-VIEW-6 core)

This commit is contained in:
2026-07-27 13:38:56 -04:00
6 changed files with 252 additions and 6 deletions
+12 -1
View File
@@ -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<ResolvedZone>& 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<std::uint8_t>& 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
+16 -1
View File
@@ -198,6 +198,14 @@ struct PerformanceZone {
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> 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 v1v5 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
+17 -3
View File
@@ -17,6 +17,16 @@ double pitchRatio(int note, int rootNote) {
return std::pow(2.0, static_cast<double>(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<double>(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<double>(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;
}
+21 -1
View File
@@ -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).