fix(vst): pin output bus to stereo (mode is decode-only) killing the hard-right pan; auto-default channel mode from the loaded capture (state v9)
This commit is contained in:
@@ -129,6 +129,23 @@ static void testSelectNoLoopIsAbsent() {
|
||||
CHECK(sel && !sel->loop.hasLoop); // absent loop -> hasLoop false (not a zero loop)
|
||||
}
|
||||
|
||||
static void testSelectChannelCountThreaded() {
|
||||
// GA: the bank's capture channel-count intrinsic rides SelectedSample so the shell can
|
||||
// auto-default the channel mode (stereo capture -> Stereo). An entry without the
|
||||
// intrinsic yields 0 (unknown — the auto-default skips it).
|
||||
Sample st = makeSample("st", "Wide", "reasampler_bank/st.wav", 60);
|
||||
st.channelCount = 2;
|
||||
Sample mo = makeSample("mo", "Narrow", "reasampler_bank/mo.wav", 60);
|
||||
mo.channelCount = 1;
|
||||
const std::string json = bookJson({st, mo, makeSample("un", "Old", "reasampler_bank/un.wav", 60)}, {});
|
||||
auto selSt = selectSample(json, "st");
|
||||
CHECK(selSt && selSt->channelCount == 2);
|
||||
auto selMo = selectSample(json, "mo");
|
||||
CHECK(selMo && selMo->channelCount == 1);
|
||||
auto selUn = selectSample(json, "un");
|
||||
CHECK(selUn && selUn->channelCount == 0); // unstamped -> unknown, never a guess
|
||||
}
|
||||
|
||||
static void testSelectEmptyBlob() {
|
||||
CHECK(!selectSample("", "a").has_value());
|
||||
}
|
||||
@@ -1320,6 +1337,55 @@ static void testComponentStateV8TruncatedMasterGain() {
|
||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||
}
|
||||
|
||||
// --- v9 component state: the GA channel-mode explicit flag ------------------------------------
|
||||
|
||||
static void testComponentStateChannelModeExplicitRoundTrip() {
|
||||
// The explicit flag survives a round-trip in BOTH states, its envelope neighbours intact.
|
||||
ComponentState s;
|
||||
s.selectionId = "pick";
|
||||
s.channelMode = ChannelMode::Stereo;
|
||||
s.channelModeExplicit = true;
|
||||
s.masterGainLinear = 0.5;
|
||||
ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||
CHECK(back.channelModeExplicit);
|
||||
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||
CHECK(std::fabs(back.masterGainLinear - 0.5) < 1e-12);
|
||||
CHECK(back.selectionId == "pick");
|
||||
s.channelModeExplicit = false;
|
||||
back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||
CHECK(!back.channelModeExplicit); // implicit round-trips too (not defaulted-true)
|
||||
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||
}
|
||||
|
||||
static void testComponentStateV8LiftsImplicitChannelMode() {
|
||||
// A GENUINE v8 blob (version tag 8: mode, marker, velocity, voice bytes, gain, id, zones —
|
||||
// NO explicit flag) lifts to channelModeExplicit = FALSE: a pre-GA mode byte is treated as
|
||||
// the un-touched default so the shell's auto-default may follow the loaded capture. Hand-
|
||||
// built (serializeComponentState now emits v9, so it cannot make a v8 blob).
|
||||
std::vector<std::uint8_t> v8;
|
||||
v8.push_back(8); v8.push_back(0); v8.push_back(0); v8.push_back(0); // version 8
|
||||
v8.push_back(0); // channel mode = mono
|
||||
for (int i = 0; i < 8; ++i) v8.push_back(0); // marker = 0
|
||||
v8.push_back(88); // preview velocity
|
||||
v8.push_back(7); // voice count
|
||||
v8.push_back(0); // voice mode = poly
|
||||
v8.push_back(0); // trigger = retrigger
|
||||
for (int i = 0; i < 8; ++i) v8.push_back(0); // gain double bytes...
|
||||
v8[17 + 6] = 0xF0; v8[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754)
|
||||
const std::string id = "saved";
|
||||
v8.push_back(static_cast<std::uint8_t>(id.size())); v8.push_back(0); v8.push_back(0); v8.push_back(0);
|
||||
v8.insert(v8.end(), id.begin(), id.end());
|
||||
v8.push_back(0); v8.push_back(0); v8.push_back(0); v8.push_back(0); // zone count 0
|
||||
const ComponentState back = deserializeComponentState(v8, 44100.0);
|
||||
CHECK(!back.channelModeExplicit); // pre-GA blob -> implicit (auto-default allowed)
|
||||
CHECK(back.channelMode == ChannelMode::Mono);
|
||||
CHECK(back.masterGainLinear == 1.0);
|
||||
CHECK(back.previewVelocity == 88);
|
||||
CHECK(back.voiceCount == 7);
|
||||
CHECK(back.selectionId == "saved");
|
||||
CHECK(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
|
||||
@@ -2033,6 +2099,7 @@ int main() {
|
||||
testSelectRootNoteDefault();
|
||||
testSelectLoopThreaded();
|
||||
testSelectNoLoopIsAbsent();
|
||||
testSelectChannelCountThreaded();
|
||||
testSelectEmptyBlob();
|
||||
testSelectMalformedBlob();
|
||||
testSelectZeroSamples();
|
||||
@@ -2136,6 +2203,8 @@ int main() {
|
||||
testComponentStateV7LiftsUnityMasterGain();
|
||||
testComponentStateV8CorruptMasterGainFallsBack();
|
||||
testComponentStateV8TruncatedMasterGain();
|
||||
testComponentStateChannelModeExplicitRoundTrip();
|
||||
testComponentStateV8LiftsImplicitChannelMode();
|
||||
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
||||
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
||||
testReconcileKeepsOnlySelectedFullRangeZone();
|
||||
|
||||
@@ -851,6 +851,35 @@ static void testMonoSamplePlaysDualMonoInStereo() {
|
||||
}
|
||||
}
|
||||
|
||||
static void testDualMonoStereoSampleRendersCentered() {
|
||||
// GA Bug 1 (pure-layer proof): a STEREO sample whose two channels are IDENTICAL (a
|
||||
// dual-mono capture) must render EXACTLY equal L and R — bitwise, every frame — under
|
||||
// BOTH pitch engines. Any asymmetry here (a silent L, a channel offset, divergent
|
||||
// shifter state) would pan the output; hard-panned output from a dual-mono capture
|
||||
// therefore cannot originate in the engine.
|
||||
auto renderBoth = [](PitchEngine engine, int note) {
|
||||
SampleData s = sineSample(600, 12.0, 60);
|
||||
s.framesR = s.frames; // dual-mono: identical channels
|
||||
s.play.pitchEngine = engine;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*preserveWindowFrames=*/128);
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> left(256, 0.f), right(256, 0.f);
|
||||
eng.render(left.data(), right.data(), 256);
|
||||
bool sound = false, equal = true;
|
||||
for (std::size_t i = 0; i < left.size(); ++i) {
|
||||
if (left[i] != 0.0f) sound = true;
|
||||
if (left[i] != right[i]) equal = false; // EXACT: dual-mono must be centered
|
||||
}
|
||||
CHECK(sound); // the render actually produced signal (a 0==0 pass would be vacuous)
|
||||
CHECK(equal);
|
||||
};
|
||||
renderBoth(PitchEngine::Varispeed, 60);
|
||||
renderBoth(PitchEngine::Varispeed, 67); // off-root: repitch rides both channels equally
|
||||
renderBoth(PitchEngine::Preserve, 60); // both shifters run (no unity demotion in MIDI)
|
||||
renderBoth(PitchEngine::Preserve, 67); // off-root Preserve: per-channel shift, same state
|
||||
}
|
||||
|
||||
static void testMonoRenderUnchangedByStereoData() {
|
||||
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
|
||||
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
|
||||
@@ -1975,6 +2004,7 @@ int main() {
|
||||
testChannelCount();
|
||||
testStereoRenderKeepsChannelsDistinct();
|
||||
testMonoSamplePlaysDualMonoInStereo();
|
||||
testDualMonoStereoSampleRendersCentered();
|
||||
testMonoRenderUnchangedByStereoData();
|
||||
testStereoRenderAdvancesLikeMonoRepitch();
|
||||
testStereoRenderSumsVoicesPerChannel();
|
||||
|
||||
Reference in New Issue
Block a user