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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user