PITCH/RATE deck: Rate and Pitch knobs compounded into one read increment, on a three-state commit predicate and payload v16
This commit is contained in:
+204
-4
@@ -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,200 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +3636,10 @@ int main() {
|
||||
testPreserveUnityRateIsBitIdenticalToTheShippedRead();
|
||||
testSourcePeriodChangesTheRenderedStream();
|
||||
testPreserveStretchChangesDurationNotPitch();
|
||||
testKeyTrackRateAndPitchOffsetResolveToOneMultiply();
|
||||
testPreserveRoutesRateToDurationAndTheOffsetToPitch();
|
||||
testRateScalesTheLoopPeriodWithoutMovingItsStoredFrames();
|
||||
testStagedStageTimesDoNotScaleWithRateWhileTheSpanDoes();
|
||||
testPreserveStretchSpeaksOnFrameZeroAtEveryRate();
|
||||
testPreserveStretchLoopsTheSourceSpan();
|
||||
testPreserveStretchThirtyTwoVoicesHoldUp();
|
||||
|
||||
Reference in New Issue
Block a user