FA1: unity-Preserve bypass kills preview onset latency; drain-slot reload keeps voices ringing through curve edits; velocity path proven end-to-end
This commit is contained in:
@@ -1419,6 +1419,50 @@ static void testVelocityCurveResolvesToZone() {
|
||||
CHECK(r.zones[0].velocityCurve.equals(vst::VelocityCurve::linear()));
|
||||
}
|
||||
|
||||
// FA1 bug 3a — the COMPOSED end-to-end regression, mirroring the processor's reload composition
|
||||
// exactly: an authored curve survives the component-state round-trip (the save/load seam), then
|
||||
// resolvePerformance -> buildZonedKeymap -> VoiceEngine (constructed with a Preserve window, the
|
||||
// DAW configuration) -> render, and the rendered level tracks velocity through the curve. This
|
||||
// is the full pure slice of the click-to-sound path; only the bridge read + WAV decode (shell
|
||||
// I/O) are outside it. A y=x curve at velocity 1 must be near-silent — NOT max volume.
|
||||
static void testVelocityCurveEndToEndThroughReloadComposition() {
|
||||
// 1. The instrument's own state: one full-keyboard zone with a LINEAR curve (the exact edit
|
||||
// Daniel made), round-tripped through the v7 component-state wire (save -> load).
|
||||
ComponentState s;
|
||||
s.selectionId = "a";
|
||||
PerformanceZone z = zone("a", 0, 127);
|
||||
z.velocityCurve = vst::VelocityCurve::linear();
|
||||
s.map.zones.push_back(z);
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s), 48000.0);
|
||||
CHECK(back.map.zones.size() == 1);
|
||||
if (back.map.zones.size() != 1) return;
|
||||
|
||||
// 2. Resolve against a live bank blob (the shared bank_book parse, root 60 intrinsic).
|
||||
const std::string json = bookJson({makeSample("a", "Kick", "reasampler_bank/a.wav", 60)}, {});
|
||||
const ResolvedPerformance rp = resolvePerformance(json, back.map);
|
||||
CHECK(rp.zones.size() == 1);
|
||||
if (rp.zones.size() != 1) return;
|
||||
// The round-tripped zone still runs the PRESERVE product default (the DAW engine config).
|
||||
CHECK(rp.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
||||
|
||||
// 3. Build the zoned keymap from decoded DC-1 PCM and play it through an engine constructed
|
||||
// the way reloadFromBank constructs it (Preserve voices pre-sized to a real window).
|
||||
auto steadyLevelAt = [&](int vel) -> double {
|
||||
DecodedZonePcm pcm;
|
||||
pcm.monoFrames.assign(4000, 1.0f);
|
||||
pcm.sampleRate = 48000;
|
||||
const Keymap km = buildZonedKeymap(rp.zones, {pcm});
|
||||
VoiceEngine eng(16, km, /*preserveCap=*/8, /*window=*/256);
|
||||
eng.noteOn(62, vel); // transposed: the genuine OLA shifter path
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1000);
|
||||
return static_cast<double>(out[900]); // steady state (ring fully DC past the window)
|
||||
};
|
||||
CHECK(approx(steadyLevelAt(127), 1.0));
|
||||
CHECK(approx(steadyLevelAt(64), 64.0 / 127.0));
|
||||
CHECK(steadyLevelAt(1) < 0.02); // velocity 1 through y=x: near-silent, never max volume
|
||||
}
|
||||
|
||||
static void testVelocityCurveV6BackCompatLiftsToFlat() {
|
||||
// A v6 PAYLOAD blob (marker + version 6 + full play tail + keyTrack, but NO velocity-curve field)
|
||||
// lifts every zone to VelocityCurve::flat() (R10-F1 Option A — flat y=1). This is the DELIBERATE
|
||||
@@ -1822,6 +1866,7 @@ int main() {
|
||||
testVelocityCurveRoundTrip();
|
||||
testVelocityCurveThroughComponentEnvelope();
|
||||
testVelocityCurveResolvesToZone();
|
||||
testVelocityCurveEndToEndThroughReloadComposition();
|
||||
testVelocityCurveV6BackCompatLiftsToFlat();
|
||||
testPlayParamsV2BackCompatLiftsToDefaults();
|
||||
testPlayParamsThroughComponentEnvelope();
|
||||
|
||||
+106
-3
@@ -19,6 +19,7 @@
|
||||
|
||||
#include "../src/vst/sampler_core.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
@@ -1291,18 +1292,113 @@ static void testPreserveGateStereoLoopComposes() {
|
||||
}
|
||||
|
||||
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
|
||||
// Uses TRANSPOSED notes only: a note at the root demotes to the Varispeed path (FA1 unity
|
||||
// bypass) and deliberately does not count toward the cap — see the demotion test below.
|
||||
static void testPreserveVoiceCap() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
// 8 voices total, Preserve cap of 2.
|
||||
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
||||
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
||||
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
||||
CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
||||
CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
||||
CHECK(eng.activeVoiceCount() == 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FA1 — Preserve unity bypass (preview latency) + velocity under the Preserve engine.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A Preserve voice started at UNITY shift (note == effective root, pitch env off) must speak on
|
||||
// frame ONE — the FA1 latency fix. Pre-fix, the note ran through the OLA shifter, whose warm()d
|
||||
// ring delays onset by a half window (~25 ms at the product 50 ms window): frame 0 was silence.
|
||||
// The demoted voice reads the source directly (bit-identical to Varispeed at ratio 1.0).
|
||||
static void testPreserveUnityVoiceSpeaksImmediately() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(60, 127); // at root: unity shift -> demoted, zero onset delay
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 4);
|
||||
CHECK(approx(out[0], 1.0, 1e-6)); // the DC sample, on the very first frame
|
||||
}
|
||||
|
||||
// keyTrack 0 collapses EVERY note to unity — an off-root note also demotes and speaks at once.
|
||||
static void testPreserveKeyTrackZeroAlsoSpeaksImmediately() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].keyTrack = 0.0; // no tracking: all keys play root pitch (unity)
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(67, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 4);
|
||||
CHECK(approx(out[0], 1.0, 1e-6));
|
||||
}
|
||||
|
||||
// A TRANSPOSED Preserve note keeps the genuine OLA path: onset is shifter-delayed (the inherent
|
||||
// half-window cost of preserving duration) and the voice reaches full level once the ring fills.
|
||||
// Also proves the demotion is unity-ONLY — the shifter still transposes off-root notes.
|
||||
static void testPreserveTransposedVoiceKeepsOlaPath() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1500);
|
||||
// Early frames are the shifter's fill (near-silent) — the structural OLA onset.
|
||||
double early = 0.0;
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
early = (std::max)(early, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
CHECK(early < 0.1);
|
||||
// Once the ring is full of the DC source (>= window frames in), output reaches the sample
|
||||
// level (Hann taps partition unity, so DC passes at gain 1).
|
||||
double late = 0.0;
|
||||
for (std::size_t i = 600; i < 1500; ++i) {
|
||||
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
CHECK(late > 0.9);
|
||||
}
|
||||
|
||||
// A unity-demoted voice does NOT count toward the Preserve cap (it runs no shifter — it costs
|
||||
// Varispeed CPU, not OLA CPU), so root-note notes never starve transposed Preserve polyphony.
|
||||
static void testPreserveUnityVoiceDoesNotConsumeCap() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
||||
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // unity -> demoted, cap untouched
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st genuine Preserve voice
|
||||
CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
|
||||
CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd genuine Preserve DROPPED
|
||||
CHECK(eng.activeVoiceCount() == 3); // demoted + two Preserve
|
||||
}
|
||||
|
||||
// FA1 bug 3a regression, in the DAW's ACTUAL configuration: the velocity curve must drive the
|
||||
// gain under the PRESERVE product-default engine with a CONFIGURED shifter window (every prior
|
||||
// velocity test ran the bare Varispeed core). A linear y=x curve at velocity 1 must be
|
||||
// near-silent — NOT max volume.
|
||||
static void testVelocityCurveAppliesUnderPreserve() {
|
||||
auto steadyLevelAt = [&](int vel) -> double {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256);
|
||||
eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion)
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1000);
|
||||
return static_cast<double>(out[900]); // steady state: ring is fully DC by frame 256
|
||||
};
|
||||
CHECK(approx(steadyLevelAt(127), 1.0, 0.02));
|
||||
CHECK(approx(steadyLevelAt(64), 64.0 / 127.0, 0.02));
|
||||
CHECK(steadyLevelAt(1) < 0.02); // y=x at velocity 1: near-silent, the Daniel repro case
|
||||
}
|
||||
|
||||
// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
|
||||
//
|
||||
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
|
||||
@@ -1409,6 +1505,13 @@ int main() {
|
||||
testPreserveGateStereoLoopComposes();
|
||||
testPreserveVoiceCap();
|
||||
|
||||
// FA1 — Preserve unity bypass (preview latency) + velocity under Preserve.
|
||||
testPreserveUnityVoiceSpeaksImmediately();
|
||||
testPreserveKeyTrackZeroAlsoSpeaksImmediately();
|
||||
testPreserveTransposedVoiceKeepsOlaPath();
|
||||
testPreserveUnityVoiceDoesNotConsumeCap();
|
||||
testVelocityCurveAppliesUnderPreserve();
|
||||
|
||||
// S12 review fix — per-zone A/D/S/R reaches the voice envelope.
|
||||
testPerZoneAdsrReachesVoiceEnvelope();
|
||||
testZeroAdsrIsInstantSustain();
|
||||
|
||||
Reference in New Issue
Block a user