From b0b24c3e31bdbeb3b813e6eb487b40861e06f523 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 13:26:07 -0400 Subject: [PATCH] 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. --- src/vst/sample_map.cpp | 28 +++++++++++-- src/vst/sample_map.h | 46 +++++++++++++-------- tests/test_sample_map.cpp | 85 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 19 deletions(-) diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 5f07b99..0346171 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -538,6 +538,9 @@ std::vector serializeComponentState(const ComponentState& state) { // 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). 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 — // unlike the v1 selection blob where the id ran to end-of-stream). putU32le(out, static_cast(state.selectionId.size())); @@ -597,16 +600,35 @@ ComponentState deserializeComponentState(const std::vector& bytes, readZonesPayload(r, out.map, projectRate); 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 - // v5: the channel-mode byte, then the 8-byte consumed-assignment marker, precede the v3 - // body. A non-{0,1} mode byte is treated as mono (conservative default) rather than - // rejected — a corrupt mode never silences the instance. + // v6: the channel-mode byte, then the 8-byte consumed-assignment marker, then the 1-byte + // preview velocity, precede the v3 body. A non-{0,1} mode byte is treated as mono + // (conservative default) rather than rejected — a corrupt mode never silences the instance. 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::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(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index fac8cf4..6d453b3 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -390,21 +390,23 @@ PerformanceMap deserializePerformance(const std::vector& bytes, // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // state), never auto-playing sample #1. // -// Format (envelope v5): 4-byte LE version tag (== 5), then a 1-byte channel-mode field (0 = mono, -// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), -// then a 4-byte LE selection-id length + id bytes, then the CURRENT zones payload (identical to -// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). -// The 8-byte marker is the ONLY envelope-v5 addition over envelope-v4 — the envelope grew a field, -// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own -// versioning; the two version numbers are independent axes). BACK-COMPAT on -// read (every older blob lifts to channelMode = MONO and lastConsumedAssignGeneration = 0, -// preserving current behavior for already-saved instances): -// * v5 blob -> {channelMode, lastConsumedAssignGeneration, selectionId, zones} direct. -// * v4 blob -> {channelMode, 0, selectionId, zones}: pre-S8/S9 reader (no marker). -// * v3 blob -> {mono, 0, selectionId, zones}: pre-S7 had no channel mode. -// * v2 blob -> {mono, 0, "", zones}: an S5 instance had zones but no separate selection. -// * v1 blob -> {mono, 0, id, one full-keyboard zone}: the S4 single-selection lift. -// * empty/unknown -> {mono, 0, "", no zones}: EMPTY (the S10 silent empty state). +// 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), then a +// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then a 4-byte LE selection-id length + +// id bytes, then the CURRENT zones payload (identical to serializePerformance's body — its own +// self-describing version, see the ZONES-PAYLOAD block). The 1-byte preview velocity is the ONLY +// envelope-v6 addition over envelope-v5 — the envelope grew a field, the zones payload is untouched +// (a PARALLEL track owns zone-record extension under its own versioning; the two version numbers +// are independent axes). BACK-COMPAT on read (every older blob lifts to channelMode = MONO, +// lastConsumedAssignGeneration = 0, and previewVelocity = kPreviewVelocityDefault, preserving +// current behavior for already-saved instances): +// * v6 blob -> {channelMode, lastConsumedAssignGeneration, previewVelocity, selectionId, zones} direct. +// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). +// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). +// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. +// * 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 // 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& bytes, // 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 // 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 { std::string selectionId; // the single-capture pick; "" = no pick PerformanceMap map; // the opt-in zones; empty = no zones ChannelMode channelMode = ChannelMode::Mono; // S7 output mode; default mono (D-E) 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 // marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index f5e28fb..b4a690a 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1026,6 +1026,85 @@ static void testComponentStateV5TruncatedMarker() { 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 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(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 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 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) ---------------- // // The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and @@ -1480,6 +1559,12 @@ int main() { testComponentStateDefaultMarkerIsZero(); testComponentStateV4LiftsMarkerToZero(); testComponentStateV5TruncatedMarker(); + testComponentStatePreviewVelocityRoundTrip(); + testComponentStateDefaultPreviewVelocityIsMid(); + testComponentStatePreviewVelocityExtremes(); + testComponentStateV5LiftsVelocityToMid(); + testComponentStateV4LiftsVelocityToMid(); + testComponentStateV6TruncatedVelocity(); testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();