S-VIEW-4: persist preview-trigger velocity as ComponentState envelope v6
New top-level previewVelocity field (MIDI 1..127, mid default 64), sibling of channelMode. Envelope bumped v5->v6; v5 and older blobs lift to the mid default. Zones payload untouched (independent version axis). Round-trip + v5/v4-lift + truncation tests added.
This commit is contained in:
+25
-3
@@ -538,6 +538,9 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
|||||||
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
|
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
|
||||||
// stops at the mode byte is a strict prefix (see the v4 lift below).
|
// stops at the mode byte is a strict prefix (see the v4 lift below).
|
||||||
putU64le(out, asU64(state.lastConsumedAssignGeneration));
|
putU64le(out, asU64(state.lastConsumedAssignGeneration));
|
||||||
|
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
|
||||||
|
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
|
||||||
|
out.push_back(state.previewVelocity);
|
||||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||||
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||||
@@ -597,16 +600,35 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
readZonesPayload(r, out.map, projectRate);
|
readZonesPayload(r, out.map, projectRate);
|
||||||
return out; // marker stays 0 (pre-S8/S9 reader)
|
return out; // marker stays 0 (pre-S8/S9 reader)
|
||||||
}
|
}
|
||||||
|
// BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity
|
||||||
|
// byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
||||||
|
// previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved
|
||||||
|
// pre-S-VIEW-4 instance restores at the mid default.
|
||||||
|
if (version == kSelectionZonesModeMarkerV5Version) {
|
||||||
|
const std::uint8_t modeByte = r.u8();
|
||||||
|
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||||
|
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||||
|
out.lastConsumedAssignGeneration = r.i64();
|
||||||
|
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
|
||||||
|
const std::uint32_t idLen = r.u32();
|
||||||
|
out.selectionId = r.str(idLen);
|
||||||
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
|
readZonesPayload(r, out.map, projectRate);
|
||||||
|
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
||||||
|
}
|
||||||
if (version != kComponentStateVersion) return out; // unknown -> empty
|
if (version != kComponentStateVersion) return out; // unknown -> empty
|
||||||
|
|
||||||
// v5: the channel-mode byte, then the 8-byte consumed-assignment marker, precede the v3
|
// v6: the channel-mode byte, then the 8-byte consumed-assignment marker, then the 1-byte
|
||||||
// body. A non-{0,1} mode byte is treated as mono (conservative default) rather than
|
// preview velocity, precede the v3 body. A non-{0,1} mode byte is treated as mono
|
||||||
// rejected — a corrupt mode never silences the instance.
|
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
|
||||||
const std::uint8_t modeByte = r.u8();
|
const std::uint8_t modeByte = r.u8();
|
||||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||||
out.lastConsumedAssignGeneration = r.i64();
|
out.lastConsumedAssignGeneration = r.i64();
|
||||||
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
|
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
|
||||||
|
const std::uint8_t previewVel = r.u8();
|
||||||
|
if (!r.ok) return out; // truncated before the velocity byte -> empty (mid default holds)
|
||||||
|
out.previewVelocity = previewVel;
|
||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
|
|||||||
+30
-16
@@ -390,21 +390,23 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
|||||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||||
// state), never auto-playing sample #1.
|
// state), never auto-playing sample #1.
|
||||||
//
|
//
|
||||||
// Format (envelope v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono,
|
// Format (envelope v6): 4-byte LE version tag (== 6), then a 1-byte channel-mode field (0 = mono,
|
||||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker),
|
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
||||||
// then a 4-byte LE selection-id length + id bytes, then the CURRENT zones payload (identical to
|
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then a 4-byte LE selection-id length +
|
||||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
// id bytes, then the CURRENT zones payload (identical to serializePerformance's body — its own
|
||||||
// The 8-byte marker is the ONLY envelope-v5 addition over envelope-v4 — the envelope grew a field,
|
// self-describing version, see the ZONES-PAYLOAD block). The 1-byte preview velocity is the ONLY
|
||||||
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
// envelope-v6 addition over envelope-v5 — the envelope grew a field, the zones payload is untouched
|
||||||
// versioning; the two version numbers are independent axes). BACK-COMPAT on
|
// (a PARALLEL track owns zone-record extension under its own versioning; the two version numbers
|
||||||
// read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0,
|
// are independent axes). BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
|
||||||
// preserving current behavior for already-saved instances):
|
// lastConsumedAssignGeneration = 0, and previewVelocity = kPreviewVelocityDefault, preserving
|
||||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct.
|
// current behavior for already-saved instances):
|
||||||
// * v4 blob -> {channelMode, 0, selectionId, zones}: pre-S8/S9 reader (no marker).
|
// * v6 blob -> {channelMode, lastConsumedAssignGeneration, previewVelocity, selectionId, zones} direct.
|
||||||
// * v3 blob -> {mono, 0, selectionId, zones}: pre-S7 had no channel mode.
|
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
|
||||||
// * v2 blob -> {mono, 0, "", zones}: an S5 instance had zones but no separate selection.
|
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
|
||||||
// * v1 blob -> {mono, 0, id, one full-keyboard zone}: the S4 single-selection lift.
|
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
|
||||||
// * empty/unknown -> {mono, 0, "", no zones}: EMPTY (the S10 silent empty state).
|
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
|
||||||
|
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
|
||||||
|
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
|
||||||
//
|
//
|
||||||
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
||||||
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
||||||
@@ -413,14 +415,26 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
|||||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
||||||
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
||||||
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
||||||
|
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
|
||||||
|
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
|
||||||
|
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||||
|
|
||||||
struct ComponentState {
|
struct ComponentState {
|
||||||
std::string selectionId; // the single-capture pick; "" = no pick
|
std::string selectionId; // the single-capture pick; "" = no pick
|
||||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||||
ChannelMode channelMode = ChannelMode::Mono; // S7 output mode; default mono (D-E)
|
ChannelMode channelMode = ChannelMode::Mono; // S7 output mode; default mono (D-E)
|
||||||
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
||||||
|
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
|
||||||
|
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
|
||||||
|
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
|
||||||
|
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||||
};
|
};
|
||||||
|
|
||||||
inline constexpr std::uint32_t kComponentStateVersion = 5;
|
inline constexpr std::uint32_t kComponentStateVersion = 6;
|
||||||
|
|
||||||
|
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
|
||||||
|
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||||
|
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||||
|
|
||||||
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
||||||
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||||
|
|||||||
@@ -1026,6 +1026,85 @@ static void testComponentStateV5TruncatedMarker() {
|
|||||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v6 component state: the S-VIEW-4 preview-trigger velocity ------------------
|
||||||
|
|
||||||
|
static void testComponentStatePreviewVelocityRoundTrip() {
|
||||||
|
// The preview velocity round-trips through the v6 envelope alongside selection + mode + marker
|
||||||
|
// + zones. A non-default value (not 64) proves the byte is actually read back, not defaulted.
|
||||||
|
ComponentState s;
|
||||||
|
s.selectionId = "pick";
|
||||||
|
s.channelMode = ChannelMode::Stereo;
|
||||||
|
s.lastConsumedAssignGeneration = 1700000123456LL;
|
||||||
|
s.previewVelocity = 111; // non-default
|
||||||
|
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
|
CHECK(back.previewVelocity == 111); // velocity survives
|
||||||
|
CHECK(back.channelMode == ChannelMode::Stereo); // envelope neighbours intact
|
||||||
|
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL);
|
||||||
|
CHECK(back.selectionId == "pick");
|
||||||
|
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateDefaultPreviewVelocityIsMid() {
|
||||||
|
// A default-constructed state carries the mid velocity default and round-trips it.
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
|
||||||
|
CHECK(back.previewVelocity == kPreviewVelocityDefault);
|
||||||
|
CHECK(kPreviewVelocityDefault == 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStatePreviewVelocityExtremes() {
|
||||||
|
// The full MIDI velocity range round-trips: 1 (softest audible) and 127 (max) both survive the
|
||||||
|
// single-byte field without clamping or overflow.
|
||||||
|
for (std::uint8_t v : {std::uint8_t{1}, std::uint8_t{127}}) {
|
||||||
|
ComponentState s;
|
||||||
|
s.previewVelocity = v;
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
|
CHECK(back.previewVelocity == v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV5LiftsVelocityToMid() {
|
||||||
|
// A GENUINE v5 blob (version tag 5: mode byte, 8-byte marker, then id + zones — NO velocity
|
||||||
|
// byte) must lift previewVelocity to kPreviewVelocityDefault, its mode/marker/selection/zones
|
||||||
|
// intact. Build it by hand (serializeComponentState now emits v6, so it cannot make a v5 blob).
|
||||||
|
// This proves an already-saved pre-S-VIEW-4 instance restores at the mid default.
|
||||||
|
std::vector<std::uint8_t> v5;
|
||||||
|
v5.push_back(5); v5.push_back(0); v5.push_back(0); v5.push_back(0); // version 5
|
||||||
|
v5.push_back(1); // channel mode = stereo
|
||||||
|
for (int i = 0; i < 8; ++i) v5.push_back(0); // marker = 0
|
||||||
|
const std::string id = "saved";
|
||||||
|
v5.push_back(static_cast<std::uint8_t>(id.size())); v5.push_back(0); v5.push_back(0); v5.push_back(0);
|
||||||
|
v5.insert(v5.end(), id.begin(), id.end());
|
||||||
|
v5.push_back(0); v5.push_back(0); v5.push_back(0); v5.push_back(0); // zone count 0
|
||||||
|
const ComponentState back = deserializeComponentState(v5, 44100.0);
|
||||||
|
CHECK(back.previewVelocity == kPreviewVelocityDefault); // no velocity byte in v5 -> mid default
|
||||||
|
CHECK(back.channelMode == ChannelMode::Stereo); // v5 mode byte honored
|
||||||
|
CHECK(back.selectionId == "saved");
|
||||||
|
CHECK(back.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV4LiftsVelocityToMid() {
|
||||||
|
// A pre-S8/S9 v4 blob (mode byte, then id + zones — no marker, no velocity) also lifts
|
||||||
|
// previewVelocity to the mid default. Proves the older-than-v5 lift path defaults the field too.
|
||||||
|
std::vector<std::uint8_t> v4;
|
||||||
|
v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // version 4
|
||||||
|
v4.push_back(0); // channel mode = mono
|
||||||
|
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // id length 0
|
||||||
|
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0
|
||||||
|
const ComponentState back = deserializeComponentState(v4, 44100.0);
|
||||||
|
CHECK(back.previewVelocity == kPreviewVelocityDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV6TruncatedVelocity() {
|
||||||
|
// A v6 blob truncated inside the header before the velocity byte (mode + full marker present,
|
||||||
|
// velocity byte cut) -> empty, with the mid velocity default holding (bounded read, never throws).
|
||||||
|
std::vector<std::uint8_t> t{6, 0, 0, 0, 1}; // version 6, mode byte
|
||||||
|
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker, no velocity byte
|
||||||
|
const ComponentState back = deserializeComponentState(t, 44100.0);
|
||||||
|
CHECK(back.previewVelocity == kPreviewVelocityDefault);
|
||||||
|
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
|
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
|
||||||
//
|
//
|
||||||
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
|
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
|
||||||
@@ -1480,6 +1559,12 @@ int main() {
|
|||||||
testComponentStateDefaultMarkerIsZero();
|
testComponentStateDefaultMarkerIsZero();
|
||||||
testComponentStateV4LiftsMarkerToZero();
|
testComponentStateV4LiftsMarkerToZero();
|
||||||
testComponentStateV5TruncatedMarker();
|
testComponentStateV5TruncatedMarker();
|
||||||
|
testComponentStatePreviewVelocityRoundTrip();
|
||||||
|
testComponentStateDefaultPreviewVelocityIsMid();
|
||||||
|
testComponentStatePreviewVelocityExtremes();
|
||||||
|
testComponentStateV5LiftsVelocityToMid();
|
||||||
|
testComponentStateV4LiftsVelocityToMid();
|
||||||
|
testComponentStateV6TruncatedVelocity();
|
||||||
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
||||||
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user