Merge pS-w1-t3-state: persist preview-trigger velocity as ComponentState v6 (S-VIEW-4 core)

This commit is contained in:
2026-07-27 13:39:09 -04:00
5 changed files with 154 additions and 19 deletions
+4
View File
@@ -197,6 +197,9 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
channelMode_ = cs.channelMode;
}
applyOutputArrangement(cs.channelMode);
// S-VIEW-4: restore the per-instance preview velocity. No mutex — setState is a load-time
// call serialized by the host; there is no concurrent writer before Wave 2.
previewVelocity_ = cs.previewVelocity;
// Rebuild from the restored state (off-thread — setState is a load-time call).
reloadFromBank();
return kResultOk;
@@ -217,6 +220,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
}
state_out.previewVelocity = previewVelocity_; // S-VIEW-4: persist the preview strike velocity
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
if (!bytes.empty()) {
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
+6
View File
@@ -250,6 +250,12 @@ private:
// NOT read on the audio thread.
std::int64_t lastSeenBankGeneration_ = -1;
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
// user's chosen strike velocity survives a project save/reload. No concurrent writer before
// Wave 2 (no editor knob yet) — setState and getState are the sole accessors, both on the
// load/save thread (host-serialized). Default kPreviewVelocityDefault (64).
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
// Latched from setupProcessing so setActive/reload can size against it. Read
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
// before any audio, and reloadFromBank guards on it before use.
+29 -3
View File
@@ -549,6 +549,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
// 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<std::uint32_t>(state.selectionId.size()));
@@ -608,16 +611,39 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& 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)
// Clamp to the documented MIDI 1..127 range: a 0 byte (or any out-of-spec value from a
// corrupt blob) falls back to the mid default rather than silencing the preview trigger.
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
? previewVel
: kPreviewVelocityDefault;
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
+30 -16
View File
@@ -405,21 +405,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
// 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
@@ -428,14 +430,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
// (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}.