feat: run the per-voice filter between the pitch and amp stages, with its own deck

Params ride the one parameter set; payload v8 -> v9, off by default.
Deck composition moves to a pure deck_groups module in pitch -> filter -> amp order.
This commit is contained in:
2026-07-30 15:26:14 -04:00
parent c9c708a338
commit 67215509cb
25 changed files with 1354 additions and 191 deletions
+52
View File
@@ -610,6 +610,57 @@ static void testResolvePlayConvertsWallClockAtTheRate() {
CHECK(at96.trigger.fadeInFrames == 441); // still unconverted
}
static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() {
// The filter's control positions are already rate-free, so only its envelope crosses the
// seconds->frames boundary. A converted norm would be a bug in the other direction: the
// same preset must sound identical at 48k and 96k.
PlaySeconds st;
st.filter.enabled = true;
st.filter.settings.cutoffNorm = 0.25f;
st.filter.settings.resonanceNorm = 0.75f;
st.filter.settings.morphNorm = 0.5f;
st.filter.settings.driveNorm = 0.125f;
st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
st.filter.modAmount = -0.5;
st.filter.velAmount = 0.25;
st.filter.keyTrack = 1.25;
st.filter.env.attackSeconds = 0.01;
st.filter.env.holdSeconds = 0.02;
st.filter.env.decaySeconds = 0.03;
st.filter.env.sustainLevel = 0.4;
st.filter.env.releaseSeconds = 0.05;
const PlayParams at48 = resolvePlay(st, 48000);
CHECK(at48.filter.enabled);
CHECK(at48.filter.settings.cutoffNorm == 0.25f);
CHECK(at48.filter.settings.resonanceNorm == 0.75f);
CHECK(at48.filter.settings.morphNorm == 0.5f);
CHECK(at48.filter.settings.driveNorm == 0.125f);
CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
CHECK(at48.filter.modAmount == -0.5);
CHECK(at48.filter.velAmount == 0.25);
CHECK(at48.filter.keyTrack == 1.25);
CHECK(at48.filter.env.attackFrames == 480);
CHECK(at48.filter.env.holdFrames == 960);
CHECK(at48.filter.env.decayFrames == 1440);
CHECK(at48.filter.env.sustainLevel == 0.4); // a level, not a time
CHECK(at48.filter.env.releaseFrames == 2400);
const PlayParams at96 = resolvePlay(st, 96000);
CHECK(at96.filter.env.attackFrames == 960);
CHECK(at96.filter.env.releaseFrames == 4800);
CHECK(at96.filter.settings.cutoffNorm == 0.25f); // rate-free: unchanged
// Off by default, and the default envelope is a flat unity so a disengaged filter has
// nothing to modulate with either.
const PlayParams bare = resolvePlay(PlaySeconds{}, 48000);
CHECK(!bare.filter.enabled);
CHECK(bare.filter.modAmount == 0.0);
CHECK(bare.filter.velAmount == 0.0);
CHECK(bare.filter.keyTrack == 0.0);
CHECK(bare.filter.env.sustainLevel == 1.0);
}
static void testResolvePlayRoundsAndFloorsNegatives() {
PlaySeconds st;
st.adsr.attackSeconds = 0.0001; // 4.41 frames at 44.1k -> rounds to 4
@@ -845,6 +896,7 @@ int main() {
testRetainRefsFiltersToPlayedSet();
testLegacyLiftDecision();
testResolvePlayConvertsWallClockAtTheRate();
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
testResolvePlayRoundsAndFloorsNegatives();
testResolveCaptureUsesIntrinsicsWhenNoOverride();
testResolveCaptureOverridesBeatIntrinsics();