Phase S voices: user-set polyphony, mono stack (retrig|legato), isolated preview card, idle-drain retirement; unity bypass preview-only (v7)

This commit is contained in:
2026-07-27 21:29:30 -04:00
parent d9321eaf3a
commit 885b7f29a2
10 changed files with 1176 additions and 98 deletions
+122
View File
@@ -1105,6 +1105,121 @@ static void testComponentStateV6TruncatedVelocity() {
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
// --- v7 component state: the Phase S voice-system fields (count / mode / trigger) -------------
static void testComponentStateVoiceSystemRoundTrip() {
// Non-default values on all three fields prove the bytes are read back, not defaulted; the
// envelope neighbours (mode, marker, velocity, selection, zones) ride alongside intact.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.lastConsumedAssignGeneration = 42;
s.previewVelocity = 99;
s.voiceCount = 5;
s.voiceMode = VoiceMode::Mono;
s.monoTrigger = MonoTrigger::Legato;
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.voiceCount == 5);
CHECK(back.voiceMode == VoiceMode::Mono);
CHECK(back.monoTrigger == MonoTrigger::Legato);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.lastConsumedAssignGeneration == 42);
CHECK(back.previewVelocity == 99);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateVoiceDefaultsRoundTrip() {
// A default-constructed state carries {16, Poly, Retrigger} — the pre-Phase-S behavior —
// and round-trips it. Locks the constants the engine + editor share.
const ComponentState back =
deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(back.voiceCount == kDefaultVoiceCount);
CHECK(kDefaultVoiceCount == 16 && kMinVoiceCount == 1 && kMaxVoiceCount == 32);
CHECK(back.voiceMode == VoiceMode::Poly);
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
}
static void testComponentStateVoiceCountExtremesRoundTrip() {
// Both range edges survive the single-byte field exactly.
for (int vc : {kMinVoiceCount, kMaxVoiceCount}) {
ComponentState s;
s.voiceCount = vc;
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.voiceCount == vc);
}
}
static void testComponentStateVoiceCountWriterClamps() {
// The WRITER never emits an out-of-range byte: above-max clamps to max; a nonsensical
// below-min value (a programming error upstream) falls back to the default.
ComponentState hi;
hi.voiceCount = 99;
CHECK(deserializeComponentState(serializeComponentState(hi), 44100.0).voiceCount ==
kMaxVoiceCount);
ComponentState lo;
lo.voiceCount = 0;
CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).voiceCount ==
kDefaultVoiceCount);
}
static void testComponentStateV6LiftsVoiceDefaults() {
// A GENUINE v6 blob (version tag 6: mode, marker, velocity, id, zones — NO voice bytes)
// lifts to the Phase S voice defaults {16, Poly, Retrigger}, its other fields intact.
// Hand-built (serializeComponentState now emits v7, so it cannot make a v6 blob). This
// proves an already-saved pre-Phase-S instance restores playing exactly as it did.
std::vector<std::uint8_t> v6;
v6.push_back(6); v6.push_back(0); v6.push_back(0); v6.push_back(0); // version 6
v6.push_back(1); // channel mode = stereo
for (int i = 0; i < 8; ++i) v6.push_back(0); // marker = 0
v6.push_back(111); // preview velocity
const std::string id = "saved";
v6.push_back(static_cast<std::uint8_t>(id.size())); v6.push_back(0); v6.push_back(0); v6.push_back(0);
v6.insert(v6.end(), id.begin(), id.end());
v6.push_back(0); v6.push_back(0); v6.push_back(0); v6.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v6, 44100.0);
CHECK(back.voiceCount == kDefaultVoiceCount);
CHECK(back.voiceMode == VoiceMode::Poly);
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
CHECK(back.previewVelocity == 111); // the v6 byte still honored
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
static void testComponentStateV7CorruptVoiceBytesFallBack() {
// Out-of-range voice bytes in a v7 blob fall back to each field's DEFAULT (the
// previewVelocity corrupt-byte precedent) — a corrupt blob never silences or distorts the
// instance to an edge the user never chose. Build v7 by serializing, then vandalize the
// three voice bytes in place (offsets: 4 version + 1 mode + 8 marker + 1 velocity = 14).
ComponentState s;
s.voiceCount = 7;
s.voiceMode = VoiceMode::Mono;
s.monoTrigger = MonoTrigger::Legato;
std::vector<std::uint8_t> bytes = serializeComponentState(s);
bytes[14] = 0; // voice count 0: below kMinVoiceCount
bytes[15] = 7; // voice mode: not a legal {0,1} value
bytes[16] = 9; // mono trigger: not a legal {0,1} value
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.voiceCount == kDefaultVoiceCount);
CHECK(back.voiceMode == VoiceMode::Poly); // non-1 mode byte -> Poly default
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
}
static void testComponentStateV7TruncatedVoiceBytes() {
// A v7 blob cut INSIDE the three voice bytes -> empty, defaults holding (bounded read).
std::vector<std::uint8_t> t{7, 0, 0, 0, 1}; // version 7, 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); // voice count only —
const ComponentState back = deserializeComponentState(t, 44100.0); // mode/trigger cut
CHECK(back.voiceCount == kDefaultVoiceCount);
CHECK(back.voiceMode == VoiceMode::Poly);
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
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
@@ -1908,6 +2023,13 @@ int main() {
testComponentStateV5LiftsVelocityToMid();
testComponentStateV4LiftsVelocityToMid();
testComponentStateV6TruncatedVelocity();
testComponentStateVoiceSystemRoundTrip();
testComponentStateVoiceDefaultsRoundTrip();
testComponentStateVoiceCountExtremesRoundTrip();
testComponentStateVoiceCountWriterClamps();
testComponentStateV6LiftsVoiceDefaults();
testComponentStateV7CorruptVoiceBytesFallBack();
testComponentStateV7TruncatedVoiceBytes();
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
testReconcileKeepsOnlySelectedFullRangeZone();