Play cross-rate captures at their own pitch and length
The voice now converts the file's rate to the host's at note-on instead of assuming they match. Stage times resolve at the host rate, the filter solves at it, and the bake follows. Same-rate renders are unchanged bit for bit.
This commit is contained in:
+58
-20
@@ -891,38 +891,74 @@ static void testBuildSampleDataThreadsEverything() {
|
||||
p.keyTrack = 0.25;
|
||||
p.play.adsr.attackSeconds = 0.01;
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}});
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}}, 48000);
|
||||
CHECK(sd.playable());
|
||||
CHECK(sd.frames.size() == 3);
|
||||
CHECK(sd.sampleRate == 48000);
|
||||
CHECK(sd.renderRate == 48000);
|
||||
CHECK(sd.rootNote == 40); // the override, not the capture's 60
|
||||
CHECK(sd.loop.hasLoop && sd.loop.start == 10 && sd.loop.end == 90);
|
||||
CHECK(sd.startFrame == 7);
|
||||
CHECK(sd.keyTrack == 0.25);
|
||||
CHECK(sd.channelCount() == 1);
|
||||
// Wall-clock seconds resolve to frames at THIS decode's rate.
|
||||
// Wall-clock seconds resolve to frames at the render rate.
|
||||
CHECK(sd.play.adsr.attackFrames == 480);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataResolvesSecondsAtTheDecodeRate() {
|
||||
// The rate that governs the conversion is the DECODE's, not a baked constant: the same
|
||||
// parameter set built against two decodes yields two different frame counts.
|
||||
static void testBuildSampleDataResolvesSecondsAtTheRenderRate() {
|
||||
// The voice counts every stage in the frames it EMITS, so the host's rate governs the
|
||||
// conversion, never the decode's: two decodes rendered at one host rate resolve alike, and
|
||||
// one decode rendered at two host rates does not.
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.1;
|
||||
p.play.adsr.releaseSeconds = 0.25;
|
||||
p.play.trigAhd.attackSeconds = 0.02;
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), p);
|
||||
const SampleData at44 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}});
|
||||
const SampleData at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}});
|
||||
CHECK(at44.play.adsr.attackFrames == 4410);
|
||||
CHECK(at44.play.adsr.releaseFrames == 11025);
|
||||
CHECK(at96.play.adsr.attackFrames == 9600);
|
||||
CHECK(at96.play.adsr.releaseFrames == 24000);
|
||||
const SampleData file44at48 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}, 48000);
|
||||
const SampleData file96at48 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}}, 48000);
|
||||
const SampleData file44at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}, 96000);
|
||||
CHECK(file44at48.play.adsr.attackFrames == 4800);
|
||||
CHECK(file44at48.play.adsr.releaseFrames == 12000);
|
||||
CHECK(file44at48.play.trigAhd.attackFrames == 960);
|
||||
CHECK(file96at48.play.adsr.attackFrames == 4800);
|
||||
CHECK(file96at48.play.adsr.releaseFrames == 12000);
|
||||
CHECK(file44at96.play.adsr.attackFrames == 9600);
|
||||
CHECK(file44at96.play.adsr.releaseFrames == 24000);
|
||||
// The decode's rate stays the SOURCE's, and the conversion between the two is the ratio.
|
||||
CHECK(file44at48.sampleRate == 44100 && file44at48.renderRate == 48000);
|
||||
CHECK(file44at48.sourceRateRatio() == 44100.0 / 48000.0);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataWithNoHostRateRendersAtTheDecodeRate() {
|
||||
// Before the host reports a rate there is nothing to convert to: the capture renders at its
|
||||
// own rate, exactly as a hand-built SampleData does, until a rebuild supplies one.
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.1;
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f}, 44100, {}}, 0);
|
||||
CHECK(sd.playable());
|
||||
CHECK(sd.renderRate == 0);
|
||||
CHECK(sd.outputRate() == 44100);
|
||||
CHECK(sd.sourceRateRatio() == 1.0);
|
||||
CHECK(sd.play.adsr.attackFrames == 4410);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataRefusesARateNoCaptureCouldHave() {
|
||||
// The ceiling is a bound on audio-thread work, so the refusal sits exactly at it.
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{});
|
||||
const int host = 48000;
|
||||
const auto atCeiling = static_cast<int>(host * kMaxSourceRateRatio);
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling, {}}, host).playable());
|
||||
const SampleData corrupt = buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling + 1, {}}, host);
|
||||
CHECK(!corrupt.playable());
|
||||
// Far BELOW the host costs nothing per frame, so it plays.
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, 1000, {}}, host).playable());
|
||||
}
|
||||
|
||||
static void testBuildSampleDataCarriesTheSecondChannel() {
|
||||
const SampleData sd = buildSampleData(
|
||||
resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}});
|
||||
DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}}, 44100);
|
||||
CHECK(sd.channelCount() == 2);
|
||||
CHECK(sd.framesR.size() == 2 && approx(sd.framesR[0], 0.9) && approx(sd.framesR[1], 0.8));
|
||||
}
|
||||
@@ -931,7 +967,7 @@ static void testBuildSampleDataDropsMismatchedSecondChannel() {
|
||||
// A malformed pair must fall back to MONO rather than half-playing.
|
||||
const SampleData sd = buildSampleData(
|
||||
resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}});
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}}, 44100);
|
||||
CHECK(sd.channelCount() == 1);
|
||||
CHECK(sd.framesR.empty());
|
||||
}
|
||||
@@ -941,7 +977,7 @@ static void testBuildSampleDataEmptyPcmIsUnplayable() {
|
||||
// SampleData (silence), never a voice started on an empty read span. (A non-positive
|
||||
// rate is a programming error the build asserts on, so it is not exercised here.)
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{});
|
||||
const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}});
|
||||
const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}}, 44100);
|
||||
CHECK(!sd.playable());
|
||||
CHECK(sd.frames.empty());
|
||||
}
|
||||
@@ -959,7 +995,7 @@ static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() {
|
||||
std::sin(2.0 * 3.14159265358979323846 * 30.0 * static_cast<double>(i) / rate));
|
||||
}
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz
|
||||
}
|
||||
|
||||
@@ -984,13 +1020,13 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
|
||||
|
||||
InstrumentParams noLoop;
|
||||
const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(bare.sourcePeriodFrames == 0.0); // no loop -> whole source -> no ONE period
|
||||
|
||||
InstrumentParams looped;
|
||||
looped.loopOverride = SampleLoop{true, loopStart, static_cast<std::int64_t>(frames)};
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), looped),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(std::fabs(sd.sourcePeriodFrames - loopPeriod) < 2.0);
|
||||
|
||||
// A loop too short to host the full search band falls back to the whole source rather than
|
||||
@@ -1000,7 +1036,7 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
|
||||
InstrumentParams shortLoop;
|
||||
shortLoop.loopOverride = SampleLoop{true, 118000, static_cast<std::int64_t>(frames)};
|
||||
const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(shortSd.sourcePeriodFrames == 0.0);
|
||||
}
|
||||
|
||||
@@ -1008,7 +1044,7 @@ static void testBuildSampleDataCarriesTheVelocityCurve() {
|
||||
InstrumentParams p;
|
||||
p.velocityCurve = VelocityCurve::linear();
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f}, 44100, {}});
|
||||
DecodedPcm{{0.1f}, 44100, {}}, 44100);
|
||||
// The curve reaches the engine's own copy: a mid velocity maps to ~half gain, which the
|
||||
// flat default would not do.
|
||||
CHECK(std::fabs(sd.velocityCurve.eval(64.0) - 64.0 / 127.0) < 1e-6);
|
||||
@@ -1067,7 +1103,9 @@ int main() {
|
||||
testResolveNoPickAndStaleIdAreSilence();
|
||||
testResolveFromRefsNeedsNoBankAtAll();
|
||||
testBuildSampleDataThreadsEverything();
|
||||
testBuildSampleDataResolvesSecondsAtTheDecodeRate();
|
||||
testBuildSampleDataResolvesSecondsAtTheRenderRate();
|
||||
testBuildSampleDataWithNoHostRateRendersAtTheDecodeRate();
|
||||
testBuildSampleDataRefusesARateNoCaptureCouldHave();
|
||||
testBuildSampleDataCarriesTheSecondChannel();
|
||||
testBuildSampleDataDropsMismatchedSecondChannel();
|
||||
testBuildSampleDataEmptyPcmIsUnplayable();
|
||||
|
||||
Reference in New Issue
Block a user