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
+177 -13
View File
@@ -92,10 +92,10 @@ struct Zone {
std::vector<VelocityPoint> curve; // empty -> the flat endpoints
};
static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
strv(out, z.sampleId);
u32v(out, static_cast<std::uint32_t>(z.lowNote));
u32v(out, static_cast<std::uint32_t>(z.highNote));
// Everything after a zone's id and key range — which is EXACTLY the whole v8 single record,
// so the two shapes are written from one place here just as the codec writes them from one
// place (putOverrides + the shared play tail).
static void putRecordBody(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
u8v(out, z.rootOverride >= 0 ? 1 : 0);
if (z.rootOverride >= 0) u32v(out, static_cast<std::uint32_t>(z.rootOverride));
if (pv >= 2) {
@@ -133,6 +133,13 @@ static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t
}
}
static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
strv(out, z.sampleId);
u32v(out, static_cast<std::uint32_t>(z.lowNote));
u32v(out, static_cast<std::uint32_t>(z.highNote));
putRecordBody(out, z, pv);
}
// The envelope fields, in wire order. A builder at version N emits only the prefix fields
// version N carried, so each lift can be asserted against a blob shaped exactly as that
// version's writer produced.
@@ -178,6 +185,33 @@ static std::vector<std::uint8_t> envelopeWithZones(const Envelope& env,
return out;
}
// The CURRENT envelope carrying a payload-v8 SINGLE RECORD — the shape immediately before the
// filter tail. The shipping writer only emits v9, so a v8 blob can come from nowhere but
// bytes laid out here, which is what makes the off/neutral filter lift provable rather than
// assumed.
static std::vector<std::uint8_t> envelopeWithV8Record(const std::string& selectionId,
const Zone& record) {
Envelope env;
env.selectionId = selectionId;
std::vector<std::uint8_t> out;
u32v(out, env.version);
u8v(out, env.modeByte);
i64v(out, env.assignGeneration);
u8v(out, env.previewVelocity);
u8v(out, env.voiceCount);
u8v(out, env.voiceMode);
u8v(out, env.monoTrigger);
f64v(out, env.masterGain);
u8v(out, env.channelModeExplicit);
u32v(out, 0); // sample-refs: empty table
strv(out, env.instanceGuid);
strv(out, env.selectionId);
u32v(out, kParamsFormatMarker);
u32v(out, 8);
putRecordBody(out, record, 7); // the v7 zone tail IS the v8 single record's body
return out;
}
// Shorthand for the common case: the CURRENT envelope version carrying a zone payload.
static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionId,
const std::vector<Zone>& zones,
@@ -388,7 +422,7 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x08,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x09,0x00,0x00,
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
@@ -401,13 +435,39 @@ static void testGoldenFullBlobFixture() {
0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
// --- payload v9 filter tail, at its OFF/NEUTRAL default (this fixture sets no
// filter field), in the header's documented order ---
0x00, // enabled = false
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // cutoffNorm 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // resonanceNorm 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // morphNorm 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0
0x00, // morphLaw = HighBandLow
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velAmount 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env decay 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // env sustain 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env release 0.0
0x02,0x00,0x00,0x00, // filter curve: 2 points (linear)
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
if (bytes.size() == sizeof(kGolden)) {
bool same = true;
for (std::size_t i = 0; i < bytes.size(); ++i) {
if (bytes[i] != kGolden[i]) { same = false; break; }
if (bytes[i] != kGolden[i]) {
std::printf(" golden byte %zu: got 0x%02x, want 0x%02x\n", i,
bytes[i], kGolden[i]);
same = false;
break;
}
}
CHECK(same);
}
@@ -443,8 +503,109 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 8);
CHECK(kParamsPayloadVersion == 9);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter tail rode a PAYLOAD bump, not an envelope one — the two axes stay
// independent, so a future envelope field cannot collide with it on one number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
// A v8 blob is a strict prefix of v9, so it must lift to the OFF/NEUTRAL filter — the reason
// a project saved before the filter existed reopens sounding identical. Everything the v8
// record did carry must survive alongside it.
static void testV8RecordLiftsToTheOffNeutralFilter() {
legacy::Zone rec;
rec.rootOverride = 48;
rec.startPoint = 512;
rec.holdSeconds = 0.25;
rec.attackSeconds = 0.011;
rec.releaseSeconds = 0.222;
rec.keyTrack = 0.75;
rec.preserve = true;
const ComponentState out =
deserializeComponentState(envelopeWithV8Record("kick", rec), 48000.0);
CHECK(out.selectionId == "kick");
CHECK(out.params.rootOverride && *out.params.rootOverride == 48);
CHECK(out.params.startPoint && *out.params.startPoint == 512);
CHECK(out.params.keyTrack == 0.75);
CHECK(out.params.play.adsr.holdSeconds == 0.25);
CHECK(out.params.play.adsr.releaseSeconds == 0.222);
CHECK(out.params.play.pitchEngine == PitchEngine::Preserve);
// The lift, field by field: nothing engaged, nothing modulating, a flat unity envelope.
const FilterSeconds& f = out.params.play.filter;
const FilterSeconds def;
CHECK(!f.enabled);
CHECK(f.settings.cutoffNorm == def.settings.cutoffNorm);
CHECK(f.settings.resonanceNorm == def.settings.resonanceNorm);
CHECK(f.settings.morphNorm == def.settings.morphNorm);
CHECK(f.settings.driveNorm == def.settings.driveNorm);
CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow);
CHECK(f.modAmount == 0.0);
CHECK(f.velAmount == 0.0);
CHECK(f.keyTrack == 0.0);
CHECK(f.env.sustainLevel == 1.0);
CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 &&
f.env.releaseSeconds == 0.0 && f.env.holdSeconds == 0.0);
// Re-saving lifts it into the current format, and that blob is what the writer would have
// produced for the same state — so the lift is stable, not one-way lossy.
ComponentState resaved = out;
CHECK(serializeComponentState(resaved) ==
serializeComponentState(deserializeComponentState(
serializeComponentState(resaved), 48000.0)));
}
// The v9 tail round-trips losslessly, including the morph law's non-default leg and a filter
// velocity curve distinct from the amp's.
static void testFilterTailRoundTripsLosslessly() {
ComponentState in;
in.selectionId = "pad";
FilterSeconds& f = in.params.play.filter;
f.enabled = true;
f.settings.cutoffNorm = 0.375f;
f.settings.resonanceNorm = 0.8125f;
f.settings.morphNorm = 0.25f;
f.settings.driveNorm = 0.5f;
f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow;
f.modAmount = -0.625;
f.velAmount = 0.5;
f.keyTrack = 1.5;
f.env.attackSeconds = 0.031;
f.env.holdSeconds = 0.062;
f.env.decaySeconds = 0.125;
f.env.sustainLevel = 0.25;
f.env.releaseSeconds = 0.5;
f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.1}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}});
// The amp's own curve stays different, so a codec that read one into the other fails here.
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat();
const ComponentState out =
deserializeComponentState(serializeComponentState(in), 48000.0);
const FilterSeconds& g = out.params.play.filter;
CHECK(g.enabled);
CHECK(g.settings.cutoffNorm == f.settings.cutoffNorm);
CHECK(g.settings.resonanceNorm == f.settings.resonanceNorm);
CHECK(g.settings.morphNorm == f.settings.morphNorm);
CHECK(g.settings.driveNorm == f.settings.driveNorm);
CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow);
CHECK(g.modAmount == f.modAmount);
CHECK(g.velAmount == f.velAmount);
CHECK(g.keyTrack == f.keyTrack);
CHECK(g.env.attackSeconds == f.env.attackSeconds);
CHECK(g.env.holdSeconds == f.env.holdSeconds);
CHECK(g.env.decaySeconds == f.env.decaySeconds);
CHECK(g.env.sustainLevel == f.env.sustainLevel);
CHECK(g.env.releaseSeconds == f.env.releaseSeconds);
CHECK(g.velocityCurve.size() == 3);
CHECK(g.velocityCurve.equals(f.velocityCurve));
CHECK(out.params.velocityCurve.equals(
reasampler::instrument::engine::VelocityCurve::flat()));
}
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
@@ -643,7 +804,7 @@ static void testEveryOlderPayloadVersionMigrates() {
(pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds));
}
// And the CURRENT version does NOT take the migration path: it reads its own record.
CHECK(kParamsPayloadVersion == 8);
CHECK(kParamsPayloadVersion >= kParamsSingleRecordVersion);
}
// The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary
@@ -814,11 +975,12 @@ static void testSampleRefsTruncatedMidEntry() {
std::vector<std::uint8_t> bytes = serializeComponentState(s);
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default)) = 158 bytes;
// entry two is 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0).
// Cutting 178 bytes keeps the first 27 of entry two's 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 178);
bytes.resize(bytes.size() - 178);
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
// v9 filter tail) = 292 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4, loop
// 1+8+8, channels4, name 4+0). Cutting 312 keeps the first 27 of entry two's 47 — mid
// loop.start (offset 23..31).
CHECK(bytes.size() > 312);
bytes.resize(bytes.size() - 312);
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
@@ -916,6 +1078,8 @@ int main() {
testUnknownEnvelopeVersionIsEmpty();
testV1SelectionLift();
testTruncationDegradesCleanly();
testV8RecordLiftsToTheOffNeutralFilter();
testFilterTailRoundTripsLosslessly();
if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n");
return 0;