S12: store wall-clock ADSR/pitch-env as seconds, resolve to frames at live rate

Kill kTier0Nominal*, adsrNeedsRateResolve, tier0Adsr, gateAdsr. Zones payload v5 carries
seconds; v3 legacy reads convert at the frozen authoring rate; v4 (branch-only) dropped.
Editor sliders now seconds. Engine takes frames resolved at keymap build.
This commit is contained in:
2026-07-27 02:51:46 -04:00
parent 1d338318e7
commit a54af277e4
10 changed files with 539 additions and 433 deletions
+57 -58
View File
@@ -149,7 +149,7 @@ static void testRepitchObservedPeriod() {
// Unity: played at root, observed period ~= native.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km, flatAdsr());
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, frames);
@@ -159,7 +159,7 @@ static void testRepitchObservedPeriod() {
// +1 octave: advances 2x, observed period halves.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km, flatAdsr());
VoiceEngine eng(4, km);
eng.noteOn(72, 127);
std::vector<AudioSample> out;
eng.render(out, frames / 2); // half as many frames covers the whole sample
@@ -169,7 +169,7 @@ static void testRepitchObservedPeriod() {
// -1 octave: advances 0.5x, observed period doubles.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
VoiceEngine eng(4, km, flatAdsr());
VoiceEngine eng(4, km);
eng.noteOn(48, 127);
std::vector<AudioSample> out;
eng.render(out, frames);
@@ -285,7 +285,7 @@ static void testAdsrZeroAttackDecay() {
static void testPolyphonicAllocation() {
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
VoiceEngine eng(8, km, flatAdsr());
VoiceEngine eng(8, km);
// Four simultaneous notes -> four active voices, each on a distinct voice.
std::size_t v60 = eng.noteOn(60, 100);
@@ -329,10 +329,11 @@ static void testNoteOffReleasesNewestSameNote() {
const double gainOld = velOld / 127.0; // ~0.504
const double gainNew = velNew / 127.0; // 1.0
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60));
AdsrParams a = flatAdsr();
a.releaseFrames = 10; // short but non-zero so voice stays active through release
VoiceEngine eng(8, km, a);
SampleData sd = dcSample(100000, 60);
sd.play.adsr = flatAdsr();
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
Keymap km = Keymap::singleSampleChromatic(sd);
VoiceEngine eng(8, km);
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain
@@ -371,7 +372,7 @@ static void testOutOfZoneNoteConsumesNoVoice() {
Keymap km;
km.samples.push_back(dcSample(100, 60));
km.zones.push_back(KeyZone{60, 72, 60, 0});
VoiceEngine eng(4, km, flatAdsr());
VoiceEngine eng(4, km);
std::size_t v = eng.noteOn(30, 100); // below the only zone
CHECK(v == VoiceEngine::kNoVoice);
@@ -383,14 +384,13 @@ static void testOutOfZoneNoteConsumesNoVoice() {
// ---------------------------------------------------------------------------
static void testStealsReleasingVoiceFirst() {
// Long per-zone release so the voice stays active through the release tail.
// Per the S12 fix, Voice::start uses sample.play.adsr — not the engine's gateAdsr —
// so the long release must live on the SampleData, not on the VoiceEngine constructor arg.
// Long per-zone release so the voice stays active through the release tail. Voice::start reads
// sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData.
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, flatAdsr());
VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
@@ -415,7 +415,7 @@ static void testStealsOldestWhenNoneReleasing() {
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, flatAdsr());
VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
@@ -453,7 +453,7 @@ static void testLoopSustainSeamless() {
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out;
@@ -475,7 +475,7 @@ static void testZeroLengthLoopGoesSilent() {
s.loop.start = 25;
s.loop.end = 25; // zero length
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -499,7 +499,7 @@ static void testSingleFrameLoop() {
s.loop.end = 6; // single-frame loop: [5, 6)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out;
@@ -518,7 +518,7 @@ static void testAbsentLoopGoesSilent() {
SampleData s = dcSample(50, 60);
// s.loop.hasLoop stays false.
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 100);
@@ -539,7 +539,7 @@ static void testStartFrameOffsetsInitialRead() {
s.rootNote = 60;
s.startFrame = 30;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> out;
eng.render(out, 3);
@@ -555,7 +555,7 @@ static void testStartFrameZeroIsUnchanged() {
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
s.rootNote = 60; // startFrame stays 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1);
@@ -568,7 +568,7 @@ static void testStartFrameOutOfRangeClampsToZero() {
SampleData s = dcSample(10, 60); // 10 frames of 1.0
s.startFrame = 10; // == frameCount: out of range
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 5);
@@ -589,7 +589,7 @@ static void testStartFrameWithLoop() {
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 200);
@@ -618,7 +618,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
std::vector<AudioSample> out;
@@ -644,21 +644,21 @@ static void testVelocityToVolume() {
// Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the
// rendered value is exactly velocity/127 on a DC-1 sample).
{
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 1.0, 1e-4));
}
{
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 64);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
}
{
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 1);
std::vector<AudioSample> out;
eng.render(out, 1);
@@ -669,7 +669,7 @@ static void testVelocityToVolume() {
// Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
VoiceEngine eng(4, km, flatAdsr());
VoiceEngine eng(4, km);
eng.noteOn(60, 127); // gain 1.0
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
std::vector<AudioSample> out;
@@ -705,7 +705,7 @@ static void testStereoRenderKeepsChannelsDistinct() {
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
@@ -720,7 +720,7 @@ static void testMonoSamplePlaysDualMonoInStereo() {
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
eng.render(left.data(), right.data(), 8);
@@ -734,7 +734,7 @@ static void testMonoRenderUnchangedByStereoData() {
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
VoiceEngine engS(1, kmS, flatAdsr());
VoiceEngine engS(1, kmS);
engS.noteOn(60, 127);
std::vector<AudioSample> mono;
engS.render(mono, 8); // the mono overload
@@ -759,7 +759,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
}
s.rootNote = 60;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(4, km, flatAdsr());
VoiceEngine eng(4, km);
eng.noteOn(72, 127); // +1 octave
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
eng.render(left.data(), right.data(), frames / 2);
@@ -770,7 +770,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
static void testStereoRenderSumsVoicesPerChannel() {
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
VoiceEngine eng(4, km, flatAdsr());
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(60, 127); // second voice, same note
std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
@@ -781,7 +781,7 @@ static void testStereoRenderSumsVoicesPerChannel() {
static void testStereoRenderNullBufferIsNoOp() {
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> buf(4, 0.f);
eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash
@@ -810,7 +810,7 @@ static void testStereoStartFrameLoopShareOneReadHead() {
s.loop.end = 30; // loop [20,30): frames 20..29
CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> left(200, 0.f), right(200, 0.f);
@@ -908,7 +908,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction,
static void testTriggerLengthFractionFrames() {
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio
std::vector<AudioSample> out;
eng.render(out, 200);
@@ -922,7 +922,7 @@ static void testTriggerLengthFractionFrames() {
static void testTriggerLengthWithStart() {
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 200);
@@ -936,7 +936,7 @@ static void testTriggerFadeShape() {
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 120);
@@ -956,7 +956,7 @@ static void testTriggerEdgeCases() {
// %=0: zero play length -> voice frees at once, no sound.
{
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 50);
@@ -967,7 +967,7 @@ static void testTriggerEdgeCases() {
{
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 50);
@@ -977,7 +977,7 @@ static void testTriggerEdgeCases() {
// %=100 plays the full post-start span.
{
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 80);
@@ -989,7 +989,7 @@ static void testTriggerEdgeCases() {
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
static void testTriggerIgnoresNoteOff() {
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 10);
@@ -1039,7 +1039,7 @@ static void testPreserveDurationInvariance() {
auto lengthAt = [&](int note) -> std::size_t {
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
VoiceEngine eng(1, km, flatAdsr(), /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
eng.noteOn(note, 127);
return soundingLength(eng, 4000);
};
@@ -1066,7 +1066,7 @@ static void testVarispeedStillCouplesDuration() {
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(note, 127);
return soundingLength(eng, 4000);
};
@@ -1091,7 +1091,7 @@ static void testPitchEnvOffBitIdentical() {
s.play.pitchEnv.decayFrames = 500;
}
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
std::vector<AudioSample> out;
eng.render(out, n);
@@ -1119,7 +1119,7 @@ static void testPitchEnvOnBendsVarispeed() {
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
std::vector<AudioSample> out;
eng.render(out, 4000);
@@ -1154,7 +1154,7 @@ static void testPreserveGateStereoLoopComposes() {
s.play.pitchEngine = PitchEngine::Preserve;
CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr(), 0, 512);
VoiceEngine eng(1, km, 0, 512);
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
eng.render(left.data(), right.data(), 2000);
@@ -1176,23 +1176,22 @@ static void testPreserveVoiceCap() {
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
// 8 voices total, Preserve cap of 2.
VoiceEngine eng(8, km, flatAdsr(), /*preserveCap=*/2, /*window=*/256);
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
CHECK(eng.activeVoiceCount() == 2);
}
// --- S12 review fix: per-zone A/D/S/R actually reaches the voice envelope. ---
// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
//
// Before the fix, Voice::start used the instrument-wide gateAdsr for A/D/S/R and only
// folded the per-zone holdFrames. These two tests assert the corrected path.
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
// keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path.
// The zone's attackFrames drives the envelope ramp — NOT the VoiceEngine's gateAdsr.
// Strategy: give the VoiceEngine a FLAT gateAdsr (instant attack) but put an explicit
// 10-frame attack on the SampleData.play.adsr. If Voice::start reads the zone ADSR, the
// DC-1 output will be 0 at frame 0 and 1.0 after the 10-frame ramp. If it instead used
// gateAdsr (flat = instant), frame 0 would already be 1.0. This is the load-bearing proof.
// The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on
// the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame
// 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be
// 1.0 at frame 0. This is the load-bearing proof.
static void testPerZoneAdsrReachesVoiceEnvelope() {
SampleData s = dcSample(500, 60);
// Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release.
@@ -1203,11 +1202,11 @@ static void testPerZoneAdsrReachesVoiceEnvelope() {
s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); // instrument-wide gateAdsr = flat (instant attack)
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
std::vector<AudioSample> out;
eng.render(out, 20);
// Frame 0: attack start, envelope near 0. If gateAdsr (flat) were used, this would be 1.0.
// Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here.
CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp
// Frame 9: still ramping (last attack frame, linear ramp reaches 0.9).
CHECK(out[9] < 1.0 - 1e-9);
@@ -1226,7 +1225,7 @@ static void testZeroAdsrIsInstantSustain() {
s.play.adsr = AdsrParams{};
s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 5);