Merge Γ-W2-T1: Rate and Pitch compound into one read increment, on a three-state commit predicate and payload v16

This commit is contained in:
2026-08-02 06:32:22 -04:00
38 changed files with 1670 additions and 200 deletions
+11
View File
@@ -50,6 +50,8 @@ InstrumentParams dialed() {
p.play.pitchEnv.peakSemitones = -7.0;
p.play.pitchEnv.shape.attackSeconds = 0.05;
p.play.pitchVelocityCurve = VelocityCurve::linear();
p.play.playRate = 0.5;
p.play.pitchOffsetSemitones = -7.5;
p.play.filter.enabled = true;
p.play.filter.modAmount = -0.8;
p.play.filter.velAmount = 0.6;
@@ -139,6 +141,15 @@ int main() {
CHECK(after.play.pitchEnv.peakSemitones == 0.0);
CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds);
// --- RESET: Rate and the baseline Pitch offset -----------------------------------
// Both are processing the bake already printed, so the whitelist leaves them at their
// defaults — the safe direction. A second bake of the result at a still-dialled rate would
// otherwise re-stretch what the first one baked in.
CHECK(after.play.playRate == 1.0);
CHECK(after.play.pitchOffsetSemitones == 0.0);
CHECK(after.play.playRate == freshPlay.playRate);
CHECK(after.play.pitchOffsetSemitones == freshPlay.pitchOffsetSemitones);
// --- RESET: the filter, including its velocity/key-tracking mod -----------------
CHECK(!after.play.filter.enabled);
CHECK(after.play.filter.modAmount == 0.0);
+128
View File
@@ -64,6 +64,19 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
return peak;
}
// The last frame of the file that carries any signal at all — where the voice ACTUALLY stopped.
// A measurement of the engine, never a second evaluation of the derivation under test. -1 when
// the render is silent throughout.
std::int64_t lastSoundingFrame(const BakeAudio& audio) {
for (std::int64_t f = audio.frameCount() - 1; f >= 0; --f) {
if (std::fabs(static_cast<double>(
audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)])) > kSilence) {
return f;
}
}
return -1;
}
// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the
// comparison a measurement of the derived end rather than of a second derivation.
@@ -92,6 +105,20 @@ std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
return plan ? plan->totalFrames : -1;
}
// Where the dialed sound stops when NOTHING cuts it: the same sound programmed with a
// deliberately long note and a window to match. This is the reference a derived window is
// judged against, and it has to be measured rather than recomputed — an under-derived Gate
// window truncates by releasing the note EARLY, which leaves no signal outside the file at all
// and so is invisible to "nothing past the end".
std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) {
NoteProgram p = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(100));
p.length = lengthOfSeconds(heldSeconds);
p.end = EndOffset(offsetFromMs(200.0));
const std::optional<BakePlan> plan = planOf(p);
if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; }
return lastSoundingFrame(renderBake(s, *plan, kUnity));
}
// The last frame of the file, which is where a hard cut shows up.
double lastFrameLevel(const BakeAudio& audio) {
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
@@ -337,6 +364,107 @@ int main() {
CHECK(derivedFrames(staged) == 12000 + kPad);
}
// ============================ RATE AND PITCH ====================================
// The one judgement every case below makes: the derived window holds the WHOLE free-running
// sound (the derived render stops exactly where the uncut one does), and it is exactly
// enough rather than merely long. `heldSeconds` only has to exceed the free-running length.
const auto windowHoldsTheWholeNote = [&](const SampleData& s, double heldSeconds,
const char* what) {
const std::int64_t trueEnd = freeRunningEnd(s, heldSeconds);
const std::int64_t derived = derivedFrames(s);
const std::int64_t got = lastSoundingFrame(bakeWith(s, 0.0));
const bool held = trueEnd >= 0 && derived > trueEnd && got == trueEnd;
CHECK(held);
CHECK(held && derived - trueEnd <= kPad + 8);
if (!(held && derived - trueEnd <= kPad + 8)) {
std::printf(" %s: free-running end %lld, derived render end %lld, window %lld\n",
what, static_cast<long long>(trueEnd), static_cast<long long>(got),
static_cast<long long>(derived));
}
};
// --- Rate scales the window under BOTH engines, in both derived branches --------------
// Rate IS the read rate: Varispeed folds it into the read increment, Preserve feeds the
// stretcher at it. Either way a 50 % rate doubles how long the source takes to play out and
// a 200 % one halves it, so a window blind to Rate truncates by half at the slow end and
// prints a file of trailing silence at the fast one.
{
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
for (PlayMode mode : {PlayMode::Trigger, PlayMode::Gate}) {
for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) {
SampleData s = dcSample(48000); // 1 s; 2 s at the slowest rate
s.play.playMode = mode;
s.play.pitchEngine = eng;
s.play.adsr.releaseFrames = 0;
s.play.playRate = rate;
char what[64];
std::snprintf(what, sizeof(what), "eng %d mode %d rate %.2f",
static_cast<int>(eng), static_cast<int>(mode), rate);
windowHoldsTheWholeNote(s, 3.0, what);
}
}
}
}
// --- A downward Pitch offset stretches the window under VARISPEED only ---------------
// It is a factor of the read increment there and a shifter transpose under Preserve, so the
// window follows it in one engine and not the other. Both must still hold the whole note.
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchOffsetSemitones = -12.0; // half rate for the note's whole lifetime
CHECK(derivedFrames(s) == 96000 + kPad);
windowHoldsTheWholeNote(s, 3.0, "varispeed pitch -12");
SampleData p = s;
p.play.pitchEngine = PitchEngine::Preserve;
CHECK(derivedFrames(p) == 48000 + kPad); // the read rate never moved
windowHoldsTheWholeNote(p, 3.0, "preserve pitch -12");
// An UPWARD offset bounds nothing — the read only gets faster — so the window keeps the
// un-stretched span and the balance is trailing silence, on the same asymmetry the
// velocity->pitch term already takes.
SampleData up = s;
up.play.pitchOffsetSemitones = 12.0;
CHECK(derivedFrames(up) == 48000 + kPad);
const BakeAudio wideUp = bakeWith(up, /*extraMs=*/500.0);
CHECK(peakAt(wideUp, 48000 + kPad, wideUp.frameCount()) == 0.0);
}
// --- Rate and Pitch COMPOUND, because the voice folds them into one multiply ----------
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.playRate = 0.5;
s.play.pitchOffsetSemitones = -12.0; // together: a quarter-speed read
CHECK(derivedFrames(s) == 192000 + kPad);
windowHoldsTheWholeNote(s, 5.0, "varispeed rate 0.5 x pitch -12");
}
// --- Gate over a sustain loop is Hold's, and Rate does not touch it -------------------
// The note length there is the user's Hold in wall clock and the release is ticked per
// output frame, so neither term of the stretch applies — the one derived branch that must
// NOT move when Rate does.
{
SampleData s = dcSample(48000);
s.loop = SampleLoop{true, 0, 24000};
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 4800;
CHECK(bakeWindowNeedsHold(s));
const std::int64_t unity = derivedFrames(s);
for (double rate : {0.5, 2.0}) {
SampleData r = s;
r.play.playRate = rate;
CHECK(derivedFrames(r) == unity);
}
}
// ============================== VELOCITY ========================================
// --- The bake renders at the velocity it is handed ----------------------------------
+158 -40
View File
@@ -10,6 +10,7 @@
#include "../src/core/instrument/engine/envelopes.h" // AhdEnvelope (header-only: the codec
// links no engine, and this adds none)
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
#include "../src/core/instrument/engine/time_stretch.h" // the rate bounds the codec clamps to
#include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral)
#include <cmath>
@@ -453,7 +454,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,0x0f,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x10,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,
@@ -551,6 +552,9 @@ static void testGoldenFullBlobFixture() {
0x00, // Straight
// --- payload v15 limiter enable ---
0x00, // bypassed (the default)
// --- payload v16 rate + baseline pitch offset ---
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // playRate 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // pitchOffsetSemitones 0.0
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -598,10 +602,10 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 15);
CHECK(kParamsPayloadVersion == 16);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter, staged-curve, loop, velocity, spline, bake-Hold and limiter tails rode
// The filter, staged-curve, loop, velocity, spline, bake-Hold, limiter and rate tails rode
// PAYLOAD bumps, not envelope ones — the two axes stay independent, so a future envelope
// field cannot collide with any of them on one number. This pins the NUMBERS only; that
// each tail's bytes sit in the order its number implies is
@@ -613,7 +617,8 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(kParamsSplineVersion > kParamsVelocityVersion);
CHECK(kParamsBakeHoldVersion > kParamsSplineVersion);
CHECK(kParamsLimiterVersion > kParamsBakeHoldVersion);
CHECK(kParamsPayloadVersion == kParamsLimiterVersion);
CHECK(kParamsRateVersion > kParamsLimiterVersion);
CHECK(kParamsPayloadVersion == kParamsRateVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
@@ -798,9 +803,14 @@ static void testNonFiniteAhdSecondsLiftToZero() {
static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2;
static constexpr std::size_t kBakeHoldTailBytes = 4 + 1;
static constexpr std::size_t kLimiterTailBytes = 1;
static constexpr std::size_t kRateTailBytes = 8 + 8; // v16: rate + pitch offset, two doubles
// Everything past the hard flags, as ONE unit — the splice tests cut back over all of it, so a
// new rung is one edit here rather than a hand-counted sum at each of them.
static constexpr std::size_t kTrailingTailBytes =
kBakeHoldTailBytes + kLimiterTailBytes + kRateTailBytes;
// The v14/v15 tails, re-appended after a splice so the record still ends where the reader
// expects. They go back in wire order: Hold first, then the limiter byte.
// The v14/v15/v16 tails, re-appended after a splice so the record still ends where the reader
// expects. They go back in wire order: Hold, then the limiter byte, then the rate pair.
static void putBakeHoldTail(std::vector<std::uint8_t>& out, int quarterExponent,
note::DivisionModifier modifier) {
legacy::u32v(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(quarterExponent)));
@@ -811,9 +821,15 @@ static void putLimiterTail(std::vector<std::uint8_t>& out, bool enabled) {
legacy::u8v(out, enabled ? 1 : 0);
}
static void putRateTail(std::vector<std::uint8_t>& out, double rate, double pitchOffset) {
legacy::f64v(out, rate);
legacy::f64v(out, pitchOffset);
}
static void putDefaultTrailingTails(std::vector<std::uint8_t>& out) {
putBakeHoldTail(out, 2, note::DivisionModifier::Straight); // 1/1, the field's default
putLimiterTail(out, false); // bypassed, the field's default
putRateTail(out, 1.0, 0.0); // unity rate, no offset
}
// A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still
@@ -848,8 +864,8 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
// order in params_payload.cpp) is deterministic and this test can splice it exactly.
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes);
CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
legacy::u32v(bytes, 5); // amp: bogus count...
for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts
legacy::u32v(bytes, 2); // filter: correct count, unchanged
@@ -896,8 +912,8 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
in.params.loopCrossfadeFrames = 321;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes);
CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry
// …and nothing at all after it, so the blob simply ends inside the v13 tail.
@@ -947,7 +963,7 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric
// Everything after it — the amp flags, both well-formed neighbour blocks, the Hold and the
// limiter byte — is exactly what the serializer wrote, which is the whole hazard.
constexpr std::size_t kThreePointFlagTail = (4 + 3) + (4 + 2) + (4 + 2);
constexpr std::size_t kTrailingTails = kBakeHoldTailBytes + kLimiterTailBytes;
constexpr std::size_t kTrailingTails = kTrailingTailBytes;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kThreePointFlagTail + kTrailingTails);
const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kTrailingTails;
@@ -1044,10 +1060,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord()
in.params.loopCrossfadeFrames = 5;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes);
CHECK(bytes.size() >= kHardFlagTailBytes + kTrailingTailBytes);
// Drops the bake-Hold and limiter tails with the flags: the truncation strands everything
// after it, which is the whole point — both lift to their defaults alongside the flags.
bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes);
bytes.resize(bytes.size() - kHardFlagTailBytes - kTrailingTailBytes);
legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing
legacy::u8v(bytes, 0x00);
@@ -1255,18 +1271,18 @@ static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact()
CHECK(out.params.keyTrack == 0.25);
CHECK(out.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted));
// The same state stamped v14, with exactly the one appended byte cut away: byte-for-byte
// The same state stamped v14, with the two rungs appended after it cut away: byte-for-byte
// what the Ξ binary wrote. Its Hold must survive in full.
const ComponentState v14 = deserializeComponentState(
payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes), 48000.0);
payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes + kRateTailBytes),
48000.0);
CHECK(!v14.params.limiterEnabled);
CHECK(v14.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted));
CHECK(v14.params.keyTrack == 0.25);
// And a v13 blob, one rung further back, lifts to BOTH defaults.
const ComponentState v13 = deserializeComponentState(
payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes),
48000.0);
payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes), 48000.0);
CHECK(!v13.params.limiterEnabled);
CHECK(v13.params.bakeHold == InstrumentParams{}.bakeHold);
CHECK(v13.params.keyTrack == 0.25);
@@ -1280,37 +1296,49 @@ static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact()
// The ORDERING proof at the WRITER, stated in bytes rather than in prose: the payload's whole
// discipline is that each version's fields are a strict suffix on the previous version's, so
// v14's Hold pair must be emitted BEFORE v15's limiter byte or every v14 blob already saved
// mis-parses. Asserted at absolute offsets from the end of the blob, with both fields off
// their defaults, so transposing the two writes fails on the values and not just the layout.
// v14's Hold pair must be emitted BEFORE v15's limiter byte, and both before v16's rate pair,
// or every blob already saved at those rungs mis-parses. Asserted at absolute offsets from the
// end of the blob, with every field off its default, so transposing any two writes fails on the
// values and not just the layout.
static void testAppendedTailsSitInVersionOrderOnTheWire() {
ComponentState in;
in.selectionId = "pad";
in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet);
in.params.limiterEnabled = true;
in.params.play.playRate = 2.0; // 0x4000000000000000 LE
in.params.play.pitchOffsetSemitones = -12.0; // 0xC028000000000000 LE
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() > kBakeHoldTailBytes + kLimiterTailBytes);
CHECK(bytes.size() > kTrailingTailBytes);
// The last six bytes are, in order: the v14 Hold's 4-byte LE exponent, its 1-byte
// modifier, then the v15 limiter byte.
const std::size_t holdAt = bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes;
// In order: the v14 Hold's 4-byte LE exponent, its 1-byte modifier, the v15 limiter byte,
// then the v16 rate and pitch-offset doubles.
const std::size_t holdAt = bytes.size() - kTrailingTailBytes;
CHECK(bytes[holdAt + 0] == 0xfe); // -2 as int32 LE two's-complement
CHECK(bytes[holdAt + 1] == 0xff);
CHECK(bytes[holdAt + 2] == 0xff);
CHECK(bytes[holdAt + 3] == 0xff);
CHECK(bytes[holdAt + 4] == static_cast<std::uint8_t>(note::DivisionModifier::Triplet));
CHECK(bytes[bytes.size() - 1] == 0x01); // the limiter enable, last
CHECK(bytes[holdAt + 5] == 0x01); // the limiter enable
const std::size_t rateAt = holdAt + kBakeHoldTailBytes + kLimiterTailBytes;
const std::uint8_t wantRate[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40};
const std::uint8_t wantOffset[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0xc0};
for (std::size_t i = 0; i < 8; ++i) {
CHECK(bytes[rateAt + i] == wantRate[i]);
CHECK(bytes[rateAt + 8 + i] == wantOffset[i]);
}
// The same claim from the other side: flipping only the limiter changes only the LAST
// byte, so the byte the limiter owns cannot be one the Hold also writes.
// The same claim from the other side: flipping only the limiter changes only the byte the
// limiter owns, so it cannot be one the Hold or the rate pair also writes.
ComponentState off = in;
off.params.limiterEnabled = false;
const std::vector<std::uint8_t> offBytes = serializeComponentState(off);
CHECK(offBytes.size() == bytes.size());
if (offBytes.size() == bytes.size()) {
for (std::size_t i = 0; i + 1 < bytes.size(); ++i) CHECK(offBytes[i] == bytes[i]);
CHECK(offBytes[bytes.size() - 1] == 0x00);
for (std::size_t i = 0; i < bytes.size(); ++i) {
if (i == holdAt + 5) CHECK(offBytes[i] == 0x00);
else CHECK(offBytes[i] == bytes[i]);
}
}
}
@@ -1408,10 +1436,10 @@ static void testV13BlobLiftsToTheDefaultHold() {
in.params.loopCrossfadeFrames = 128;
in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted);
// Stamp the payload back to v13 and drop the v14 and v15 tails both: byte-for-byte what
// the v13 binary would have written.
const std::vector<std::uint8_t> v13 = payloadDowngradedTo(
in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes);
// Stamp the payload back to v13 and drop every tail appended since: byte-for-byte what the
// v13 binary would have written.
const std::vector<std::uint8_t> v13 =
payloadDowngradedTo(in, kParamsSplineVersion, kTrailingTailBytes);
const ComponentState out = deserializeComponentState(v13, 48000.0);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
CHECK(out.selectionId == "pad");
@@ -1427,18 +1455,22 @@ static void testBakeHoldCorruptPairClampsToTheLadder() {
ComponentState in;
in.selectionId = "pad";
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes);
bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes);
CHECK(bytes.size() >= kTrailingTailBytes);
bytes.resize(bytes.size() - kTrailingTailBytes);
legacy::u32v(bytes, static_cast<std::uint32_t>(static_cast<std::int32_t>(9999)));
legacy::u8v(bytes, 200); // an unnamed modifier byte
putLimiterTail(bytes, true); // a well-formed byte after it, so the clamp is the only fault
// Well-formed, off-default tails after it, so the clamp is the only fault in the blob.
putLimiterTail(bytes, true);
putRateTail(bytes, 0.5, 7.0);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.params.bakeHold ==
note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight));
// The tail behind the corrupt pair still lands on its own field: the clamp consumed exactly
// the five bytes it was owed, so the limiter byte was not read out of the Hold's modifier.
// The tails behind the corrupt pair still land on their own fields: the clamp consumed
// exactly the five bytes it was owed, so nothing after it was read out of alignment.
CHECK(out.params.limiterEnabled);
CHECK(out.params.play.playRate == 0.5);
CHECK(out.params.play.pitchOffsetSemitones == 7.0);
}
// A blob truncated INSIDE the v14 tail costs the Hold alone — and, with the v15 byte stranded
@@ -1451,22 +1483,106 @@ static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() {
in.params.loopCrossfadeFrames = 96;
in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet);
in.params.limiterEnabled = true;
in.params.play.playRate = 0.75;
in.params.play.pitchOffsetSemitones = -5.0;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes);
bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes);
CHECK(bytes.size() >= kTrailingTailBytes);
bytes.resize(bytes.size() - kTrailingTailBytes);
legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing
legacy::u8v(bytes, 0x00);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold);
CHECK(!out.params.limiterEnabled); // stranded behind the Hold, and revived not wiped
// The rate pair is stranded two rungs behind the damage and must reach its own neutral
// rather than fabricating one out of the drained bytes.
CHECK(out.params.play.playRate == 1.0);
CHECK(out.params.play.pitchOffsetSemitones == 0.0);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 71);
CHECK(out.params.play.adsr.attackSeconds == 0.017);
CHECK(out.params.loopCrossfadeFrames == 96);
}
// --- The rate + pitch-offset tail (payload v16) ------------------------------
// The rung's whole contract in one test: a v16 blob round-trips BOTH fields exactly, and a v15
// blob — a strict prefix of it, byte-for-byte what the shipped binary wrote — lifts to unity
// rate and zero offset, which is what every instance before them played. The neighbours ahead of
// the pair are checked too, so a misread that shifted the record shows up here rather than as a
// silent retune.
static void testRateAndPitchOffsetRoundTripAndV15LiftsToUnity() {
ComponentState in;
in.selectionId = "pad";
in.params.keyTrack = 0.75;
in.params.limiterEnabled = true;
in.params.bakeHold = note::makeDivision(3, note::DivisionModifier::Dotted);
// Both off their defaults, and both exactly representable, so == is the right comparison:
// the codec stores raw doubles and must not round either one.
in.params.play.playRate = 0.75;
in.params.play.pitchOffsetSemitones = -7.5;
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.play.playRate == 0.75);
CHECK(out.params.play.pitchOffsetSemitones == -7.5);
CHECK(out.params.limiterEnabled);
CHECK(out.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted));
CHECK(out.params.keyTrack == 0.75);
const ComponentState v15 = deserializeComponentState(
payloadDowngradedTo(in, kParamsLimiterVersion, kRateTailBytes), 48000.0);
CHECK(v15.params.play.playRate == 1.0);
CHECK(v15.params.play.pitchOffsetSemitones == 0.0);
// Everything the v15 binary DID write survives the lift untouched.
CHECK(v15.params.limiterEnabled);
CHECK(v15.params.bakeHold == note::makeDivision(3, note::DivisionModifier::Dotted));
CHECK(v15.params.keyTrack == 0.75);
// Unity/zero is the default at the struct as well as on the wire, so a fresh instance and a
// lifted v15 one are the same sound.
CHECK(PlaySeconds{}.playRate == 1.0);
CHECK(PlaySeconds{}.pitchOffsetSemitones == 0.0);
}
// Corruption degrades to the neutral, and an out-of-RANGE rate resolves through the stretcher's
// own clamp rather than surviving unclamped: playback would clamp it anyway, so a stored value
// that did not would leave the needle — and the host normalization, once the instrument reports
// parameters — disagreeing with what is actually played. The offset has no such downstream clamp
// at all (it feeds a 2^(x/12) that reaches a per-sample cast), so it gets a real range test and
// degrades whole.
static void testCorruptRateOrOffsetDegradesToTheNeutral() {
const double nan = std::numeric_limits<double>::quiet_NaN();
const struct { double rate; double offset; double wantRate; double wantOffset; } cases[] = {
{nan, 3.0, 1.0, 3.0},
{0.75, nan, 0.75, 0.0},
{0.0, 3.0, 1.0, 3.0}, // a zero rate would stall the read head
{-1.0, 3.0, 1.0, 3.0}, // and a negative one would run it backwards
{std::numeric_limits<double>::infinity(), 3.0, 1.0, 3.0},
// Finite but out of the stretcher's range — reachable from a downgrade, not corruption.
// Clamped to the bound the engine would have played, not left to re-serialize.
{10.0, 3.0, instrument::engine::kStretchRateMax, 3.0},
{0.01, 3.0, instrument::engine::kStretchRateMin, 3.0},
{instrument::engine::kStretchRateMin, 3.0, instrument::engine::kStretchRateMin, 3.0}, // the bounds themselves
{instrument::engine::kStretchRateMax, 3.0, instrument::engine::kStretchRateMax, 3.0}, // survive untouched
{0.75, 1e9, 0.75, 0.0}, // past the +/-24 st throw
{0.75, -1e9, 0.75, 0.0},
{0.75, 24.0, 0.75, 24.0}, // the throw itself is IN range
{0.75, -24.0, 0.75, -24.0},
};
for (const auto& c : cases) {
ComponentState in;
in.selectionId = "pad";
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= kRateTailBytes);
bytes.resize(bytes.size() - kRateTailBytes);
putRateTail(bytes, c.rate, c.offset);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.params.play.playRate == c.wantRate);
CHECK(out.params.play.pitchOffsetSemitones == c.wantOffset);
}
}
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
// the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals.
@@ -2079,6 +2195,8 @@ int main() {
testV13BlobLiftsToTheDefaultHold();
testBakeHoldCorruptPairClampsToTheLadder();
testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord();
testRateAndPitchOffsetRoundTripAndV15LiftsToUnity();
testCorruptRateOrOffsetDegradesToTheNeutral();
if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n");
return 0;
+68 -24
View File
@@ -412,10 +412,12 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
CHECK(deckNormFromBipolar(3.0) == 1.0);
}
static void testEveryDeckControlIsClassifiedLiveOrReloading() {
// The live set: the seven filter tone/modulation knobs, plus every stage time, stage level,
// hold fraction and curve exponent on all three envelopes — in BOTH mode shapes.
static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
// The live set: the seven filter tone/modulation knobs, the baseline pitch offset, plus
// every stage time, stage level, hold fraction and curve exponent on all three envelopes —
// in BOTH mode shapes.
const DeckParam live[] = {
DeckParam::kPitch,
DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ,
DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterVel,
DeckParam::kFilterKeyTrack,
@@ -434,7 +436,14 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
DeckParam::kFilterEnvReleaseCurve,
DeckParam::kFilterTrigAttackCurve, DeckParam::kFilterTrigDecayCurve,
};
for (DeckParam p : live) CHECK(isLiveDeckParam(p));
for (DeckParam p : live) CHECK(deckParamCommit(p) == LiveCommit::Live);
// The note-on-latched tier: published like a live control, read only at note-on. Asserted as
// its OWN state rather than as "not Reload" — the whole point of widening the predicate is
// that Rate must not fall back into either neighbour, and Γ-W4-T1 reads this classification
// to decide what it exposes to the host.
const DeckParam latched[] = {DeckParam::kRate};
for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched);
// Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
// is excluded.
@@ -448,15 +457,16 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kMonoTrigger, DeckParam::kMasterGain,
};
for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p));
for (DeckParam p : reloads) CHECK(deckParamCommit(p) == LiveCommit::Reload);
// COVERAGE, not cardinality: every id appears in EXACTLY ONE of the two lists. A sum check
// COVERAGE, not cardinality: every id appears in EXACTLY ONE of the three lists. A sum check
// would stay green if an edit duplicated one id and dropped another, leaving that one
// unclassified.
for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
const DeckParam p = static_cast<DeckParam>(i);
int seen = 0;
for (DeckParam q : live) if (q == p) ++seen;
for (DeckParam q : latched) if (q == p) ++seen;
for (DeckParam q : reloads) if (q == p) ++seen;
if (seen != 1) std::printf(" (deck id %d classified %d times)\n", i, seen);
CHECK(seen == 1);
@@ -464,26 +474,34 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
}
static void testOnlyALiveControlsDragTakesTheLiveTier() {
// isLiveDeckParam alone is not what a user experiences — liveCommitFor is, at the editor's
// deckParamCommit alone is not what a user experiences — liveCommitFor is, at the editor's
// commit site. Inverting it has to FAIL a test rather than merely read wrong.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kFilterCutoff)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack)));
const auto knob = [](DeckParam p) {
return liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(p));
};
CHECK(knob(DeckParam::kFilterCutoff) == LiveCommit::Live);
CHECK(knob(DeckParam::kAttack) == LiveCommit::Live);
CHECK(knob(DeckParam::kPitch) == LiveCommit::Live);
// The Trigger amp is live now that the fade pair folded into the AHD — the one behavioural
// consequence of that consolidation.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigAttack)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigDecayCurve)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigLength)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kMasterGain)));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAmpEnvSelect)));
CHECK(knob(DeckParam::kTrigAttack) == LiveCommit::Live);
CHECK(knob(DeckParam::kTrigDecayCurve) == LiveCommit::Live);
// Rate keeps its own tier through the drag site: it must not arrive as Live (which would let
// it move a sounding note) nor as Reload (which would re-decode the WAV under a swept knob).
CHECK(knob(DeckParam::kRate) == LiveCommit::NoteOnLatched);
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::Reload);
CHECK(knob(DeckParam::kMasterGain) == LiveCommit::Reload);
CHECK(knob(DeckParam::kAmpEnvSelect) == LiveCommit::Reload);
// The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id
// are not parameter-set controls, so they must never reach the enum.
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kCount)));
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -2) == LiveCommit::Reload);
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, -1) == LiveCommit::Reload);
CHECK(knob(DeckParam::kCount) == LiveCommit::Reload);
// Every stage value an envelope node can reach is live, in either mode shape.
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1));
CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1) == LiveCommit::Live);
// Every other drag (markers, scrollbar, curve nodes) commits through a reload.
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)));
CHECK(liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)) ==
LiveCommit::Reload);
}
// --- The overlay selection state machine ---------------------------------------
@@ -582,9 +600,9 @@ static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in
// spline_egs_tests alongside the rest of the spline rules.)
static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() {
CHECK(!isLiveDeckParam(DeckParam::kAmpEnvMode));
CHECK(!isLiveDeckParam(DeckParam::kPitchEnvMode));
CHECK(!isLiveDeckParam(DeckParam::kFilterEnvMode));
CHECK(deckParamCommit(DeckParam::kAmpEnvMode) == LiveCommit::Reload);
CHECK(deckParamCommit(DeckParam::kPitchEnvMode) == LiveCommit::Reload);
CHECK(deckParamCommit(DeckParam::kFilterEnvMode) == LiveCommit::Reload);
CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp);
CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch);
CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter);
@@ -644,6 +662,31 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned
// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here.
// PITCH/RATE carries three cells and measures exactly 192 — the KNOB row (3 x kDeckCellW plus
// padding) is what it measures from, and the caption row must stay under that. The ceiling is
// asserted by construction rather than as a comment: at a caption reserve of 80 the group is
// still 192, and at 81 it is not, which is the whole content of "hard ceiling 80". Widening the
// group is not the remedy if the caption text ever outgrows it — narrowing the mode toggle is.
static void testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const DeckGroupDesc* pitch = nullptr;
for (const DeckGroupDesc& d : g) if (d.id == kGroupPitch) pitch = &d;
CHECK(pitch != nullptr);
if (!pitch) return;
CHECK(pitch->cellIds.size() == 3);
CHECK(pitch->cellIds[0] == static_cast<int>(DeckParam::kKeyTrack));
CHECK(pitch->cellIds[1] == static_cast<int>(DeckParam::kRate));
CHECK(pitch->cellIds[2] == static_cast<int>(DeckParam::kPitch));
CHECK(deckGroupWidth(*pitch) == 192);
CHECK(3 * kDeckCellW + 2 * kDeckGroupPadX == 192); // the knob row IS the measurement
DeckGroupDesc probe = *pitch;
probe.captionWidth = 80;
CHECK(deckGroupWidth(probe) == 192); // at the ceiling the caption row still fits under it
probe.captionWidth = 81;
CHECK(deckGroupWidth(probe) > 192); // one past it, the caption row takes over
}
// Gate is the common face and its group widths are what the width budget is spent against:
// pin them at the floor so a later edit anywhere in the deck cannot move one silently.
// (Measured from the shipped descriptors, not copied out of a failing run.) The WRAP row a
@@ -652,7 +695,7 @@ static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
static void testGateModeGroupWidthsAreUnchanged() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const struct { int id; int width; } want[] = {
{kGroupPitch, 150}, {kGroupPitchEnv, 252}, {kGroupFilter, 524},
{kGroupPitch, 192}, {kGroupPitchEnv, 252}, {kGroupFilter, 524},
{kGroupFilterEnv, 312}, {kGroupAmpEnv, 312}, {kGroupVelocity, 192},
{kGroupVoice, 164}, {kGroupMaster, 72},
};
@@ -736,7 +779,7 @@ int main() {
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
testTheModeTogglesCostNoGroupWidth();
testEveryDeckControlIsClassifiedLiveOrReloading();
testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers();
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight();
testVelocityGroupOwnsTheThreeCurvesExclusively();
@@ -750,6 +793,7 @@ int main() {
testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testThePitchRateGroupIsKnobRowDrivenAtExactlyOneNinetyTwo();
testGateModeGroupWidthsAreUnchanged();
testGateSplineGateRoundTripsToTheSameLayout();
testTheEditorFloorIsDerivedFromTheDeckWidthBudget();
+92
View File
@@ -14,6 +14,7 @@
using namespace reasampler;
using namespace reasampler::instrument::ui;
namespace engine = reasampler::instrument::engine; // the stretcher's own rate bounds + clamp
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
@@ -88,6 +89,88 @@ static void testNormRoundTripsThroughEveryValueDomain() {
CHECK(p.adsr.decaySeconds == 0.0);
}
// Rate's range is the STRETCHER's, aliased rather than restated, so the knob's two ends and the
// engine's clamp cannot become two opinions. Asserted against the engine constants themselves.
static void testRateKnobEndsAreTheStretchersOwnBounds() {
CHECK(kRateMinRatio == engine::kStretchRateMin);
CHECK(kRateMaxRatio == engine::kStretchRateMax);
PlaySeconds p;
setDeckParam(DeckParam::kRate, p, 0.0, 0);
CHECK(p.playRate == engine::kStretchRateMin);
CHECK(engine::clampStretchRate(p.playRate) == p.playRate); // the clamp has nothing to do
setDeckParam(DeckParam::kRate, p, 1.0, 0);
CHECK(p.playRate == engine::kStretchRateMax);
CHECK(engine::clampStretchRate(p.playRate) == p.playRate);
// And nowhere on the travel does the knob produce a rate the engine would move.
for (int i = 0; i <= 1000; ++i) {
setDeckParam(DeckParam::kRate, p, static_cast<double>(i) / 1000.0, 0);
CHECK(engine::clampStretchRate(p.playRate) == p.playRate);
if (engine::clampStretchRate(p.playRate) != p.playRate) return;
}
}
// The two new bindings write the two new fields and nothing else — both are doubles on
// PlaySeconds with adjacent homes, so a getter/setter pair that crossed them would still
// round-trip. The centre detent is exact on both, which is what lets an untouched knob persist
// unity rate and zero transposition.
static void testRateAndPitchBindTheirOwnFields() {
PlaySeconds p;
setDeckParam(DeckParam::kRate, p, 0.5, 0);
CHECK(p.playRate == 1.0);
CHECK(p.pitchOffsetSemitones == 0.0);
CHECK(deckParamNorm(DeckParam::kRate, p) == 0.5);
setDeckParam(DeckParam::kPitch, p, 0.5, 0);
CHECK(p.pitchOffsetSemitones == 0.0);
CHECK(p.playRate == 1.0);
CHECK(deckParamNorm(DeckParam::kPitch, p) == 0.5);
// Pitch rides the SAME centre-expanded depth taper as the pitch envelope's own depth, over
// the SAME throw — a second constant here would be the defect the spec names.
setDeckParam(DeckParam::kPitch, p, 1.0, 0);
CHECK(p.pitchOffsetSemitones == kPitchDepthMaxSemis);
CHECK(kPitchDepthMaxSemis == kVelocityPitchRangeSemitones);
setDeckParam(DeckParam::kPitch, p, 0.0, 0);
CHECK(p.pitchOffsetSemitones == -kPitchDepthMaxSemis);
CHECK(p.playRate == 1.0); // untouched by every write above but its own
// A move on Rate leaves the offset alone, in the other direction.
setDeckParam(DeckParam::kPitch, p, 0.5, 0);
setDeckParam(DeckParam::kRate, p, 0.0, 0);
CHECK(p.pitchOffsetSemitones == 0.0);
}
// Shift's whole unit on BOTH new knobs is the semitone, not the percent their labels read in.
// Asserted through the deck's own snap entry point (the shell calls nothing else), and in
// semitones, which is the unit the rule is stated in.
static void testShiftSnapsBothNewKnobsToWholeSemitones() {
CHECK(deckParamUnit(DeckParam::kRate) == UnitCategory::Semitones);
CHECK(deckParamUnit(DeckParam::kPitch) == UnitCategory::Semitones);
PlaySeconds p;
// Rate: a norm a third of the way up is 8 semitones below unity — snapping must land on a
// whole one, and the knob must still be able to reach an octave and a fifth by hand.
for (double norm : {0.13, 0.37, 0.5, 0.62, 0.88}) {
setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, norm), 0);
const double semis = 12.0 * std::log2(p.playRate);
CHECK(std::fabs(semis - std::round(semis)) < 1e-9);
if (!(std::fabs(semis - std::round(semis)) < 1e-9)) return;
}
// The two landmarks by name: unity, and a fifth up.
setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, 0.5), 0);
CHECK(p.playRate == 1.0);
setDeckParam(DeckParam::kRate, p, snapDeckParamNorm(DeckParam::kRate, 0.5 + 7.0 / 24.0), 0);
CHECK(std::fabs(12.0 * std::log2(p.playRate) - 7.0) < 1e-9);
// Pitch: whole semitones on the centre-expanded taper, exactly (its taper resolves onto a
// micro-semitone grid, so a whole semitone is ON that grid).
for (double norm : {0.17, 0.33, 0.71, 0.94}) {
setDeckParam(DeckParam::kPitch, p, snapDeckParamNorm(DeckParam::kPitch, norm), 0);
CHECK(p.pitchOffsetSemitones == std::round(p.pitchOffsetSemitones));
if (p.pitchOffsetSemitones != std::round(p.pitchOffsetSemitones)) return;
}
}
// The dual-ring reset contract: the outer ring resets the stage VALUE and the inner dial resets
// the EXPONENT, each leaving the other exactly as it was. Both fields are asserted in both
// directions — checking only the field that changed would pass even if the reset clobbered its
@@ -234,6 +317,12 @@ static void testEveryDefaultHasAnExactNormalizedPreimage() {
CHECK(deckParamNorm(DeckParam::kTrigLength, d) == d.trigger.lengthFraction);
CHECK(deckParamNorm(DeckParam::kTrigHold, d) == d.trigAhd.holdFraction);
CHECK(deckBipolarFromNorm(deckParamNorm(DeckParam::kFilterModAmt, d)) == d.filter.modAmount);
// The PITCH/RATE pair. Rate's preimage is the taper's unity detent, which sits at true
// centre only because these bounds are reciprocal; Pitch's is the depth taper's exact zero.
CHECK(rateRatioFromNorm(deckParamNorm(DeckParam::kRate, d), kRateMinRatio, kRateMaxRatio) ==
d.playRate);
CHECK(depthSemitonesFromNorm(deckParamNorm(DeckParam::kPitch, d), kPitchDepthMaxSemis) ==
d.pitchOffsetSemitones);
CHECK(util::curveFromKnobNorm(deckParamNorm(DeckParam::kAttackCurve, d)) ==
d.adsr.attackCurve);
// Master gain's unity: the case where a hair off is an audible gain error rather than a
@@ -342,6 +431,9 @@ static void testTimeConstantsAlwaysReadInMilliseconds() {
int main() {
testTheTwoCeilingNamesAreOneNumber();
testNormRoundTripsThroughEveryValueDomain();
testRateKnobEndsAreTheStretchersOwnBounds();
testRateAndPitchBindTheirOwnFields();
testShiftSnapsBothNewKnobsToWholeSemitones();
testResetTouchesOnlyItsOwnRingOnADualRingKnob();
testInnerResetLandsOnTheExactLinearNeutral();
testResetLandsOnTheStoredDefaultOfEachControl();
+226 -4
View File
@@ -211,7 +211,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
PitchEnvParams longer = p;
longer.shape.decayFrames = 2000;
b.applyLive(longer); // decay doubled mid-decay
b.applyLive(100000, longer); // decay doubled mid-decay, same span
CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame
// A depth move is a level step, so it glides rather than jumping: the first frame after
@@ -224,7 +224,7 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); }
PitchEnvParams noDepth = p;
noDepth.peakSemitones = 0.0;
c.applyLive(noDepth); // depth to zero mid-decay
c.applyLive(100000, noDepth); // depth to zero mid-decay
CHECK(c.tick() == d.tick());
// ...and it does eventually reach the new depth rather than staying put.
for (int i = 0; i < 400; ++i) c.tick();
@@ -259,7 +259,7 @@ static void testPitchEnvelopeHoldStagePlaysAndHoldsPhase() {
for (int i = 0; i < 300; ++i) f.tick();
PitchEnvParams wider = p;
wider.shape.holdFraction = 1.0;
f.applyLive(wider);
f.applyLive(1000, wider);
CHECK(f.tick() == 12.0);
for (int i = 0; i < 1200; ++i) f.tick();
CHECK(f.tick() == 0.0);
@@ -307,7 +307,7 @@ static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() {
PitchEnvParams dialled = stale;
dialled.peakSemitones = 12.0;
dialled.shape.decayFrames = 1000;
env.snapLive(dialled);
env.snapLive(100000, dialled);
CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope
for (int i = 0; i < 499; ++i) env.tick();
CHECK(std::fabs(env.tick() - 6.0) < 1e-12);
@@ -720,6 +720,224 @@ static void testOneBlockServesTwoIndependentObservers() {
CHECK(seen.filterSettings.cutoffNorm == 0.2f);
}
// --- The third commit class: published live, read only at note-on -------------------------
// A ramp source read under Varispeed, so every output frame IS the read position — a moved read
// increment shows up directly rather than as a timbre change. The claim has two halves and both
// are asserted: the sounding note is byte-identical to one that never saw the publish, AND the
// next note-on takes the new rate. Asserting only the first would pass on a rate that never
// arrived at all.
static SampleData rampForReadRate() {
SampleData s;
s.frames.resize(200000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(static_cast<double>(i) / 200000.0);
}
s.sampleRate = kRate;
s.rootNote = 60;
s.play.adsr.sustainLevel = 1.0;
return s;
}
// A one-voice engine with its Preserve shifters actually SIZED, unlike renderWithLive's — the
// shared harness leaves them unconfigured, which silently routes a Preserve voice down the
// varispeed read and would make "in both engines" mean one engine twice.
static std::vector<AudioSample> renderPreserveCapable(SampleData& s, LiveParams& block,
const LiveValues* changed, int changeAfter,
int note) {
s.live = &block;
block.publish(foldLive(s.play));
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(note, 100);
std::vector<AudioSample> out;
for (int b = 0; b < 24; ++b) {
if (changed && b == changeAfter) block.publish(*changed);
engine.render(out, 512);
}
return out;
}
static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() {
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
SampleData still = rampForReadRate();
SampleData moved = rampForReadRate();
still.play.pitchEngine = eng;
moved.play.pitchEngine = eng;
LiveParams blockA, blockB;
LiveValues halfRate = foldLive(moved.play);
halfRate.playRate = 0.5;
// At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the
// output is then the source at the read head under both engines, which is what makes
// the ramp readable as a read rate at all.
const std::vector<AudioSample> baseline =
renderPreserveCapable(still, blockA, nullptr, -1, 60);
const std::vector<AudioSample> swept =
renderPreserveCapable(moved, blockB, &halfRate, 8, 60);
// BYTE-identical, not merely close: the sounding voice never reads the field.
CHECK(baseline.size() == swept.size());
bool untouched = true;
for (std::size_t i = 0; i < baseline.size() && i < swept.size(); ++i) {
if (baseline[i] != swept[i]) { untouched = false; break; }
}
CHECK(untouched);
// The next note-on takes it — measured as the note's LIFETIME, which is what Rate
// controls in both engines. (The ramp's instantaneous value is a read-position probe
// under Varispeed only: under Preserve the shifter's tap sits behind the feed and
// relocates at every splice, so the value at a given output frame is not the source
// there.) The rate is carried ONLY by the published block — sample.play keeps unity —
// so a lifetime that doubles can only have come from the block.
auto blocksAlive = [&](double rate) {
SampleData fresh = rampForReadRate();
fresh.play.pitchEngine = eng;
LiveParams block;
fresh.live = &block;
LiveValues published = foldLive(fresh.play);
published.playRate = rate;
block.publish(published);
VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(60, 100);
std::vector<AudioSample> out;
int blocks = 0;
while (engine.activeVoiceCount() > 0 && blocks < 4000) {
engine.render(out, 512);
++blocks;
}
return blocks;
};
const int atUnity = blocksAlive(1.0);
const int atHalf = blocksAlive(0.5);
CHECK(atUnity > 100 && atUnity < 4000); // the note really did run to its own end
CHECK(std::fabs(static_cast<double>(atHalf) - 2.0 * atUnity) < 0.05 * atUnity);
}
}
// Pitch is the other side of the same coin: it DOES move the note already sounding, under both
// engines — one more factor of the read increment under Varispeed, an addend to the shift under
// Preserve. Measured as a tail that departs from the untouched render while the frames before
// the publish stay byte-identical.
static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
SampleData still = periodicSine(200000, 64.0);
SampleData moved = periodicSine(200000, 64.0);
still.play.pitchEngine = eng;
moved.play.pitchEngine = eng;
LiveParams blockA, blockB;
LiveValues target = foldLive(moved.play);
target.pitchOffsetSemitones = -12.0;
const std::vector<AudioSample> baseline =
renderPreserveCapable(still, blockA, nullptr, -1, kTestNote);
const std::vector<AudioSample> swept =
renderPreserveCapable(moved, blockB, &target, 8, kTestNote);
double tailDiff = 0.0;
for (std::size_t i = 512 * 12; i < baseline.size(); ++i) {
tailDiff += std::fabs(static_cast<double>(swept[i]) -
static_cast<double>(baseline[i]));
}
CHECK(tailDiff > 1.0);
bool preChangeIdentical = true;
for (std::size_t i = 0; i < 512 * 8; ++i) {
if (swept[i] != baseline[i]) { preChangeIdentical = false; break; }
}
CHECK(preChangeIdentical);
}
}
// --- The live Pitch offset reaches the note's TIME domains, not only its pitch -------------
// A block published BEFORE the note starts is the snapLive path, and the snapshot's own copy of
// the offset is deliberately stale there — so this is where a Pitch offset has to be in hand
// already when the note's envelopes are fitted against the read rate. Answers how many output
// frames the voice sounded for, to a 256-frame block.
static std::size_t soundingBlocksWithPublishedPitch(SampleData& s, double offsetSemis,
std::size_t capFrames) {
LiveParams block;
LiveValues v = foldLive(s.play); // s.play keeps its own (zero) offset: the stale copy
v.pitchOffsetSemitones = offsetSemis;
block.publish(v);
s.live = &block;
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(60, 127);
std::vector<AudioSample> out;
std::size_t life = 0;
while (out.size() < capFrames && engine.activeVoiceCount() > 0) {
engine.render(out, 256);
life = out.size();
}
return life;
}
// Under Varispeed the Pitch offset is a factor of the read increment, and the staged AHD is
// evaluated at the SOURCE offset that increment advances — so its stage frames are fitted to the
// offset the note will ACTUALLY play at, exactly as they are to Rate. The attack therefore
// completes on the same output frame at every offset. Fitting against the snapshot's stale zero
// instead is what this catches.
static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() {
constexpr std::int64_t kAttack = 2000;
for (double semis : {-12.0, 0.0, 12.0}) {
SampleData s;
s.frames.assign(96000, 1.0f); // DC: the output IS the amp envelope
s.sampleRate = kRate;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
LiveParams block;
LiveValues v = foldLive(s.play);
v.pitchOffsetSemitones = semis;
block.publish(v);
s.live = &block;
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
engine.noteOn(60, 127);
std::vector<AudioSample> out;
engine.render(out, 8000);
std::size_t reachedFull = 0;
for (std::size_t i = 0; i < out.size(); ++i) {
if (out[i] > 0.99f) { reachedFull = i; break; }
}
const bool ok = reachedFull > 0 &&
std::fabs(static_cast<double>(reachedFull) -
static_cast<double>(kAttack)) < 40.0;
CHECK(ok);
if (!ok) std::printf(" pitch %+.1f st: attack completed at %zu\n", semis, reachedFull);
}
}
// The pitch envelope's SPAN is a wall-clock duration converted from the same read rate, so it
// follows the published offset too. Read out as the note's LIFETIME: the envelope's depth
// cancels the offset while it holds, so the read runs at unity for the hold and at the offset
// ratio after it — which makes the lifetime a direct readout of where the hold ended.
// 12000 source frames, offset -12 st (read at 0.5): the span is 24000 output frames, its
// half-span hold is 12000 of them at unity, and the source is exhausted exactly there.
// A span fitted to the stale zero offset is 12000, holds for 6000, and the remaining 6000
// source frames then take 12000 more output frames — 18000 in total.
static void testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan() {
SampleData s;
s.frames.assign(12000, 1.0f);
s.sampleRate = kRate;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigAhd = AhdParams{0, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0; // cancels the -12 offset while it holds
s.play.pitchEnv.shape.attackFrames = 0;
s.play.pitchEnv.shape.decayFrames = 0;
s.play.pitchEnv.shape.holdFraction = 0.5;
const std::size_t life = soundingBlocksWithPublishedPitch(s, -12.0, 60000);
CHECK(life > 11000 && life < 13000);
if (!(life > 11000 && life < 13000)) std::printf(" refit span: life %zu\n", life);
}
// --- What stays latched at note-on -------------------------------------------------------
static void testPitchRatioAndVelocityGainStayLatched() {
@@ -859,6 +1077,10 @@ int main() {
testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote();
testEveryLiveFilterControlMovesTheSoundingNote();
testOneBlockServesTwoIndependentObservers();
testARateChangeSpareTheSoundingNoteAndReachesTheNextOne();
testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines();
testAPublishedPitchOffsetLeavesTheStagedAttackWallClock();
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
testPitchRatioAndVelocityGainStayLatched();
testVelocityGainSurvivesAHostilePublishThatReallyLands();
if (g_fail == 0) std::printf("live_delivery tests passed\n");
+146
View File
@@ -24,6 +24,11 @@ static int g_fail = 0;
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static constexpr double kDepth = 24.0; // the pitch-depth throw the deck passes in today
// Rate's bounds, as the deck passes them in — the stretcher's own measured range. Written as
// literals HERE on purpose: this is the module's test, and reading the engine constant would
// make the test agree with the taper by construction rather than pin the numbers.
static constexpr double kRateMin = 0.5;
static constexpr double kRateMax = 2.0;
// --- modifiers -----------------------------------------------------------------------------
@@ -227,6 +232,109 @@ static void testDegenerateThrowCollapsesToCentre() {
CHECK(depthSemitonesFromNorm(0.9, 0.0) == 0.0);
}
// --- the rate taper -------------------------------------------------------------------------
// The three landmarks the range is specified by, all EXACT: half rate at norm 0, double at norm
// 1, and unity at TRUE knob centre — the last is what a detent has to be, and a map that merely
// came close to 1.0 there would persist a hair of transposition on an untouched knob.
static void testRateEndpointsAndCentreAreExact() {
CHECK(rateRatioFromNorm(0.0, kRateMin, kRateMax) == 0.5);
CHECK(rateRatioFromNorm(1.0, kRateMin, kRateMax) == 2.0);
CHECK(rateRatioFromNorm(0.5, kRateMin, kRateMax) == 1.0);
CHECK(rateNormFromRatio(0.5, kRateMin, kRateMax) == 0.0);
CHECK(rateNormFromRatio(2.0, kRateMin, kRateMax) == 1.0);
CHECK(rateNormFromRatio(1.0, kRateMin, kRateMax) == 0.5);
// Out of domain clamps rather than extrapolating — the map cannot reach a ratio the
// engine's own clamp would then have to move.
CHECK(rateRatioFromNorm(-1.0, kRateMin, kRateMax) == 0.5);
CHECK(rateRatioFromNorm(2.0, kRateMin, kRateMax) == 2.0);
CHECK(rateNormFromRatio(0.1, kRateMin, kRateMax) == 0.0);
CHECK(rateNormFromRatio(9.0, kRateMin, kRateMax) == 1.0);
}
// The taper's defining property, and the reason it is the exception to centre expansion: equal
// travel buys equal SEMITONES, everywhere. Checked as a constant ratio-of-ratios across the
// travel rather than at the two ends, which a centre-expanded map would also pass.
static void testRateIsLinearInSemitonesAcrossTheWholeTravel() {
const double step = 1.0 / 24.0; // 24 equal steps over 24 semitones
for (int i = 0; i < 24; ++i) {
const double lo = rateRatioFromNorm(static_cast<double>(i) * step, kRateMin, kRateMax);
const double hi = rateRatioFromNorm(static_cast<double>(i + 1) * step, kRateMin, kRateMax);
CHECK(std::fabs(hi / lo - std::exp2(1.0 / 12.0)) < 1e-12);
if (!(std::fabs(hi / lo - std::exp2(1.0 / 12.0)) < 1e-12)) return;
}
// The named musical landmarks that buys: an octave at each end, a fifth seven steps out.
CHECK(std::fabs(rateRatioFromNorm(0.5 + 7.0 / 24.0, kRateMin, kRateMax) -
std::exp2(7.0 / 12.0)) < 1e-12);
}
static void testRateIsMonotone() {
double prev = -1.0;
for (int i = 0; i <= 200000; ++i) {
const double v = rateRatioFromNorm(static_cast<double>(i) / 200000.0, kRateMin, kRateMax);
CHECK(v >= prev);
if (v < prev) return;
prev = v;
}
}
// The preimage obligation this control actually carries: its ONE default, bitwise, because a
// host's reset-to-default arrives as toPlain(defaultNorm) with no editor bypass to intercept it.
// Both endpoints are exact for the same reason. Everything between round-trips to within an ulp
// rather than bitwise — the map carries no output quantum, and the header says why.
static void testRateDefaultAndEndpointsRoundTripBitwise() {
CHECK(rateRatioFromNorm(rateNormFromRatio(1.0, kRateMin, kRateMax), kRateMin, kRateMax) == 1.0);
CHECK(rateRatioFromNorm(rateNormFromRatio(0.5, kRateMin, kRateMax), kRateMin, kRateMax) == 0.5);
CHECK(rateRatioFromNorm(rateNormFromRatio(2.0, kRateMin, kRateMax), kRateMin, kRateMax) == 2.0);
for (int milli = 500; milli <= 2000; milli += 7) {
const double ratio = static_cast<double>(milli) / 1000.0;
const double back =
rateRatioFromNorm(rateNormFromRatio(ratio, kRateMin, kRateMax), kRateMin, kRateMax);
CHECK(std::fabs(back - ratio) < 1e-14 * ratio);
if (!(std::fabs(back - ratio) < 1e-14 * ratio)) return;
}
}
// The exact-unity detent is DERIVED from the bounds, not assumed to sit at centre. The shipped
// bounds are reciprocal so the two agree today, but they are a MEASURED range: re-measure them
// asymmetric and a detent pinned to 0.5 makes the map fold back on itself around centre. Run at
// a deliberately non-reciprocal pair, which is exactly the case the ratio-of-ratios and
// round-trip tests above would still have passed.
static void testRateDetentFollowsAsymmetricBoundsInsteadOfCentre() {
constexpr double kLo = 0.4;
constexpr double kHi = 3.0; // kLo * kHi == 1.2, so unity is NOT at 0.5
const double unity = rateNormFromRatio(1.0, kLo, kHi);
CHECK(unity > 0.0 && unity < 1.0);
CHECK(std::fabs(unity - 0.5) > 0.01); // the case a 0.5 detent gets wrong
CHECK(rateRatioFromNorm(unity, kLo, kHi) == 1.0); // ...and unity is still EXACT there
double prev = -1.0;
for (int i = 0; i <= 200000; ++i) {
const double v = rateRatioFromNorm(static_cast<double>(i) / 200000.0, kLo, kHi);
CHECK(v >= prev);
if (v < prev) { std::printf(" asymmetric fold at i=%d\n", i); return; }
prev = v;
}
// That sweep steps OVER the detent rather than onto it, so walk its immediate neighbourhood
// too — a misplaced exact case shows up there and nowhere else.
for (int k = -8; k < 8; ++k) {
const double a = rateRatioFromNorm(unity + static_cast<double>(k) * 1e-9, kLo, kHi);
const double b = rateRatioFromNorm(unity + static_cast<double>(k + 1) * 1e-9, kLo, kHi);
CHECK(b >= a);
if (!(b >= a)) { std::printf(" detent fold at k=%d\n", k); return; }
}
// And the shipped reciprocal bounds still put unity at true knob centre: the general rule
// reproduces the special case rather than replacing it.
CHECK(rateNormFromRatio(1.0, kRateMin, kRateMax) == 0.5);
}
// Degenerate bounds are a caller bug, not a crash: the map collapses to unity.
static void testDegenerateRateBoundsCollapseToUnity() {
CHECK(rateRatioFromNorm(0.3, 2.0, 0.5) == 1.0);
CHECK(rateNormFromRatio(0.9, 2.0, 0.5) == 0.5);
CHECK(rateRatioFromNorm(0.3, 0.0, 2.0) == 1.0);
}
// --- the whole-unit snaps -------------------------------------------------------------------
static void testMillisecondSnap() {
@@ -255,6 +363,36 @@ static void testSemitoneSnap() {
kDepth) == 7.0);
}
// Rate's unit is the semitone though it displays as a percent, so Shift lands on the 25 steps
// between the bounds — which is what puts an octave and a fifth under the hand. The detent and
// both ends are reached EXACTLY, so a snap cannot leave the knob a hair off its own endpoint.
static void testRateSemitoneSnap() {
CHECK(snapRateRatioToWholeSemitone(1.0) == 1.0);
CHECK(snapRateRatioToWholeSemitone(0.5) == 0.5);
CHECK(snapRateRatioToWholeSemitone(2.0) == 2.0);
CHECK(std::fabs(snapRateRatioToWholeSemitone(1.5) - std::exp2(7.0 / 12.0)) < 1e-15);
// Just off a step in each direction resolves back onto it.
CHECK(std::fabs(snapRateRatioToWholeSemitone(std::exp2(7.0 / 12.0) * 1.005) -
std::exp2(7.0 / 12.0)) < 1e-15);
CHECK(std::fabs(snapRateRatioToWholeSemitone(std::exp2(7.0 / 12.0) * 0.995) -
std::exp2(7.0 / 12.0)) < 1e-15);
// Within a quarter-semitone of unity snaps to unity, not to a neighbouring step.
CHECK(snapRateRatioToWholeSemitone(std::exp2(0.25 / 12.0)) == 1.0);
CHECK(snapRateRatioToWholeSemitone(0.0) == 1.0); // unusable input parks at unity
CHECK(snapRateRatioToWholeSemitone(-1.0) == 1.0);
// What the knob actually stores after a Shift-drag is the snapped norm mapped back through
// the taper — so the property that matters is that THAT value is still a whole semitone.
// Measured in semitones, which is the unit the criterion is stated in.
for (int st = -12; st <= 12; ++st) {
const double norm =
rateNormFromRatio(std::exp2(static_cast<double>(st) / 12.0), kRateMin, kRateMax);
const double stored = rateRatioFromNorm(norm, kRateMin, kRateMax);
const double semis = 12.0 * std::log2(stored);
CHECK(std::fabs(semis - static_cast<double>(st)) < 1e-9);
if (!(std::fabs(semis - static_cast<double>(st)) < 1e-9)) return;
}
}
// The exponent snap reaches 1.0, the linear neutral — one snap from the dial's centre — and
// clamps into curve_law's own domain rather than rounding to a zero that is not an exponent.
static void testExponentSnap() {
@@ -285,9 +423,17 @@ int main() {
testEveryWholeSemitoneRoundTripsExactly();
testDegenerateThrowCollapsesToCentre();
testRateEndpointsAndCentreAreExact();
testRateIsLinearInSemitonesAcrossTheWholeTravel();
testRateIsMonotone();
testRateDefaultAndEndpointsRoundTripBitwise();
testRateDetentFollowsAsymmetricBoundsInsteadOfCentre();
testDegenerateRateBoundsCollapseToUnity();
testMillisecondSnap();
testPercentSnap();
testSemitoneSnap();
testRateSemitoneSnap();
testExponentSnap();
if (g_fail == 0) std::printf("param_taper: all tests passed\n");
+381 -4
View File
@@ -3070,12 +3070,14 @@ static void testPreserveStretchChangesDurationNotPitch() {
CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0));
CHECK(approx(period(fast, 2000, 9000), srcPeriod, 8.0));
// The non-tautology witness: VARISPEED is the engine that couples them. Reaching the same
// durations there costs exactly the pitch change Preserve refuses to make — so the three
// equal periods above are a property of the stretcher, not of the measurement.
// The non-tautology witness: VARISPEED is the engine that couples them. The SAME rate 0.5
// reaches the same doubled duration there, and pays for it with exactly the octave Preserve
// refuses to drop — so the three equal periods above are a property of the stretcher, not of
// the measurement.
std::size_t lifeVari = 0;
const std::vector<AudioSample> vari = run(0.5, PitchEngine::Varispeed, lifeVari);
CHECK(approx(static_cast<double>(lifeVari), 24000.0, 200.0)); // rate ignored under Varispeed
CHECK(approx(static_cast<double>(lifeVari), 48000.0, 400.0));
CHECK(approx(period(vari, 2000, 9000), srcPeriod * 2.0, 16.0));
SampleData down = s;
down.play.pitchEngine = PitchEngine::Varispeed;
Voice vv;
@@ -3091,6 +3093,373 @@ static void testPreserveStretchChangesDurationNotPitch() {
CHECK(approx(period(variDown, 2000, 9000), srcPeriod * 2.0, 16.0)); // ...at half pitch
}
// --- Rate, the Pitch offset and key-tracking compound into ONE read increment. ---
// Proved by IDENTITY rather than by measurement: under Varispeed the three factors land in one
// multiply, so three different ways of asking for the same total ratio must render BYTE for
// BYTE the same. A per-sample stage added for either new control, or one of them applied at a
// different point in the chain, breaks this equality even where a measured pitch still looks
// right — which a period measurement alone would not catch.
static void testKeyTrackRateAndPitchOffsetResolveToOneMultiply() {
SampleData base = sineSample(20000, 100.0);
base.play.adsr = flatAdsr();
base.play.pitchEngine = PitchEngine::Varispeed;
const std::size_t n = 8000;
auto render = [&](int note, double rate, double offsetSemis) {
SampleData s = base;
s.play.playRate = rate;
s.play.pitchOffsetSemitones = offsetSemis;
Voice v;
v.start(note, 127, s, /*declickTakeover=*/false, rate);
std::vector<AudioSample> out(n, 0.0f);
for (std::size_t i = 0; i < n; ++i) out[i] = v.renderFrame();
return out;
};
// Three routes to a half-speed, octave-down read: through the keyboard, through Rate, and
// through the Pitch offset.
const std::vector<AudioSample> viaNote = render(48, 1.0, 0.0);
const std::vector<AudioSample> viaRate = render(60, 0.5, 0.0);
const std::vector<AudioSample> viaOffset = render(60, 1.0, -12.0);
CHECK(hashStream(viaNote) == hashStream(viaRate));
CHECK(hashStream(viaNote) == hashStream(viaOffset));
// And they are not all trivially silent or all trivially unity — the route below differs.
CHECK(hashStream(viaNote) != hashStream(render(60, 1.0, 0.0)));
// They MULTIPLY rather than accumulate anywhere else: an octave down at the keyboard and a
// doubled Rate cancel exactly, back to the untransposed read.
CHECK(hashStream(render(48, 2.0, 0.0)) == hashStream(render(60, 1.0, 0.0)));
// Same cancellation across the other pair, so no factor is privileged.
CHECK(hashStream(render(60, 2.0, -12.0)) == hashStream(render(60, 1.0, 0.0)));
}
// Under PRESERVE the same three factors SPLIT: key-tracking and the Pitch offset drive the
// shifter's transpose, Rate drives duration alone. Asserted both ways round — the offset must
// move pitch WITHOUT moving duration, which is the mirror of the rate case beside it.
static void testPreserveRoutesRateToDurationAndTheOffsetToPitch() {
const std::int64_t w = 1024;
const std::size_t frames = 24000;
const double srcPeriod = 160.0;
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) / srcPeriod));
}
s.rootNote = 60;
s.play.adsr = flatAdsr();
s.play.pitchEngine = PitchEngine::Preserve;
auto run = [&](int note, double rate, double offsetSemis, std::size_t& life) {
SampleData local = s;
local.play.playRate = rate;
local.play.pitchOffsetSemitones = offsetSemis;
Voice v;
v.presizePreserveShifters(w);
v.start(note, 127, local, /*declickTakeover=*/false, rate);
std::vector<AudioSample> out;
out.reserve(frames * 3);
life = 0;
for (std::size_t i = 0; i < frames * 3 && v.active(); ++i) {
out.push_back(v.renderFrame());
++life;
}
return out;
};
auto period = [](const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
double sum = 0.0;
std::size_t prev = 0, count = 0;
for (std::size_t i = from + 1; i < to && i < v.size(); ++i) {
if (v[i - 1] <= 0.0f && v[i] > 0.0f) {
if (count > 0) sum += static_cast<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
};
std::size_t lifeFlat = 0, lifeDown = 0;
const std::vector<AudioSample> flat = run(60, 1.0, 0.0, lifeFlat);
const std::vector<AudioSample> down = run(60, 1.0, -12.0, lifeDown);
// Duration is untouched by the offset — only the transpose moved.
CHECK(approx(static_cast<double>(lifeFlat), 24000.0, 200.0));
CHECK(approx(static_cast<double>(lifeDown), 24000.0, 200.0));
CHECK(approx(period(flat, 2000, 9000), srcPeriod, 8.0));
CHECK(approx(period(down, 2000, 9000), srcPeriod * 2.0, 16.0));
// The offset and the keyboard reach the shifter through the SAME factor, so an octave down
// from either is the identical render.
std::size_t lifeNote = 0;
const std::vector<AudioSample> viaNote = run(48, 1.0, 0.0, lifeNote);
CHECK(hashStream(viaNote) == hashStream(down));
// …and Rate does not reach it at all: a rate change moves duration and leaves the period.
std::size_t lifeSlow = 0;
const std::vector<AudioSample> slow = run(60, 0.5, 0.0, lifeSlow);
CHECK(approx(static_cast<double>(lifeSlow), 48000.0, 400.0));
CHECK(approx(period(slow, 2000, 9000), srcPeriod, 8.0));
}
// The loop's AUDIBLE period scales with Rate while its stored frames — the marks the waveform
// draws — are never rewritten. The source is a ramp confined to the loop span, so the rendered
// stream is a sawtooth whose period IS the loop traversed once.
static void testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames() {
constexpr std::int64_t kLoopStart = 4000;
constexpr std::int64_t kLoopEnd = 8000;
SampleData base;
base.frames.assign(20000, 0.0f);
for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) {
base.frames[static_cast<std::size_t>(i)] =
static_cast<float>(i - kLoopStart) / static_cast<float>(kLoopEnd - kLoopStart);
}
base.rootNote = 60;
base.startFrame = kLoopStart;
base.loop = SampleLoop{true, kLoopStart, kLoopEnd};
base.play.adsr = flatAdsr();
base.play.pitchEngine = PitchEngine::Varispeed;
// Output frames between successive mid-ramp crossings — the loop's audible period. Measured
// on the RISING half rather than on the seam: at a fractional read position the seam frame is
// interpolated across the wrap, so the drop arrives as two half-steps and an edge detector
// either misses it or counts it twice. The ramp crosses its midpoint exactly once per cycle.
auto sawPeriod = [](const std::vector<AudioSample>& v) {
double sum = 0.0;
std::size_t prev = 0, count = 0;
for (std::size_t i = 1; i < v.size(); ++i) {
if (v[i - 1] <= 0.5f && v[i] > 0.5f) {
if (count > 0) sum += static_cast<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
};
for (double rate : {1.0, 0.5, 2.0}) {
SampleData s = base;
s.play.playRate = rate;
Voice v;
v.start(60, 127, s, /*declickTakeover=*/false, rate);
std::vector<AudioSample> out(30000, 0.0f);
for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame();
CHECK(approx(sawPeriod(out), 4000.0 / rate, 2.0));
// The stored span is a source-frame FACT: the engine reads it and never writes it, so
// the two waveform markers sit where they sat.
CHECK(s.loop.start == kLoopStart);
CHECK(s.loop.end == kLoopEnd);
CHECK(s.startFrame == kLoopStart);
}
}
// Preserve's half of the loop claim, and it is the OPPOSITE of the Varispeed one — written down
// here because the obvious extension of the test above is WRONG. Preserve consumes the loop at
// `rate` source frames per output frame, so the TRAVERSAL scales (the feed-side witness in
// testPreserveStretchLoopsTheSourceSpan measures that directly); what the listener hears does
// not, because holding the source's period while its duration changes is the definition of the
// engine. Measured with a ring long enough to hold the whole loop, so the reading is the design
// property rather than splice cadence — at shorter rings the same fixture measured 3064 and 4130
// frames at rate 0.5 (windows 1024 and 2048), neither of which is the 8000 a scaling period
// would give either.
static void testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal() {
constexpr std::int64_t kLoopStart = 4000;
constexpr std::int64_t kLoopEnd = 8000;
SampleData base;
base.frames.assign(20000, 0.0f);
for (std::int64_t i = kLoopStart; i < kLoopEnd; ++i) {
base.frames[static_cast<std::size_t>(i)] =
static_cast<float>(i - kLoopStart) / static_cast<float>(kLoopEnd - kLoopStart);
}
base.rootNote = 60;
base.startFrame = kLoopStart;
base.loop = SampleLoop{true, kLoopStart, kLoopEnd};
base.play.adsr = flatAdsr();
base.play.pitchEngine = PitchEngine::Preserve;
auto sawPeriod = [](const std::vector<AudioSample>& v) {
double sum = 0.0;
std::size_t prev = 0, count = 0;
for (std::size_t i = 1; i < v.size(); ++i) {
if (v[i - 1] <= 0.5f && v[i] > 0.5f) {
if (count > 0) sum += static_cast<double>(i - prev);
prev = i;
++count;
}
}
return count > 1 ? sum / static_cast<double>(count - 1) : 0.0;
};
for (double rate : {1.0, 0.5, 2.0}) {
SampleData s = base;
s.play.playRate = rate;
Voice v;
v.presizePreserveShifters(8192); // > the 4000-frame loop
v.start(60, 127, s, /*declickTakeover=*/false, rate);
std::vector<AudioSample> out(40000, 0.0f);
for (std::size_t i = 0; i < out.size(); ++i) out[i] = v.renderFrame();
const double period = sawPeriod(out);
CHECK(approx(period, 4000.0, 40.0));
if (!approx(period, 4000.0, 40.0)) std::printf(" rate %.2f period %.1f\n", rate, period);
// And the marks the waveform draws are source-frame FACTS the engine only ever reads.
CHECK(s.loop.start == kLoopStart);
CHECK(s.loop.end == kLoopEnd);
CHECK(s.startFrame == kLoopStart);
}
}
// The other half of the same rule, which nothing asserted: a drawn contour is a pure function of
// NORMALIZED sample position, so it follows the read head and its wall-clock shape scales by
// 1/rate — under BOTH engines, since both advance that head at the rate. Measured as the output
// frame the contour's own half-way point arrives on, which is what a listener hears move.
static void testADrawnContourScalesWithRateInBothEngines() {
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
double atUnity = 0.0;
for (double rate : {1.0, 0.5, 2.0}) {
SampleData s = dcSample(24000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = eng;
s.play.playRate = rate;
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = VelocityCurve::linear(); // 0 -> 1 across the sample
Voice v;
v.presizePreserveShifters(1024);
v.start(60, 127, s, /*declickTakeover=*/false, rate);
double halfway = 0.0;
for (std::size_t i = 0; i < 80000 && v.active(); ++i) {
const double y = static_cast<double>(v.renderFrame());
if (halfway == 0.0 && y > 0.5) halfway = static_cast<double>(i);
}
CHECK(halfway > 0.0);
if (rate == 1.0) atUnity = halfway;
// 12000 source frames in at unity; twice as many output frames at half rate.
else CHECK(approx(halfway, atUnity / rate, atUnity * 0.02));
if (rate != 1.0 && !approx(halfway, atUnity / rate, atUnity * 0.02)) {
std::printf(" eng %d rate %.2f: halfway %.0f, wanted %.0f\n",
static_cast<int>(eng), rate, halfway, atUnity / rate);
}
}
}
}
// Pitch is the same multiply as Rate under Varispeed, so the same rule binds it: a staged stage
// time is OF THE PERFORMANCE and does not scale. The AHD is the case that can go wrong, since it
// is evaluated at the SOURCE offset — which a Pitch offset advances faster or slower. Under
// Preserve the offset never touches the read, so the same attack lands on the same frame there
// for a different reason; asserted in both so the compensation cannot be applied to the wrong
// engine. Key-tracking is deliberately NOT compensated, and the last block pins that too.
static void testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed() {
constexpr std::int64_t kAttack = 2000;
SampleData base = dcSample(48000);
base.play.playMode = PlayMode::Trigger;
base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
const auto attackFrame = [](const SampleData& s, int note) {
Voice v;
v.presizePreserveShifters(1024);
v.start(note, 127, s, /*declickTakeover=*/false, s.play.playRate);
for (std::size_t i = 0; i < 200000 && v.active(); ++i) {
if (static_cast<double>(v.renderFrame()) > 0.99) return static_cast<double>(i);
}
return -1.0;
};
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
for (double semis : {-12.0, -5.0, 0.0, 7.0, 12.0}) {
SampleData s = base;
s.play.pitchEngine = eng;
s.play.pitchOffsetSemitones = semis;
const double got = attackFrame(s, 60);
CHECK(approx(got, static_cast<double>(kAttack), 40.0));
if (!approx(got, static_cast<double>(kAttack), 40.0)) {
std::printf(" eng %d pitch %+.1f st: attack completed at %.0f\n",
static_cast<int>(eng), semis, got);
}
}
}
// Key-tracking stays UNCOMPENSATED on purpose — it is a shipped sound, and compensating it
// would move every note off the root. An octave up therefore completes the attack in half
// the output frames, which is exactly the behaviour Pitch above does not have.
SampleData vari = base;
vari.play.pitchEngine = PitchEngine::Varispeed;
CHECK(approx(attackFrame(vari, 72), static_cast<double>(kAttack) / 2.0, 40.0));
}
// --- The Varispeed null case, baselined so the NEXT track's claim is measured. ---
// Unlike the Preserve hashes above, these were captured from THIS commit rather than witnessed
// against the pre-track one, and that difference is the whole reason the comment says so: the
// pre-track equality is proved structurally instead, and cheaply — at Rate 100 % and Pitch 0 st
// both new factors of recomputeBaseRatio's product are EXACTLY 1.0 (semitoneRatio short-circuits
// at zero; the clamp returns 1.0 for 1.0), and multiplying a double by 1.0 is bit-exact, so the
// read increment is the pre-track engine's own. What these constants add is a witness for the
// track AFTER this one. A change here is a change to what every already-saved project sounds
// like — re-derive the cause before re-baselining.
static void testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline() {
const std::size_t n = 6000;
struct Case { int note; bool stereo; bool loop; std::uint64_t hashL; std::uint64_t hashR; };
const Case cases[] = {
{60, false, false, 5964955069002935931ull, 0ull}, // on root: unity read
{67, false, false, 134881748704183217ull, 0ull}, // +7 st
{55, false, false, 11914283967735558216ull, 0ull}, // -5 st
{67, true, true, 11674273643338193955ull, 15241091931688620298ull}, // stereo + loop
};
for (const Case& c : cases) {
SampleData s = stretchProbeSample(4000, c.stereo);
s.play.pitchEngine = PitchEngine::Varispeed;
if (c.loop) {
s.loop.hasLoop = true;
s.loop.start = 1200;
s.loop.end = 3600;
s.loopCrossfadeFrames = 256;
}
std::vector<AudioSample> l(n), r(c.stereo ? n : 0);
renderVoice(s, c.note, /*rate=*/1.0, /*window=*/2205, c.stereo, l, r);
const std::uint64_t hl = hashStream(l);
CHECK(hl == c.hashL);
if (hl != c.hashL) std::printf(" varispeed note %d L hash %lluull\n", c.note, hl);
if (c.stereo) {
const std::uint64_t hr = hashStream(r);
CHECK(hr == c.hashR);
if (hr != c.hashR) std::printf(" varispeed note %d R hash %lluull\n", c.note, hr);
}
}
}
// The asymmetry the spec is explicit about: a contour is OF THE SAMPLE and scales with Rate, a
// staged envelope is OF THE PERFORMANCE and does not. Trigger's AHD is the case that could go
// wrong — it is evaluated at the SOURCE offset, which advances at the rate — so its stage frames
// are fitted to that rate at note-on. Measured as the OUTPUT frame the attack completes on.
static void testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes() {
constexpr std::int64_t kAttack = 2000;
SampleData base = dcSample(24000);
base.play.playMode = PlayMode::Trigger;
base.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
std::size_t lifeAtUnity = 0;
for (double rate : {1.0, 2.0, 0.5}) {
SampleData s = base;
s.play.pitchEngine = eng;
s.play.playRate = rate;
Voice v;
v.presizePreserveShifters(1024);
v.start(60, 127, s, /*declickTakeover=*/false, rate);
std::size_t life = 0, reachedFull = 0;
for (std::size_t i = 0; i < 80000 && v.active(); ++i) {
const double y = static_cast<double>(v.renderFrame());
if (reachedFull == 0 && y > 0.99) reachedFull = i;
++life;
}
// The attack is wall clock: the same OUTPUT frame at every rate.
CHECK(approx(static_cast<double>(reachedFull), static_cast<double>(kAttack), 40.0));
// …while the play span itself is source frames, so the note's length DOES scale.
if (rate == 1.0) lifeAtUnity = life;
else CHECK(approx(static_cast<double>(life),
static_cast<double>(lifeAtUnity) / rate,
static_cast<double>(lifeAtUnity) * 0.02));
}
}
}
// --- The onset is a regression surface: no added latency at ANY rate. ---
static void testPreserveStretchSpeaksOnFrameZeroAtEveryRate() {
const std::int64_t w = 2048;
@@ -3440,6 +3809,14 @@ int main() {
testPreserveUnityRateIsBitIdenticalToTheShippedRead();
testSourcePeriodChangesTheRenderedStream();
testPreserveStretchChangesDurationNotPitch();
testKeyTrackRateAndPitchOffsetResolveToOneMultiply();
testPreserveRoutesRateToDurationAndTheOffsetToPitch();
testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames();
testPreserveHoldsTheLoopsAudiblePeriodWhileRateMovesItsTraversal();
testADrawnContourScalesWithRateInBothEngines();
testAPitchOffsetLeavesTheStagedAttackWallClockUnderVarispeed();
testVarispeedUnityRateAndPitchAreBitIdenticalToTheirBaseline();
testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes();
testPreserveStretchSpeaksOnFrameZeroAtEveryRate();
testPreserveStretchLoopsTheSourceSpan();
testPreserveStretchThirtyTwoVoicesHoldUp();