S12: store wall-clock ADSR/pitch-env as seconds, resolve to frames at live rate
Kill kTier0Nominal*, adsrNeedsRateResolve, tier0Adsr, gateAdsr. Zones payload v5 carries seconds; v3 legacy reads convert at the frozen authoring rate; v4 (branch-only) dropped. Editor sliders now seconds. Engine takes frames resolved at keymap build.
This commit is contained in:
+227
-121
@@ -1031,37 +1031,157 @@ static void testComponentStateV5TruncatedMarker() {
|
||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||
}
|
||||
|
||||
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
|
||||
//
|
||||
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
|
||||
// ps-w9-t2-modes (payload v3, adds the per-zone play params) makes THREE combinations first
|
||||
// reachable. Each pre-existing suite covers one axis in isolation; these lock the axes together.
|
||||
|
||||
static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() {
|
||||
// (a) The full v5 face: channelMode + the S8/S9 consumed marker (envelope) AND zones carrying
|
||||
// S15/S16 play params (payload) must ALL survive one serialize/deserialize. The two extensions
|
||||
// sit on orthogonal tracks (envelope vs self-versioned payload); this proves they compose with
|
||||
// no field cross-talk — neither the marker read nor the play-param read consumes the other's bytes.
|
||||
ComponentState s;
|
||||
s.selectionId = "pick";
|
||||
s.channelMode = ChannelMode::Stereo;
|
||||
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX -> exercises the full 8-byte field
|
||||
PerformanceZone z = zone("z0", 0, 127, /*override=*/48);
|
||||
z.play.playMode = PlayMode::Trigger;
|
||||
z.play.adsr.holdSeconds = 0.093;
|
||||
z.play.trigger.lengthFraction = 0.625;
|
||||
z.play.trigger.fadeInFrames = 32;
|
||||
z.play.trigger.fadeOutFrames = 96;
|
||||
z.play.pitchEngine = PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = true;
|
||||
z.play.pitchEnv.attackSeconds = 0.00018;
|
||||
z.play.pitchEnv.decaySeconds = 0.0145;
|
||||
z.play.pitchEnv.peakSemitones = 12.5;
|
||||
s.map.zones.push_back(z);
|
||||
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
||||
CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode
|
||||
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker
|
||||
CHECK(back.selectionId == "pick");
|
||||
CHECK(back.map.zones.size() == 1);
|
||||
if (back.map.zones.size() != 1) return;
|
||||
const ZonePlaySeconds& p = back.map.zones[0].play; // payload: play params
|
||||
CHECK(p.playMode == PlayMode::Trigger);
|
||||
CHECK(p.adsr.holdSeconds == 0.093);
|
||||
CHECK(p.trigger.lengthFraction == 0.625);
|
||||
CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96);
|
||||
CHECK(p.pitchEngine == PitchEngine::Varispeed);
|
||||
CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 &&
|
||||
p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5);
|
||||
}
|
||||
|
||||
// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the
|
||||
// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/
|
||||
// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs
|
||||
// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain
|
||||
// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer.
|
||||
static std::vector<std::uint8_t> handBuildV3PayloadOneZone(const std::string& id) {
|
||||
std::vector<std::uint8_t> b;
|
||||
auto u32 = [&](std::uint32_t v) {
|
||||
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
|
||||
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
|
||||
};
|
||||
auto u64 = [&](std::uint64_t v) {
|
||||
for (int i = 0; i < 8; ++i) b.push_back(static_cast<std::uint8_t>((v >> (i * 8)) & 0xFF));
|
||||
};
|
||||
auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); };
|
||||
u32(kZonesFormatMarker);
|
||||
u32(3); // PAYLOAD VERSION 3 (S15/S16 play tail present)
|
||||
u32(1); // zone count 1
|
||||
u32(static_cast<std::uint32_t>(id.size()));
|
||||
b.insert(b.end(), id.begin(), id.end());
|
||||
u32(10); // lowNote
|
||||
u32(70); // highNote
|
||||
b.push_back(0); // hasRootOverride = 0
|
||||
b.push_back(0); // hasLoopOverride = 0
|
||||
b.push_back(0); // hasStartPoint = 0
|
||||
// Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off.
|
||||
b.push_back(1); // playMode = Trigger
|
||||
u64(static_cast<std::uint64_t>(2048)); // adsr.holdFrames
|
||||
dbl(0.5); // trigger.lengthFraction
|
||||
u64(static_cast<std::uint64_t>(16)); // trigger.fadeInFrames
|
||||
u64(static_cast<std::uint64_t>(48)); // trigger.fadeOutFrames
|
||||
b.push_back(0); // pitchEngine = Varispeed
|
||||
b.push_back(0); // pitchEnv.enabled = 0
|
||||
u64(0); // pitchEnv.attackFrames
|
||||
u64(0); // pitchEnv.decayFrames
|
||||
dbl(0.0); // pitchEnv.peakSemitones
|
||||
return b;
|
||||
}
|
||||
|
||||
static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() {
|
||||
// (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones
|
||||
// payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build
|
||||
// save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to
|
||||
// 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who
|
||||
// saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates
|
||||
// zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so
|
||||
// the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished
|
||||
// inside the payload, not on the envelope.
|
||||
std::vector<std::uint8_t> v4;
|
||||
v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // ENVELOPE version 4
|
||||
v4.push_back(1); // channel mode = stereo
|
||||
const std::string id = "s15saved";
|
||||
v4.push_back(static_cast<std::uint8_t>(id.size()));
|
||||
v4.push_back(0); v4.push_back(0); v4.push_back(0); // idLen (LE)
|
||||
v4.insert(v4.end(), id.begin(), id.end());
|
||||
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("zv3");
|
||||
v4.insert(v4.end(), payload.begin(), payload.end());
|
||||
|
||||
const ComponentState back = deserializeComponentState(v4);
|
||||
CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0
|
||||
CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored
|
||||
CHECK(back.selectionId == "s15saved");
|
||||
CHECK(back.map.zones.size() == 1); // (c) payload-v3 zone parsed under widened check
|
||||
if (back.map.zones.size() != 1) return;
|
||||
CHECK(back.map.zones[0].sampleId == "zv3");
|
||||
CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70);
|
||||
const ZonePlaySeconds& p = back.map.zones[0].play;
|
||||
CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope
|
||||
// Legacy v3 wall-clock frames (44.1k-nominal) convert to seconds at the v3 authoring rate.
|
||||
CHECK(approx(p.adsr.holdSeconds, 2048.0 / kLegacyV3NominalRate));
|
||||
CHECK(p.trigger.lengthFraction == 0.5);
|
||||
CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is
|
||||
CHECK(p.pitchEngine == PitchEngine::Varispeed);
|
||||
CHECK(p.pitchEnv.enabled == false);
|
||||
}
|
||||
|
||||
// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift -------------
|
||||
|
||||
static void testPlayParamsRoundTrip() {
|
||||
// A zone carrying explicit S15/S16 play params (Trigger mode, hold, fades, Varispeed engine,
|
||||
// pitch env on) must round-trip ALL fields losslessly through the payload-v3 tail.
|
||||
// A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades,
|
||||
// Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail.
|
||||
PerformanceMap m;
|
||||
PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
|
||||
z.play.playMode = PlayMode::Trigger;
|
||||
z.play.adsr.holdFrames = 1234;
|
||||
z.play.adsr.holdSeconds = 0.028; // wall-clock seconds
|
||||
z.play.trigger.lengthFraction = 0.375;
|
||||
z.play.trigger.fadeInFrames = 64;
|
||||
z.play.trigger.fadeInFrames = 64; // source frames
|
||||
z.play.trigger.fadeOutFrames = 128;
|
||||
z.play.pitchEngine = PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = true;
|
||||
z.play.pitchEnv.attackFrames = 10;
|
||||
z.play.pitchEnv.decayFrames = 500;
|
||||
z.play.pitchEnv.attackSeconds = 0.0002; // wall-clock seconds
|
||||
z.play.pitchEnv.decaySeconds = 0.011;
|
||||
z.play.pitchEnv.peakSemitones = -7.5;
|
||||
m.zones.push_back(z);
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) return;
|
||||
const ZonePlayParams& p = back.zones[0].play;
|
||||
const ZonePlaySeconds& p = back.zones[0].play;
|
||||
CHECK(p.playMode == PlayMode::Trigger);
|
||||
CHECK(p.adsr.holdFrames == 1234);
|
||||
CHECK(p.adsr.holdSeconds == 0.028); // exact double round-trip
|
||||
CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip
|
||||
CHECK(p.trigger.fadeInFrames == 64);
|
||||
CHECK(p.trigger.fadeOutFrames == 128);
|
||||
CHECK(p.pitchEngine == PitchEngine::Varispeed);
|
||||
CHECK(p.pitchEnv.enabled == true);
|
||||
CHECK(p.pitchEnv.attackFrames == 10);
|
||||
CHECK(p.pitchEnv.decayFrames == 500);
|
||||
CHECK(p.pitchEnv.attackSeconds == 0.0002);
|
||||
CHECK(p.pitchEnv.decaySeconds == 0.011);
|
||||
CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip
|
||||
}
|
||||
|
||||
@@ -1073,7 +1193,7 @@ static void testPlayParamsComposeWithLoopStart() {
|
||||
z.loopOverride = lp;
|
||||
z.startPoint = 333;
|
||||
z.play.playMode = PlayMode::Gate;
|
||||
z.play.adsr.holdFrames = 999;
|
||||
z.play.adsr.holdSeconds = 0.0225;
|
||||
z.play.pitchEngine = PitchEngine::Preserve;
|
||||
m.zones.push_back(z);
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
@@ -1082,7 +1202,7 @@ static void testPlayParamsComposeWithLoopStart() {
|
||||
CHECK(back.zones[0].loopOverride.has_value() &&
|
||||
back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222);
|
||||
CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333);
|
||||
CHECK(back.zones[0].play.adsr.holdFrames == 999);
|
||||
CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225);
|
||||
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
||||
}
|
||||
|
||||
@@ -1115,7 +1235,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() {
|
||||
CHECK(back.zones[0].play.playMode == PlayMode::Gate);
|
||||
CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve
|
||||
CHECK(back.zones[0].play.pitchEnv.enabled == false);
|
||||
CHECK(back.zones[0].play.adsr.holdFrames == 0);
|
||||
CHECK(back.zones[0].play.adsr.holdSeconds == 0.0);
|
||||
}
|
||||
|
||||
static void testPlayParamsThroughComponentEnvelope() {
|
||||
@@ -1138,44 +1258,40 @@ static void testPlayParamsThroughComponentEnvelope() {
|
||||
CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed);
|
||||
}
|
||||
|
||||
// --- S12 review fix (PAYLOAD v4): full A/D/S/R per-zone round-trip. ---------------------
|
||||
// --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. ---
|
||||
//
|
||||
// Before the fix, per-zone A/D/S/R (attack/decay/sustain/release) was not serialized;
|
||||
// only holdFrames was written. These two tests assert the corrected v4 path.
|
||||
// These tests replace the R1/R2 flag/nominal-frame tests. The stored domain is seconds (rate-free);
|
||||
// the keymap build resolves seconds -> frames against whatever WAV rate is live. The lift ->
|
||||
// commit -> reload sequence must stay rate-correct at every rate (the R2 blocker).
|
||||
|
||||
// All five AHDSR fields (including the four new A/D/S/R) must round-trip through the v4 payload.
|
||||
static void testFullAdsrV4RoundTrip() {
|
||||
// All five AHDSR fields round-trip through the v5 payload as SECONDS (exact double round-trip).
|
||||
static void testFullAdsrSecondsRoundTrip() {
|
||||
PerformanceMap m;
|
||||
PerformanceZone z = zone("pad", 0, 127);
|
||||
z.play.playMode = PlayMode::Gate;
|
||||
z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (a non-default value)
|
||||
z.play.adsr.holdFrames = 882;
|
||||
z.play.adsr.decayFrames = 4410; // 0.1 s
|
||||
z.play.adsr.sustainLevel = 0.7;
|
||||
z.play.adsr.releaseFrames = 8820; // 0.2 s
|
||||
z.play.adsr.attackSeconds = 0.01;
|
||||
z.play.adsr.holdSeconds = 0.02;
|
||||
z.play.adsr.decaySeconds = 0.1;
|
||||
z.play.adsr.sustainLevel = 0.7;
|
||||
z.play.adsr.releaseSeconds = 0.2;
|
||||
z.play.pitchEngine = PitchEngine::Preserve;
|
||||
m.zones.push_back(z);
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) return;
|
||||
const AdsrParams& a = back.zones[0].play.adsr;
|
||||
CHECK(a.attackFrames == 441);
|
||||
CHECK(a.holdFrames == 882);
|
||||
CHECK(a.decayFrames == 4410);
|
||||
CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast
|
||||
CHECK(a.releaseFrames == 8820);
|
||||
const AdsrSeconds& a = back.zones[0].play.adsr;
|
||||
CHECK(a.attackSeconds == 0.01);
|
||||
CHECK(a.holdSeconds == 0.02);
|
||||
CHECK(a.decaySeconds == 0.1);
|
||||
CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast
|
||||
CHECK(a.releaseSeconds == 0.2);
|
||||
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
||||
}
|
||||
|
||||
// A genuine PAYLOAD v3 blob (S15/S16 build — has holdFrames but not A/D/S/R) must lift
|
||||
// attackFrames / decayFrames / sustainLevel / releaseFrames to the tier-0 nominal defaults
|
||||
// (kTier0Nominal* constants) so the voice sounds bit-identical to the pre-fix behavior.
|
||||
// We reuse handBuildV3PayloadOneZone which emits a valid marker-versioned v3 payload.
|
||||
static void testV3BlobLiftsAdsrToNominalDefaults() {
|
||||
// Build a PERFORMANCE blob: 4-byte kPerformanceStateVersion header + v3 zones payload.
|
||||
// deserializePerformance strips the 4-byte header and passes the rest to readZonesPayload,
|
||||
// which self-selects the v3 record shape from the payload marker+version — exercising the
|
||||
// real production lift path for a user who saved on the S15 build.
|
||||
// A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the
|
||||
// absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they
|
||||
// were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count.
|
||||
static void testV3BlobLiftsAdsrToSecondsDefaults() {
|
||||
std::vector<std::uint8_t> blob;
|
||||
auto u32 = [&](std::uint32_t v) {
|
||||
blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
|
||||
@@ -1187,91 +1303,81 @@ static void testV3BlobLiftsAdsrToNominalDefaults() {
|
||||
const PerformanceMap back = deserializePerformance(blob);
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) return;
|
||||
const AdsrParams& a = back.zones[0].play.adsr;
|
||||
// holdFrames comes from the v3 record itself; A/D/S/R must lift to the nominal tier-0 values.
|
||||
CHECK(a.holdFrames == 2048); // from the hand-built v3 record
|
||||
CHECK(a.attackFrames == kTier0NominalAttackFrames); // 132 (0.003 s at 44100)
|
||||
CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0
|
||||
CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0
|
||||
CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100)
|
||||
// The lifted zone must be flagged for rate-resolve so buildZonedKeymap rescales at the live rate.
|
||||
CHECK(back.zones[0].adsrNeedsRateResolve == true);
|
||||
const AdsrSeconds& a = back.zones[0].play.adsr;
|
||||
// hold converts from the v3 record's 44.1k-nominal frames; A/D/S/R lift to the seconds defaults.
|
||||
CHECK(approx(a.holdSeconds, 2048.0 / kLegacyV3NominalRate)); // from the hand-built v3 record
|
||||
CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds)
|
||||
CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0
|
||||
CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0
|
||||
CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060
|
||||
}
|
||||
|
||||
// A v4 blob (explicit A/D/S/R tail) must NOT set adsrNeedsRateResolve — those values
|
||||
// were authored at the DAW's rate and must not be rescaled again at keymap build time.
|
||||
static void testV4BlobClearsAdsrNeedsRateResolve() {
|
||||
PerformanceMap m;
|
||||
PerformanceZone z = zone("pad", 0, 127);
|
||||
z.play.adsr.attackFrames = 441;
|
||||
z.play.adsr.releaseFrames = 8820;
|
||||
m.zones.push_back(z);
|
||||
// A round-trip through serialize/deserialize writes a v4 payload (current version).
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) return;
|
||||
// v4 tail was explicitly read — adsrNeedsRateResolve must be false.
|
||||
CHECK(back.zones[0].adsrNeedsRateResolve == false);
|
||||
// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone
|
||||
// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate));
|
||||
// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates.
|
||||
static void testKeymapBuildResolvesSecondsToFramesAtEachRate() {
|
||||
const auto rnd = [](double s, int rate) {
|
||||
return static_cast<std::int64_t>(s * static_cast<double>(rate) + 0.5);
|
||||
};
|
||||
for (int rate : {44100, 48000, 96000}) {
|
||||
// (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds.
|
||||
{
|
||||
PerformanceMap m;
|
||||
m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds)
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) continue;
|
||||
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
|
||||
rz.play = back.zones[0].play;
|
||||
const DecodedZonePcm pcm{{0.5f}, rate};
|
||||
const Keymap km = buildZonedKeymap({rz}, {pcm});
|
||||
CHECK(km.samples.size() == 1);
|
||||
if (km.samples.empty()) continue;
|
||||
const AdsrParams& a = km.samples[0].play.adsr;
|
||||
CHECK(a.attackFrames == rnd(0.003, rate)); // tier-0 attack at this rate
|
||||
CHECK(a.decayFrames == 0);
|
||||
CHECK(a.sustainLevel == 1.0); // level, never rate-scaled
|
||||
CHECK(a.releaseFrames == rnd(0.060, rate)); // tier-0 release at this rate
|
||||
}
|
||||
// (b) AUTHORED zone -> round(seconds * rate) at this rate.
|
||||
{
|
||||
PerformanceMap m;
|
||||
PerformanceZone z = zone("auth", 0, 127);
|
||||
z.play.adsr.attackSeconds = 0.01;
|
||||
z.play.adsr.decaySeconds = 0.1;
|
||||
z.play.adsr.sustainLevel = 0.7;
|
||||
z.play.adsr.releaseSeconds = 0.2;
|
||||
m.zones.push_back(z);
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) continue;
|
||||
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
|
||||
rz.play = back.zones[0].play;
|
||||
const DecodedZonePcm pcm{{0.5f}, rate};
|
||||
const Keymap km = buildZonedKeymap({rz}, {pcm});
|
||||
CHECK(km.samples.size() == 1);
|
||||
if (km.samples.empty()) continue;
|
||||
const AdsrParams& a = km.samples[0].play.adsr;
|
||||
CHECK(a.attackFrames == rnd(0.01, rate));
|
||||
CHECK(a.decayFrames == rnd(0.1, rate));
|
||||
CHECK(a.sustainLevel == 0.7); // level, never rate-scaled
|
||||
CHECK(a.releaseFrames == rnd(0.2, rate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildTier0Keymap at 48k must produce ADSR frame counts equal to tier0Adsr(48000):
|
||||
// attack = round(kTier0NominalAttackFrames * 48000 / 44100) = round(143.67) = 144,
|
||||
// release = round(kTier0NominalReleaseFrames * 48000 / 44100) = round(2880.0) = 2880.
|
||||
// This is the "pre-fix, the Gate voice used tier0Adsr(sampleRate_)" invariant restored
|
||||
// for the single-capture fast path at any DAW rate.
|
||||
static void testBuildTier0KeymapRescalesAdsrAt48k() {
|
||||
// buildTier0Keymap resolves the default (seconds) play arg to frames at the WAV's rate — the
|
||||
// single-capture fast path. At 48k the tier-0 attack is round(0.003*48000)=144, release
|
||||
// round(0.060*48000)=2880 — identical wall-clock to any rate, no baked constant.
|
||||
static void testBuildTier0KeymapResolvesSecondsAt48k() {
|
||||
const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{});
|
||||
CHECK(km.samples.size() == 1);
|
||||
if (km.samples.empty()) return;
|
||||
const AdsrParams& a = km.samples[0].play.adsr;
|
||||
// Rescaled from 44100-nominal at 48000 Hz:
|
||||
CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0)
|
||||
CHECK(a.decayFrames == 0); // 0 * factor = 0 (no change)
|
||||
CHECK(a.sustainLevel == 1.0); // level, not frames (no rescale)
|
||||
CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0)
|
||||
}
|
||||
|
||||
// buildZonedKeymap at 48k with adsrNeedsRateResolve=true must rescale ADSR to match
|
||||
// tier0Adsr(48000), mirroring what Gate voices saw before the per-zone-ADSR fix.
|
||||
static void testBuildZonedKeymapRescalesNominalAdsrAt48k() {
|
||||
// Build a zone with nominal 44100-Hz ADSR and the needs-resolve flag (the default).
|
||||
ResolvedZone z;
|
||||
z.lowNote = 0; z.highNote = 127; z.rootNote = 60;
|
||||
z.adsrNeedsRateResolve = true; // 44100-nominal values, rescale needed
|
||||
z.play.adsr.attackFrames = kTier0NominalAttackFrames; // 132
|
||||
z.play.adsr.decayFrames = kTier0NominalDecayFrames; // 0
|
||||
z.play.adsr.sustainLevel = kTier0NominalSustainLevel; // 1.0
|
||||
z.play.adsr.releaseFrames = kTier0NominalReleaseFrames; // 2646
|
||||
const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV
|
||||
const Keymap km = buildZonedKeymap({z}, {pcm});
|
||||
CHECK(km.samples.size() == 1);
|
||||
if (km.samples.empty()) return;
|
||||
const AdsrParams& a = km.samples[0].play.adsr;
|
||||
CHECK(a.attackFrames == 144); // round(132 * 48000.0 / 44100.0)
|
||||
CHECK(a.decayFrames == 0); // 0 * factor = 0
|
||||
CHECK(a.sustainLevel == 1.0); // level, not rescaled
|
||||
CHECK(a.releaseFrames == 2880); // round(2646 * 48000.0 / 44100.0)
|
||||
}
|
||||
|
||||
// buildZonedKeymap must NOT rescale a zone whose adsrNeedsRateResolve is false —
|
||||
// those frame counts are explicitly user-authored at the DAW's rate.
|
||||
static void testBuildZonedKeymapDoesNotRescaleV4Adsr() {
|
||||
ResolvedZone z;
|
||||
z.lowNote = 0; z.highNote = 127; z.rootNote = 60;
|
||||
z.adsrNeedsRateResolve = false; // v4 blob or user-edited — do not rescale
|
||||
z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (non-nominal)
|
||||
z.play.adsr.decayFrames = 4410;
|
||||
z.play.adsr.sustainLevel = 0.7;
|
||||
z.play.adsr.releaseFrames = 8820;
|
||||
const DecodedZonePcm pcm{{0.5f}, 48000}; // 48k WAV — rescale would change values
|
||||
const Keymap km = buildZonedKeymap({z}, {pcm});
|
||||
CHECK(km.samples.size() == 1);
|
||||
if (km.samples.empty()) return;
|
||||
const AdsrParams& a = km.samples[0].play.adsr;
|
||||
CHECK(a.attackFrames == 441); // unchanged (not rescaled)
|
||||
CHECK(a.decayFrames == 4410);
|
||||
CHECK(a.sustainLevel == 0.7);
|
||||
CHECK(a.releaseFrames == 8820);
|
||||
CHECK(a.attackFrames == 144); // round(0.003 * 48000)
|
||||
CHECK(a.decayFrames == 0);
|
||||
CHECK(a.sustainLevel == 1.0); // level, not a time
|
||||
CHECK(a.releaseFrames == 2880); // round(0.060 * 48000)
|
||||
}
|
||||
|
||||
int main() {
|
||||
@@ -1325,12 +1431,10 @@ int main() {
|
||||
testPlayParamsComposeWithLoopStart();
|
||||
testPlayParamsV2BackCompatLiftsToDefaults();
|
||||
testPlayParamsThroughComponentEnvelope();
|
||||
testFullAdsrV4RoundTrip();
|
||||
testV3BlobLiftsAdsrToNominalDefaults();
|
||||
testV4BlobClearsAdsrNeedsRateResolve();
|
||||
testBuildTier0KeymapRescalesAdsrAt48k();
|
||||
testBuildZonedKeymapRescalesNominalAdsrAt48k();
|
||||
testBuildZonedKeymapDoesNotRescaleV4Adsr();
|
||||
testFullAdsrSecondsRoundTrip();
|
||||
testV3BlobLiftsAdsrToSecondsDefaults();
|
||||
testKeymapBuildResolvesSecondsToFramesAtEachRate();
|
||||
testBuildTier0KeymapResolvesSecondsAt48k();
|
||||
testComponentStateRoundTrip();
|
||||
testComponentStateLoopStartRoundTrip();
|
||||
testComponentStateSelectionOnlyNoZones();
|
||||
@@ -1358,6 +1462,8 @@ int main() {
|
||||
testComponentStateDefaultMarkerIsZero();
|
||||
testComponentStateV4LiftsMarkerToZero();
|
||||
testComponentStateV5TruncatedMarker();
|
||||
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
||||
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
||||
|
||||
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||
return g_fail != 0;
|
||||
|
||||
+57
-58
@@ -149,7 +149,7 @@ static void testRepitchObservedPeriod() {
|
||||
// Unity: played at root, observed period ~= native.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||
VoiceEngine eng(4, km, flatAdsr());
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, frames);
|
||||
@@ -159,7 +159,7 @@ static void testRepitchObservedPeriod() {
|
||||
// +1 octave: advances 2x, observed period halves.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||
VoiceEngine eng(4, km, flatAdsr());
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(72, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, frames / 2); // half as many frames covers the whole sample
|
||||
@@ -169,7 +169,7 @@ static void testRepitchObservedPeriod() {
|
||||
// -1 octave: advances 0.5x, observed period doubles.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||
VoiceEngine eng(4, km, flatAdsr());
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(48, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, frames);
|
||||
@@ -285,7 +285,7 @@ static void testAdsrZeroAttackDecay() {
|
||||
|
||||
static void testPolyphonicAllocation() {
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
|
||||
VoiceEngine eng(8, km, flatAdsr());
|
||||
VoiceEngine eng(8, km);
|
||||
|
||||
// Four simultaneous notes -> four active voices, each on a distinct voice.
|
||||
std::size_t v60 = eng.noteOn(60, 100);
|
||||
@@ -329,10 +329,11 @@ static void testNoteOffReleasesNewestSameNote() {
|
||||
const double gainOld = velOld / 127.0; // ~0.504
|
||||
const double gainNew = velNew / 127.0; // 1.0
|
||||
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60));
|
||||
AdsrParams a = flatAdsr();
|
||||
a.releaseFrames = 10; // short but non-zero so voice stays active through release
|
||||
VoiceEngine eng(8, km, a);
|
||||
SampleData sd = dcSample(100000, 60);
|
||||
sd.play.adsr = flatAdsr();
|
||||
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
|
||||
Keymap km = Keymap::singleSampleChromatic(sd);
|
||||
VoiceEngine eng(8, km);
|
||||
|
||||
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
|
||||
std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain
|
||||
@@ -371,7 +372,7 @@ static void testOutOfZoneNoteConsumesNoVoice() {
|
||||
Keymap km;
|
||||
km.samples.push_back(dcSample(100, 60));
|
||||
km.zones.push_back(KeyZone{60, 72, 60, 0});
|
||||
VoiceEngine eng(4, km, flatAdsr());
|
||||
VoiceEngine eng(4, km);
|
||||
|
||||
std::size_t v = eng.noteOn(30, 100); // below the only zone
|
||||
CHECK(v == VoiceEngine::kNoVoice);
|
||||
@@ -383,14 +384,13 @@ static void testOutOfZoneNoteConsumesNoVoice() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testStealsReleasingVoiceFirst() {
|
||||
// Long per-zone release so the voice stays active through the release tail.
|
||||
// Per the S12 fix, Voice::start uses sample.play.adsr — not the engine's gateAdsr —
|
||||
// so the long release must live on the SampleData, not on the VoiceEngine constructor arg.
|
||||
// Long per-zone release so the voice stays active through the release tail. Voice::start reads
|
||||
// sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData.
|
||||
SampleData s = dcSample(100000, 60);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, flatAdsr());
|
||||
VoiceEngine eng(2, km);
|
||||
|
||||
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
|
||||
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
|
||||
@@ -415,7 +415,7 @@ static void testStealsOldestWhenNoneReleasing() {
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.adsr.releaseFrames = 100000;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, flatAdsr());
|
||||
VoiceEngine eng(2, km);
|
||||
|
||||
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
|
||||
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
|
||||
@@ -453,7 +453,7 @@ static void testLoopSustainSeamless() {
|
||||
s.loop.end = 40;
|
||||
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
@@ -475,7 +475,7 @@ static void testZeroLengthLoopGoesSilent() {
|
||||
s.loop.start = 25;
|
||||
s.loop.end = 25; // zero length
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
@@ -499,7 +499,7 @@ static void testSingleFrameLoop() {
|
||||
s.loop.end = 6; // single-frame loop: [5, 6)
|
||||
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
@@ -518,7 +518,7 @@ static void testAbsentLoopGoesSilent() {
|
||||
SampleData s = dcSample(50, 60);
|
||||
// s.loop.hasLoop stays false.
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 100);
|
||||
@@ -539,7 +539,7 @@ static void testStartFrameOffsetsInitialRead() {
|
||||
s.rootNote = 60;
|
||||
s.startFrame = 30;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 3);
|
||||
@@ -555,7 +555,7 @@ static void testStartFrameZeroIsUnchanged() {
|
||||
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
|
||||
s.rootNote = 60; // startFrame stays 0
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1);
|
||||
@@ -568,7 +568,7 @@ static void testStartFrameOutOfRangeClampsToZero() {
|
||||
SampleData s = dcSample(10, 60); // 10 frames of 1.0
|
||||
s.startFrame = 10; // == frameCount: out of range
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 5);
|
||||
@@ -589,7 +589,7 @@ static void testStartFrameWithLoop() {
|
||||
s.loop.start = 20;
|
||||
s.loop.end = 40;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 200);
|
||||
@@ -618,7 +618,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
|
||||
s.loop.end = 40;
|
||||
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
@@ -644,21 +644,21 @@ static void testVelocityToVolume() {
|
||||
// Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the
|
||||
// rendered value is exactly velocity/127 on a DC-1 sample).
|
||||
{
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1);
|
||||
CHECK(approx(out[0], 1.0, 1e-4));
|
||||
}
|
||||
{
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 64);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1);
|
||||
CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
|
||||
}
|
||||
{
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 1);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1);
|
||||
@@ -669,7 +669,7 @@ static void testVelocityToVolume() {
|
||||
// Two voices summed: polyphony mixes additively.
|
||||
static void testPolyphonyMixesAdditively() {
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
||||
VoiceEngine eng(4, km, flatAdsr());
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127); // gain 1.0
|
||||
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
|
||||
std::vector<AudioSample> out;
|
||||
@@ -705,7 +705,7 @@ static void testStereoRenderKeepsChannelsDistinct() {
|
||||
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
|
||||
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
|
||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
|
||||
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
||||
@@ -720,7 +720,7 @@ static void testMonoSamplePlaysDualMonoInStereo() {
|
||||
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
|
||||
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
||||
eng.render(left.data(), right.data(), 8);
|
||||
@@ -734,7 +734,7 @@ static void testMonoRenderUnchangedByStereoData() {
|
||||
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
|
||||
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
|
||||
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
|
||||
VoiceEngine engS(1, kmS, flatAdsr());
|
||||
VoiceEngine engS(1, kmS);
|
||||
engS.noteOn(60, 127);
|
||||
std::vector<AudioSample> mono;
|
||||
engS.render(mono, 8); // the mono overload
|
||||
@@ -759,7 +759,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
|
||||
}
|
||||
s.rootNote = 60;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(4, km, flatAdsr());
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(72, 127); // +1 octave
|
||||
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
|
||||
eng.render(left.data(), right.data(), frames / 2);
|
||||
@@ -770,7 +770,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
|
||||
static void testStereoRenderSumsVoicesPerChannel() {
|
||||
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
|
||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
|
||||
VoiceEngine eng(4, km, flatAdsr());
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(60, 127); // second voice, same note
|
||||
std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
|
||||
@@ -781,7 +781,7 @@ static void testStereoRenderSumsVoicesPerChannel() {
|
||||
|
||||
static void testStereoRenderNullBufferIsNoOp() {
|
||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> buf(4, 0.f);
|
||||
eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash
|
||||
@@ -810,7 +810,7 @@ static void testStereoStartFrameLoopShareOneReadHead() {
|
||||
s.loop.end = 30; // loop [20,30): frames 20..29
|
||||
CHECK(s.channelCount() == 2);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
||||
|
||||
std::vector<AudioSample> left(200, 0.f), right(200, 0.f);
|
||||
@@ -908,7 +908,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction,
|
||||
static void testTriggerLengthFractionFrames() {
|
||||
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 200);
|
||||
@@ -922,7 +922,7 @@ static void testTriggerLengthFractionFrames() {
|
||||
static void testTriggerLengthWithStart() {
|
||||
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 200);
|
||||
@@ -936,7 +936,7 @@ static void testTriggerFadeShape() {
|
||||
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
|
||||
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 120);
|
||||
@@ -956,7 +956,7 @@ static void testTriggerEdgeCases() {
|
||||
// %=0: zero play length -> voice frees at once, no sound.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 50);
|
||||
@@ -967,7 +967,7 @@ static void testTriggerEdgeCases() {
|
||||
{
|
||||
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 50);
|
||||
@@ -977,7 +977,7 @@ static void testTriggerEdgeCases() {
|
||||
// %=100 plays the full post-start span.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 80);
|
||||
@@ -989,7 +989,7 @@ static void testTriggerEdgeCases() {
|
||||
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
|
||||
static void testTriggerIgnoresNoteOff() {
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 10);
|
||||
@@ -1039,7 +1039,7 @@ static void testPreserveDurationInvariance() {
|
||||
|
||||
auto lengthAt = [&](int note) -> std::size_t {
|
||||
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
|
||||
VoiceEngine eng(1, km, flatAdsr(), /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
|
||||
eng.noteOn(note, 127);
|
||||
return soundingLength(eng, 4000);
|
||||
};
|
||||
@@ -1066,7 +1066,7 @@ static void testVarispeedStillCouplesDuration() {
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(note, 127);
|
||||
return soundingLength(eng, 4000);
|
||||
};
|
||||
@@ -1091,7 +1091,7 @@ static void testPitchEnvOffBitIdentical() {
|
||||
s.play.pitchEnv.decayFrames = 500;
|
||||
}
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, n);
|
||||
@@ -1119,7 +1119,7 @@ static void testPitchEnvOnBendsVarispeed() {
|
||||
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
|
||||
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 4000);
|
||||
@@ -1154,7 +1154,7 @@ static void testPreserveGateStereoLoopComposes() {
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
CHECK(s.channelCount() == 2);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr(), 0, 512);
|
||||
VoiceEngine eng(1, km, 0, 512);
|
||||
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
|
||||
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
|
||||
eng.render(left.data(), right.data(), 2000);
|
||||
@@ -1176,23 +1176,22 @@ static void testPreserveVoiceCap() {
|
||||
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, flatAdsr(), /*preserveCap=*/2, /*window=*/256);
|
||||
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.activeVoiceCount() == 2);
|
||||
}
|
||||
|
||||
// --- S12 review fix: per-zone A/D/S/R actually reaches the voice envelope. ---
|
||||
// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
|
||||
//
|
||||
// Before the fix, Voice::start used the instrument-wide gateAdsr for A/D/S/R and only
|
||||
// folded the per-zone holdFrames. These two tests assert the corrected path.
|
||||
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
|
||||
// keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path.
|
||||
|
||||
// The zone's attackFrames drives the envelope ramp — NOT the VoiceEngine's gateAdsr.
|
||||
// Strategy: give the VoiceEngine a FLAT gateAdsr (instant attack) but put an explicit
|
||||
// 10-frame attack on the SampleData.play.adsr. If Voice::start reads the zone ADSR, the
|
||||
// DC-1 output will be 0 at frame 0 and 1.0 after the 10-frame ramp. If it instead used
|
||||
// gateAdsr (flat = instant), frame 0 would already be 1.0. This is the load-bearing proof.
|
||||
// The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on
|
||||
// the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame
|
||||
// 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be
|
||||
// 1.0 at frame 0. This is the load-bearing proof.
|
||||
static void testPerZoneAdsrReachesVoiceEnvelope() {
|
||||
SampleData s = dcSample(500, 60);
|
||||
// Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release.
|
||||
@@ -1203,11 +1202,11 @@ static void testPerZoneAdsrReachesVoiceEnvelope() {
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr()); // instrument-wide gateAdsr = flat (instant attack)
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 20);
|
||||
// Frame 0: attack start, envelope near 0. If gateAdsr (flat) were used, this would be 1.0.
|
||||
// Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here.
|
||||
CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp
|
||||
// Frame 9: still ramping (last attack frame, linear ramp reaches 0.9).
|
||||
CHECK(out[9] < 1.0 - 1e-9);
|
||||
@@ -1226,7 +1225,7 @@ static void testZeroAdsrIsInstantSustain() {
|
||||
s.play.adsr = AdsrParams{};
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, flatAdsr());
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 5);
|
||||
|
||||
Reference in New Issue
Block a user