Merge ps-w9-t2-modes: S15/S16 sampling modes + pitch engines

This commit is contained in:
2026-07-27 00:18:02 -04:00
13 changed files with 1528 additions and 77 deletions
+51 -2
View File
@@ -135,7 +135,7 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR) {
std::vector<AudioSample> framesR, const ZonePlayParams& play) {
SampleData data;
data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays
@@ -146,6 +146,7 @@ Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
data.sampleRate = sampleRate > 0 ? sampleRate : 44100;
data.rootNote = rootNote;
data.loop = loop;
data.play = play; // S15/S16 single-capture play params (product defaults unless overridden)
return Keymap::singleSampleChromatic(std::move(data));
}
@@ -186,6 +187,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
// never mutated — this only shapes what the core plays for THIS instance (D-B).
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
rz.startFrame = z.startPoint ? *z.startPoint : 0;
// S15/S16 per-zone play params carry through unchanged (they are instrument state, not
// resolved against the bank) so the keymap build can stamp them onto the SampleData.
rz.play = z.play;
out.zones.push_back(std::move(rz));
}
return out;
@@ -210,6 +214,7 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes
const std::size_t sampleIndex = km.samples.size();
km.samples.push_back(std::move(data));
KeyZone zone;
@@ -241,6 +246,19 @@ void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++).
// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields.
std::uint64_t doubleToBits(double d) {
std::uint64_t bits;
std::memcpy(&bits, &d, sizeof(bits));
return bits;
}
double bitsToDouble(std::uint64_t bits) {
double d;
std::memcpy(&d, &bits, sizeof(d));
return d;
}
// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
// blob degrades to a partial/empty parse rather than reading out of bounds.
@@ -324,6 +342,20 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
}
out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
// S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone
// has a play mode + engine — no flag gate). Order matches the header's v3 record spec.
const ZonePlayParams& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putU64le(out, asU64(pp.adsr.holdFrames));
putU64le(out, doubleToBits(pp.trigger.lengthFraction));
putU64le(out, asU64(pp.trigger.fadeInFrames));
putU64le(out, asU64(pp.trigger.fadeOutFrames));
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putU64le(out, asU64(pp.pitchEnv.attackFrames));
putU64le(out, asU64(pp.pitchEnv.decayFrames));
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones));
}
}
@@ -333,14 +365,18 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
// keeps the zones that parsed cleanly and drops the rest.
void readZonesPayload(ByteReader& r, PerformanceMap& map) {
bool extended = false;
bool extended = false; // v2+: the S11 loop/start tail is present
bool hasPlay = false; // v3+: the S15/S16 play-params tail is present
if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker
const std::uint32_t pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail
hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail
}
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). A v1/v2 payload (no play
// tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change.
PerformanceZone z;
const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen);
@@ -360,6 +396,19 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64();
}
if (hasPlay) {
// S15/S16 play params, always present in a v3 record (read in the emit order).
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdFrames = r.i64();
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64();
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.attackFrames = r.i64();
z.play.pitchEnv.decayFrames = r.i64();
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
}
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z));
}