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;
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no
|
||||
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH
|
||||
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
|
||||
// the Filter group's contents, the wrapped deck height at the editor's two pinned widths, the
|
||||
// hit-test reaching the new filter controls, and the bipolar knob law's inverse pair.
|
||||
|
||||
#include "../src/core/instrument/ui/deck_groups.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// The editor's two pinned client widths (checkSizeConstraint's 560 floor, the 840 default),
|
||||
// less the band allocator's kPad inset on each side.
|
||||
static constexpr int kAvailAtMinWidth = 560 - 16;
|
||||
static constexpr int kAvailAtDefaultWidth = 840 - 16;
|
||||
|
||||
static int indexOfGroup(const std::vector<DeckGroupDesc>& g, int id) {
|
||||
for (std::size_t i = 0; i < g.size(); ++i) {
|
||||
if (g[i].id == id) return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int cell(DeckParam p) { return static_cast<int>(p); }
|
||||
|
||||
static void testDeckReadsPitchThenFilterThenAmpLeftToRight() {
|
||||
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
|
||||
const int pitch = indexOfGroup(g, kGroupPitch);
|
||||
const int penv = indexOfGroup(g, kGroupPitchEnv);
|
||||
const int filt = indexOfGroup(g, kGroupFilter);
|
||||
const int fenv = indexOfGroup(g, kGroupFilterEnv);
|
||||
const int amp = indexOfGroup(g, kGroupAmpEnv);
|
||||
CHECK(pitch >= 0 && penv >= 0 && filt >= 0 && fenv >= 0 && amp >= 0);
|
||||
// The signal flow, left to right. Each envelope group trails its own stage.
|
||||
CHECK(pitch < penv);
|
||||
CHECK(penv < filt);
|
||||
CHECK(filt < fenv);
|
||||
CHECK(fenv < amp);
|
||||
// The two instance-wide groups stay at the end.
|
||||
CHECK(amp < indexOfGroup(g, kGroupVoice));
|
||||
CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster));
|
||||
}
|
||||
}
|
||||
|
||||
static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() {
|
||||
const std::vector<DeckGroupDesc>& g = sampleDeckGroups(PlayMode::Gate);
|
||||
const DeckGroupDesc& f = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||||
const std::vector<int> expected = {
|
||||
cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff),
|
||||
cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive),
|
||||
cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel),
|
||||
cell(DeckParam::kFilterKeyTrack)};
|
||||
CHECK(f.cellIds == expected);
|
||||
// Off by default is a state question, but reachability is a layout one: the enable
|
||||
// toggle is in the caption row and the morph law in the knob row.
|
||||
CHECK(f.captionToggle.id == cell(DeckParam::kFilterEnable));
|
||||
CHECK(f.rowToggle.id == cell(DeckParam::kFilterLaw));
|
||||
|
||||
const DeckGroupDesc& fe = g[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
|
||||
const std::vector<int> env = {
|
||||
cell(DeckParam::kFilterEnvAttack), cell(DeckParam::kFilterEnvHold),
|
||||
cell(DeckParam::kFilterEnvDecay), cell(DeckParam::kFilterEnvSustain),
|
||||
cell(DeckParam::kFilterEnvRelease)};
|
||||
CHECK(fe.cellIds == env);
|
||||
// The filter envelope has no enable of its own — the FILTER group's toggle governs both.
|
||||
CHECK(fe.captionToggle.id == -1);
|
||||
CHECK(fe.rowToggle.id == -1);
|
||||
}
|
||||
|
||||
static void testAmpGroupWidthSurvivesAGateTriggerFlip() {
|
||||
// The reserved blanks are what stop a mode flip reflowing the groups beside AMP.
|
||||
const std::vector<DeckGroupDesc> gate = sampleDeckGroups(PlayMode::Gate);
|
||||
const std::vector<DeckGroupDesc> trig = sampleDeckGroups(PlayMode::Trigger);
|
||||
const DeckGroupDesc& a = gate[static_cast<std::size_t>(indexOfGroup(gate, kGroupAmpEnv))];
|
||||
const DeckGroupDesc& b = trig[static_cast<std::size_t>(indexOfGroup(trig, kGroupAmpEnv))];
|
||||
CHECK(deckGroupWidth(a) == deckGroupWidth(b));
|
||||
CHECK(a.cellIds.size() == b.cellIds.size());
|
||||
CHECK(b.cellIds[3] == -1 && b.cellIds[4] == -1);
|
||||
// Every other group is mode-independent, so the whole deck's height is too.
|
||||
CHECK(deckHeight(gate, kAvailAtDefaultWidth) == deckHeight(trig, kAvailAtDefaultWidth));
|
||||
CHECK(deckHeight(gate, kAvailAtMinWidth) == deckHeight(trig, kAvailAtMinWidth));
|
||||
}
|
||||
|
||||
static void testWrappedDeckHeightAtThePinnedEditorWidths() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
// At the default 840 the deck takes two rows: PITCH + PITCH ENV + FILTER fill the first,
|
||||
// the remaining four fit the second.
|
||||
CHECK(deckRowCount(g, kAvailAtDefaultWidth) == 2);
|
||||
CHECK(deckHeight(g, kAvailAtDefaultWidth) == 2 * kDeckGroupH + kDeckRowGap);
|
||||
// At the 560 floor it takes four; FILTER is wider than the row on its own.
|
||||
CHECK(deckRowCount(g, kAvailAtMinWidth) == 4);
|
||||
CHECK(deckHeight(g, kAvailAtMinWidth) == 4 * kDeckGroupH + 3 * kDeckRowGap);
|
||||
|
||||
// Whole groups only, never split: every group's box lies inside the available width or is
|
||||
// the first of its row.
|
||||
const DeckLayout dl = layoutDeck(g, 8, 0, kAvailAtDefaultWidth);
|
||||
CHECK(dl.groups.size() == g.size());
|
||||
for (const DeckGroupLayout& gl : dl.groups) {
|
||||
CHECK(gl.box.x >= 8);
|
||||
CHECK(gl.box.height == kDeckGroupH);
|
||||
}
|
||||
}
|
||||
|
||||
static void testHitTestResolvesTheNewFilterControls() {
|
||||
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
|
||||
const DeckLayout dl = layoutDeck(g, 8, 40, kAvailAtDefaultWidth);
|
||||
const DeckGroupLayout& f =
|
||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilter))];
|
||||
|
||||
// Every knob cell resolves to its own id, from the centre of its cell.
|
||||
for (const DeckCellLayout& c : f.cells) {
|
||||
const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2,
|
||||
c.cell.y + c.cell.height / 2);
|
||||
CHECK(hit.kind == DeckHitKind::Knob);
|
||||
CHECK(hit.id == c.id);
|
||||
}
|
||||
CHECK(f.cells.size() == 7);
|
||||
CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff));
|
||||
|
||||
// The enable toggle's two segments and the morph-law row toggle's two.
|
||||
const DeckHit off = hitTestDeck(dl, f.captionToggle.seg0.x + 2,
|
||||
f.captionToggle.seg0.y + 2);
|
||||
CHECK(off.kind == DeckHitKind::CaptionToggle);
|
||||
CHECK(off.id == cell(DeckParam::kFilterEnable) && off.segment == 0);
|
||||
const DeckHit on = hitTestDeck(dl, f.captionToggle.seg1.x + 2,
|
||||
f.captionToggle.seg1.y + 2);
|
||||
CHECK(on.id == cell(DeckParam::kFilterEnable) && on.segment == 1);
|
||||
|
||||
const DeckHit band = hitTestDeck(dl, f.rowToggle.seg0.x + 2, f.rowToggle.seg0.y + 2);
|
||||
CHECK(band.kind == DeckHitKind::RowToggle);
|
||||
CHECK(band.id == cell(DeckParam::kFilterLaw) && band.segment == 0);
|
||||
const DeckHit notch = hitTestDeck(dl, f.rowToggle.seg1.x + 2, f.rowToggle.seg1.y + 2);
|
||||
CHECK(notch.id == cell(DeckParam::kFilterLaw) && notch.segment == 1);
|
||||
|
||||
// The filter-envelope knobs resolve too, and are distinct ids from the amp's.
|
||||
const DeckGroupLayout& fe =
|
||||
dl.groups[static_cast<std::size_t>(indexOfGroup(g, kGroupFilterEnv))];
|
||||
const DeckHit attack = hitTestDeck(dl, fe.cells[0].cell.x + 4, fe.cells[0].cell.y + 4);
|
||||
CHECK(attack.kind == DeckHitKind::Knob);
|
||||
CHECK(attack.id == cell(DeckParam::kFilterEnvAttack));
|
||||
CHECK(attack.id != cell(DeckParam::kAttack));
|
||||
}
|
||||
|
||||
static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
|
||||
// Centre is EXACT in both directions: a knob parked at 0.5 stores 0, and 0 reads back
|
||||
// 0.5 — no residual modulation from a rounding hair.
|
||||
CHECK(deckBipolarFromNorm(0.5) == 0.0);
|
||||
CHECK(deckNormFromBipolar(0.0) == 0.5);
|
||||
CHECK(deckBipolarFromNorm(0.0) == -1.0);
|
||||
CHECK(deckBipolarFromNorm(1.0) == 1.0);
|
||||
for (int i = 0; i <= 200; ++i) {
|
||||
const double norm = static_cast<double>(i) / 200.0;
|
||||
CHECK(std::fabs(deckNormFromBipolar(deckBipolarFromNorm(norm)) - norm) < 1e-12);
|
||||
const double value = -1.0 + static_cast<double>(i) / 100.0;
|
||||
CHECK(std::fabs(deckBipolarFromNorm(deckNormFromBipolar(value)) - value) < 1e-12);
|
||||
}
|
||||
// Out of range clamps rather than extrapolating.
|
||||
CHECK(deckBipolarFromNorm(-3.0) == -1.0);
|
||||
CHECK(deckBipolarFromNorm(3.0) == 1.0);
|
||||
CHECK(deckNormFromBipolar(-3.0) == 0.0);
|
||||
CHECK(deckNormFromBipolar(3.0) == 1.0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||
testFilterGroupCarriesItsFiveToneControlsPlusModulation();
|
||||
testAmpGroupWidthSurvivesAGateTriggerFlip();
|
||||
testWrappedDeckHeightAtThePinnedEditorWidths();
|
||||
testHitTestResolvesTheNewFilterControls();
|
||||
testBipolarKnobLawRoundTripsAndIsExactAtCentre();
|
||||
if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// Standalone tests for the filter's place in the VOICE PATH — no VST3, no REAPER, no
|
||||
// framework. The filter's own numerical behaviour is filter_tests / filter_state_tests /
|
||||
// filter_morph_tests / filter_params_tests; this file asserts only the integration: that a
|
||||
// disengaged filter is bit-inert, that it sits between the pitch stage and the amp stage,
|
||||
// that each voice runs its own, and that the three cutoff-modulation sources reach it.
|
||||
|
||||
#include "../src/core/instrument/engine/voice.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <type_traits>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
namespace flt = reasampler::instrument::engine::filter;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
constexpr int kRate = 48000;
|
||||
|
||||
// The port's whole point, asserted from the CALL SITE as well as inside the module: neither
|
||||
// the voice nor the envelope it drives may grow a vtable, and the filter stays a plain value
|
||||
// member (trivially copyable => nothing heap-owned, so process() cannot allocate).
|
||||
static_assert(!std::is_polymorphic_v<Voice>, "no vtable on the per-sample path");
|
||||
static_assert(!std::is_polymorphic_v<AdsrEnvelope>, "the filter envelope must inline");
|
||||
static_assert(std::is_trivially_copyable_v<flt::VoiceFilter>, "filter state is plain values");
|
||||
|
||||
// A sine whose period is EXACTLY `period` frames, so two voices started `period` apart read
|
||||
// identical values at every absolute frame — that is what isolates envelope phase from read
|
||||
// position in the per-voice-independence test.
|
||||
static SampleData periodicSine(std::size_t frames, double period, int rootNote = 60) {
|
||||
SampleData s;
|
||||
s.frames.resize(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / period));
|
||||
}
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = rootNote;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Amp envelope held wide open, so a rendered frame is exactly the (filtered) source.
|
||||
static AdsrParams flatAdsr() {
|
||||
AdsrParams a;
|
||||
a.sustainLevel = 1.0;
|
||||
return a;
|
||||
}
|
||||
|
||||
static FilterParams engagedFilter(float cutoffNorm, float resNorm, float morphNorm) {
|
||||
FilterParams f;
|
||||
f.enabled = true;
|
||||
f.settings.cutoffNorm = cutoffNorm;
|
||||
f.settings.resonanceNorm = resNorm;
|
||||
f.settings.morphNorm = morphNorm;
|
||||
f.env.sustainLevel = 1.0;
|
||||
return f;
|
||||
}
|
||||
|
||||
static std::vector<double> render(SampleData& sample, int note, int velocity, int frames) {
|
||||
Voice v;
|
||||
v.start(note, velocity, sample);
|
||||
std::vector<double> out(static_cast<std::size_t>(frames), 0.0);
|
||||
for (int i = 0; i < frames; ++i) out[static_cast<std::size_t>(i)] = v.renderFrame();
|
||||
return out;
|
||||
}
|
||||
|
||||
static double rms(const std::vector<double>& x, std::size_t from, std::size_t to) {
|
||||
double acc = 0.0;
|
||||
for (std::size_t i = from; i < to; ++i) acc += x[i] * x[i];
|
||||
return std::sqrt(acc / static_cast<double>(to - from));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Off by default.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() {
|
||||
SampleData plain = periodicSine(2000, 64);
|
||||
plain.play.adsr = flatAdsr();
|
||||
|
||||
// Independent reference: with a flat amp envelope, unity velocity curve and the note at
|
||||
// the root, the rendered frame IS the source frame. Asserting against this rather than
|
||||
// against another render of the same code proves the un-filtered path is still correct,
|
||||
// not merely self-consistent.
|
||||
const std::vector<double> bare = render(plain, 60, 100, 1500);
|
||||
for (std::size_t i = 0; i < bare.size(); ++i) {
|
||||
CHECK(static_cast<float>(bare[i]) == plain.frames[i]);
|
||||
}
|
||||
|
||||
// Now store the most violent filter settings available and leave `enabled` false. A blob
|
||||
// that carries filter parameters must sound exactly as it did before they existed.
|
||||
SampleData stored = periodicSine(2000, 64);
|
||||
stored.play.adsr = flatAdsr();
|
||||
stored.play.filter = engagedFilter(0.0f, 1.0f, 1.0f);
|
||||
stored.play.filter.enabled = false;
|
||||
stored.play.filter.settings.driveNorm = 1.0f;
|
||||
stored.play.filter.modAmount = 1.0;
|
||||
stored.play.filter.velAmount = -1.0;
|
||||
stored.play.filter.keyTrack = 2.0;
|
||||
const std::vector<double> inert = render(stored, 60, 100, 1500);
|
||||
for (std::size_t i = 0; i < inert.size(); ++i) CHECK(inert[i] == bare[i]);
|
||||
|
||||
// And engaging it must actually do something — otherwise the bit-identity above would be
|
||||
// satisfied by a filter that never runs.
|
||||
SampleData live = periodicSine(2000, 64);
|
||||
live.play.adsr = flatAdsr();
|
||||
live.play.filter = engagedFilter(0.0f, 0.0f, 1.0f); // low-pass, corner at 20 Hz
|
||||
const std::vector<double> filtered = render(live, 60, 100, 1500);
|
||||
CHECK(rms(filtered, 500, 1500) < 0.05 * rms(bare, 500, 1500));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline position: pitch -> filter -> amp.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly() {
|
||||
// With a NONLINEAR drive stage engaged, the two orderings are distinguishable: a filter
|
||||
// fed the post-amp signal would see a half-level input and shape it differently, so the
|
||||
// two renders could not be an exact factor of two apart. Filter-before-amp makes them
|
||||
// exactly that, because the amp multiply is the last thing to happen.
|
||||
const auto renderAtSustain = [](double sustain) {
|
||||
SampleData s = periodicSine(2000, 64);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.adsr.sustainLevel = sustain;
|
||||
s.play.filter = engagedFilter(0.35f, 0.9f, 1.0f);
|
||||
s.play.filter.settings.driveNorm = 1.0f; // hard nonlinearity in the resonance path
|
||||
return render(s, 60, 100, 1200);
|
||||
};
|
||||
const std::vector<double> half = renderAtSustain(0.5);
|
||||
const std::vector<double> full = renderAtSustain(1.0);
|
||||
|
||||
bool sawSignal = false;
|
||||
// Frame 0 is the AHDSR's attack peak (1.0) in BOTH runs; the sustain level takes over
|
||||
// from frame 1.
|
||||
for (std::size_t i = 1; i < half.size(); ++i) {
|
||||
CHECK(half[i] == 0.5 * full[i]);
|
||||
if (std::fabs(full[i]) > 1e-6) sawSignal = true;
|
||||
}
|
||||
CHECK(sawSignal);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-voice, not instance-wide.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently() {
|
||||
// Both voices read the SAME source value at every absolute frame (voice B starts exactly
|
||||
// one sine period late), and both amp envelopes are wide open — so the only thing that
|
||||
// can separate their outputs is their own filter-envelope phase. A single shared filter
|
||||
// would hand both voices one cutoff and one integrator pair, and they would match.
|
||||
constexpr int kPeriod = 64;
|
||||
SampleData s = periodicSine(20000, kPeriod);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.filter = engagedFilter(0.0f, 0.2f, 1.0f);
|
||||
s.play.filter.modAmount = 1.0; // envelope sweeps cutoff over the full range
|
||||
s.play.filter.env.attackFrames = 8000; // slow, so the two phases stay far apart
|
||||
s.play.filter.env.sustainLevel = 1.0;
|
||||
|
||||
Voice a;
|
||||
Voice b;
|
||||
a.start(60, 100, s);
|
||||
for (int i = 0; i < kPeriod; ++i) a.renderFrame();
|
||||
b.start(60, 100, s);
|
||||
|
||||
int differing = 0;
|
||||
for (int i = 0; i < 4000; ++i) {
|
||||
const double fa = a.renderFrame();
|
||||
const double fb = b.renderFrame();
|
||||
if (std::fabs(fa - fb) > 1e-6) ++differing;
|
||||
}
|
||||
CHECK(differing > 3000);
|
||||
|
||||
// The same staggered pair with the MODULATION removed converges: each voice still owns
|
||||
// its own integrators, so the stagger costs a short start-up transient, but with no
|
||||
// envelope reaching cutoff the two settle onto the identical steady state. That is what
|
||||
// makes the count above evidence of per-voice ENVELOPE phase rather than of the stagger.
|
||||
SampleData flat = periodicSine(20000, kPeriod);
|
||||
flat.play.adsr = flatAdsr();
|
||||
flat.play.filter = engagedFilter(0.5f, 0.2f, 1.0f);
|
||||
Voice c;
|
||||
Voice d;
|
||||
c.start(60, 100, flat);
|
||||
for (int i = 0; i < kPeriod; ++i) c.renderFrame();
|
||||
d.start(60, 100, flat);
|
||||
int settledDiffering = 0;
|
||||
for (int i = 0; i < 4000; ++i) {
|
||||
const double fc = c.renderFrame();
|
||||
const double fd = d.renderFrame();
|
||||
if (i >= 2000 && fc != fd) ++settledDiffering;
|
||||
}
|
||||
CHECK(settledDiffering == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modulation: envelope depth, velocity, key tracking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testModAmountPolarityDrivesCutoffFromOppositeEnds() {
|
||||
// A low-pass over a 750 Hz tone: energy tracks cutoff, so the sweep direction is readable
|
||||
// straight off the output's RMS. +100% from a closed filter opens it; -100% from an open
|
||||
// one closes it; 0% leaves it where the knob is.
|
||||
const auto sweep = [](float cutoffNorm, double modAmount) {
|
||||
SampleData s = periodicSine(20000, 64);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.filter = engagedFilter(cutoffNorm, 0.0f, 1.0f);
|
||||
s.play.filter.modAmount = modAmount;
|
||||
s.play.filter.env.attackFrames = 12000;
|
||||
s.play.filter.env.sustainLevel = 1.0;
|
||||
return render(s, 60, 100, 12000);
|
||||
};
|
||||
|
||||
const std::vector<double> rising = sweep(0.0f, 1.0);
|
||||
CHECK(rms(rising, 10000, 12000) > 20.0 * rms(rising, 0, 2000));
|
||||
|
||||
const std::vector<double> falling = sweep(1.0f, -1.0);
|
||||
CHECK(rms(falling, 10000, 12000) < 0.05 * rms(falling, 0, 2000));
|
||||
|
||||
// Zero depth: the envelope is still running, but it must not reach cutoff at all.
|
||||
const std::vector<double> steady = sweep(0.5f, 0.0);
|
||||
const double early = rms(steady, 2000, 4000);
|
||||
const double late = rms(steady, 10000, 12000);
|
||||
CHECK(std::fabs(late - early) < 1e-6 * early + 1e-9);
|
||||
|
||||
// The two poles land at opposite ends of the same range: at full sweep the rising run
|
||||
// ends open and the falling run ends closed.
|
||||
CHECK(rms(rising, 10000, 12000) > rms(falling, 10000, 12000));
|
||||
}
|
||||
|
||||
static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() {
|
||||
// Playback key-tracking off, so both notes read the source at the SAME rate and the only
|
||||
// note-dependent difference left is the filter's own key-tracking.
|
||||
const auto tone = [](double velAmount, double keyTrack) {
|
||||
SampleData s = periodicSine(8000, 64);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.keyTrack = 0.0;
|
||||
s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f);
|
||||
s.play.filter.velAmount = velAmount;
|
||||
s.play.filter.keyTrack = keyTrack;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Velocity: the default linear curve rises with velocity, so a positive depth opens the
|
||||
// filter for a hard hit. The amp's own velocity curve is flat, so amplitude is unaffected.
|
||||
SampleData vel = tone(1.0, 0.0);
|
||||
const std::vector<double> soft = render(vel, 60, 1, 6000);
|
||||
const std::vector<double> hard = render(vel, 60, 127, 6000);
|
||||
CHECK(rms(hard, 2000, 6000) > 2.0 * rms(soft, 2000, 6000));
|
||||
|
||||
// Key tracking: two octaves up opens it by two octaves of cutoff.
|
||||
SampleData key = tone(0.0, 1.0);
|
||||
const std::vector<double> low = render(key, 60, 100, 6000);
|
||||
const std::vector<double> high = render(key, 84, 100, 6000);
|
||||
CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000));
|
||||
|
||||
// Both neutral: neither velocity nor note may move the filter.
|
||||
SampleData neutral = tone(0.0, 0.0);
|
||||
const std::vector<double> a = render(neutral, 60, 1, 6000);
|
||||
const std::vector<double> b = render(neutral, 60, 127, 6000);
|
||||
const std::vector<double> c = render(neutral, 84, 100, 6000);
|
||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
||||
CHECK(a[i] == b[i]);
|
||||
CHECK(a[i] == c[i]);
|
||||
}
|
||||
}
|
||||
|
||||
static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() {
|
||||
SampleData s = periodicSine(8000, 64);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.filter = engagedFilter(0.2f, 0.95f, 1.0f); // high Q: a long, obvious ring
|
||||
|
||||
Voice v;
|
||||
v.start(60, 100, s);
|
||||
for (int i = 0; i < 4000; ++i) v.renderFrame(); // fill the integrators
|
||||
v.start(60, 100, s); // restart: reset() must clear them
|
||||
std::vector<double> restarted(1000);
|
||||
for (int i = 0; i < 1000; ++i) restarted[static_cast<std::size_t>(i)] = v.renderFrame();
|
||||
|
||||
const std::vector<double> fresh = render(s, 60, 100, 1000);
|
||||
for (std::size_t i = 0; i < fresh.size(); ++i) CHECK(restarted[i] == fresh[i]);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored();
|
||||
testFilterSeesThePreAmpSignalSoAmpGainScalesTheResultLinearly();
|
||||
testTwoVoicesAtDifferentEnvelopePhasesFilterIndependently();
|
||||
testModAmountPolarityDrivesCutoffFromOppositeEnds();
|
||||
testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults();
|
||||
testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak();
|
||||
if (g_fail == 0) std::printf("sampler_filter: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user