Phase S FB1 (r11): Sample-view recomposition — knob deck + elastic full-width hero + curve popup w/ right-click delete + post-mixer master gain (ComponentState v8)

This commit is contained in:
2026-07-27 23:08:04 -04:00
parent 27b2661ef2
commit 43155cf320
17 changed files with 1942 additions and 324 deletions
+106
View File
@@ -32,6 +32,7 @@
#include "../src/bank_book.h"
#include "../src/bank_model.h"
#include "../src/vst/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap)
using namespace reasampler;
@@ -1220,6 +1221,105 @@ static void testComponentStateV7TruncatedVoiceBytes() {
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
// --- v8 component state: the FB1 post-mixer master gain (linear double) -----------------------
static void testComponentStateMasterGainRoundTrip() {
// A non-default gain proves the bytes are read back, not defaulted; the envelope
// neighbours (voice bytes, velocity, selection, zones) ride alongside intact.
ComponentState s;
s.selectionId = "pick";
s.previewVelocity = 99;
s.voiceCount = 5;
s.masterGainLinear = 0.25; // -12.04 dB
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(std::fabs(back.masterGainLinear - 0.25) < 1e-12); // an exact double round-trip
CHECK(back.voiceCount == 5);
CHECK(back.previewVelocity == 99);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateMasterGainDefaultAndZeroRoundTrip() {
// Default unity round-trips (pre-FB1 output); the -inf bottom (TRUE zero) round-trips
// exactly — a user who pulled the gain to silence gets silence back after a save/load.
const ComponentState defBack =
deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(defBack.masterGainLinear == 1.0);
ComponentState zero;
zero.masterGainLinear = 0.0;
const ComponentState zeroBack =
deserializeComponentState(serializeComponentState(zero), 44100.0);
CHECK(zeroBack.masterGainLinear == 0.0);
}
static void testComponentStateMasterGainWriterClamps() {
// The WRITER never emits an out-of-range value: above the +24 dB cap clamps to the cap;
// a negative/non-finite value (a programming error upstream) falls back to unity.
ComponentState hi;
hi.masterGainLinear = 1000.0;
CHECK(std::fabs(deserializeComponentState(serializeComponentState(hi), 44100.0)
.masterGainLinear -
vst::masterGainMaxLinear()) < 1e-9);
ComponentState lo;
lo.masterGainLinear = -5.0;
CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).masterGainLinear ==
1.0);
}
static void testComponentStateV7LiftsUnityMasterGain() {
// A GENUINE v7 blob (version tag 7: mode, marker, velocity, voice bytes, id, zones — NO
// master-gain double) lifts to unity, its other fields intact. Hand-built
// (serializeComponentState now emits v8, so it cannot make a v7 blob). Proves an
// already-saved pre-FB1 instance restores playing at exactly its old output level.
std::vector<std::uint8_t> v7;
v7.push_back(7); v7.push_back(0); v7.push_back(0); v7.push_back(0); // version 7
v7.push_back(1); // channel mode = stereo
for (int i = 0; i < 8; ++i) v7.push_back(0); // marker = 0
v7.push_back(111); // preview velocity
v7.push_back(5); // voice count
v7.push_back(1); // voice mode = mono
v7.push_back(1); // trigger = legato
const std::string id = "saved";
v7.push_back(static_cast<std::uint8_t>(id.size())); v7.push_back(0); v7.push_back(0); v7.push_back(0);
v7.insert(v7.end(), id.begin(), id.end());
v7.push_back(0); v7.push_back(0); v7.push_back(0); v7.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v7, 44100.0);
CHECK(back.masterGainLinear == 1.0);
CHECK(back.voiceCount == 5);
CHECK(back.voiceMode == VoiceMode::Mono);
CHECK(back.monoTrigger == MonoTrigger::Legato);
CHECK(back.previewVelocity == 111);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
static void testComponentStateV8CorruptMasterGainFallsBack() {
// A corrupt gain double (NaN) in a v8 blob falls back to unity (the previewVelocity
// corrupt-byte precedent) — never silences or blasts the instance. Build v8 by
// serializing, then vandalize the 8 gain bytes in place (offsets: 4 version + 1 mode +
// 8 marker + 1 velocity + 3 voice bytes = 17..24).
ComponentState s;
s.masterGainLinear = 0.5;
std::vector<std::uint8_t> bytes = serializeComponentState(s);
for (int i = 0; i < 8; ++i) bytes[17 + i] = 0xFF; // 0xFFFF... = a negative NaN pattern
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.masterGainLinear == 1.0);
}
static void testComponentStateV8TruncatedMasterGain() {
// A v8 blob cut INSIDE the gain double -> empty, defaults holding (bounded read).
std::vector<std::uint8_t> t{8, 0, 0, 0, 0}; // version 8, mode byte
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker
t.push_back(64); // velocity byte
t.push_back(16); t.push_back(0); t.push_back(0); // the three voice bytes
t.push_back(0); t.push_back(0); t.push_back(0); // gain cut mid-double
const ComponentState back = deserializeComponentState(t, 44100.0);
CHECK(back.masterGainLinear == 1.0);
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
@@ -2030,6 +2130,12 @@ int main() {
testComponentStateV6LiftsVoiceDefaults();
testComponentStateV7CorruptVoiceBytesFallBack();
testComponentStateV7TruncatedVoiceBytes();
testComponentStateMasterGainRoundTrip();
testComponentStateMasterGainDefaultAndZeroRoundTrip();
testComponentStateMasterGainWriterClamps();
testComponentStateV7LiftsUnityMasterGain();
testComponentStateV8CorruptMasterGainFallsBack();
testComponentStateV8TruncatedMasterGain();
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
testReconcileKeepsOnlySelectedFullRangeZone();