instrument: latch the note done at the read-head run-off, fit the migrated fades, and lift the curve dial and overlay selection into pure modules
This commit is contained in:
@@ -7,9 +7,12 @@
|
||||
// link is a regression.
|
||||
|
||||
#include "../src/core/instrument/map/component_state_io.h"
|
||||
#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/util/curve_law.h" // kCurveNeutral (the migration neutral)
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
@@ -767,10 +770,94 @@ static void testSingleZoneMigrationIsLossless() {
|
||||
CHECK(p.play.adsr.attackCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.adsr.decayCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.adsr.releaseCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.trigAhd.attackCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.trigAhd.decayCurve == util::kCurveNeutral);
|
||||
CHECK(p.play.pitchEnv.shape.holdFraction == 0.0);
|
||||
CHECK(p.play.filter.env.attackCurve == util::kCurveNeutral);
|
||||
// The ONE exception, and the reason it is one: the fades had a prior SHAPE to reproduce,
|
||||
// so they lift to the fitted exponents rather than to the neutral (see the contour test).
|
||||
CHECK(p.play.trigAhd.attackCurve == kTriggerFadeLiftAttackCurve);
|
||||
CHECK(p.play.trigAhd.decayCurve == kTriggerFadeLiftDecayCurve);
|
||||
}
|
||||
|
||||
// The migrated Trigger amp shape against the retired EQUAL-POWER fade pair it replaced. The
|
||||
// AHD's law is phi^p and cannot reproduce sin/cos at any exponent, so the claim is a bound —
|
||||
// and the bound the fitted exponents reach is several times tighter than the linear neutral's,
|
||||
// which is what makes the fit worth a constant.
|
||||
static void testMigratedFadeContourTracksTheRetiredEqualPowerShape() {
|
||||
const double rate = 48000.0;
|
||||
const std::int64_t fadeIn = 200;
|
||||
const std::int64_t fadeOut = 300;
|
||||
const std::int64_t span = 1000;
|
||||
|
||||
legacy::Zone z;
|
||||
z.sampleId = "kick";
|
||||
z.trigger = true;
|
||||
z.lengthFraction = 1.0;
|
||||
z.fadeIn = fadeIn;
|
||||
z.fadeOut = fadeOut;
|
||||
const ComponentState st =
|
||||
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), rate);
|
||||
const AhdSeconds& lifted = st.params.play.trigAhd;
|
||||
|
||||
// Resolve the lifted seconds back to frames at the SAME rate the lift used, which is the
|
||||
// matched-rate case (the mismatched one is asserted in sample_map_tests).
|
||||
const auto toFrames = [rate](double sec) {
|
||||
return static_cast<std::int64_t>(sec * rate + 0.5);
|
||||
};
|
||||
AhdParams migrated;
|
||||
migrated.attackFrames = toFrames(lifted.attackSeconds);
|
||||
migrated.decayFrames = toFrames(lifted.decaySeconds);
|
||||
migrated.holdFraction = lifted.holdFraction;
|
||||
migrated.attackCurve = lifted.attackCurve;
|
||||
migrated.decayCurve = lifted.decayCurve;
|
||||
// Stage LENGTHS are exact: the fades land on the same frames they always did.
|
||||
AhdEnvelope ahd;
|
||||
ahd.configure(span, migrated);
|
||||
CHECK(ahd.stages().attack == fadeIn);
|
||||
CHECK(ahd.stages().decay == fadeOut);
|
||||
CHECK(ahd.stages().total == span);
|
||||
|
||||
// The pre-change evaluator, written out so the comparison is against a stated reference
|
||||
// rather than against whatever the code now does.
|
||||
const double pi = 3.14159265358979323846;
|
||||
const auto retired = [&](double off) {
|
||||
if (off < 0.0 || off >= static_cast<double>(span)) return 0.0;
|
||||
if (off < static_cast<double>(fadeIn)) {
|
||||
return std::sin(off / static_cast<double>(fadeIn) * (pi / 2.0));
|
||||
}
|
||||
const double foStart = static_cast<double>(span - fadeOut);
|
||||
if (off >= foStart) {
|
||||
return std::cos((off - foStart) / static_cast<double>(fadeOut) * (pi / 2.0));
|
||||
}
|
||||
return 1.0;
|
||||
};
|
||||
const auto worstAgainstRetired = [&](AhdEnvelope& env) {
|
||||
double worst = 0.0;
|
||||
for (std::int64_t i = 0; i < span; ++i) {
|
||||
const double d = env.amplitudeAt(static_cast<double>(i)) -
|
||||
retired(static_cast<double>(i));
|
||||
worst = worst > std::fabs(d) ? worst : std::fabs(d);
|
||||
}
|
||||
return worst;
|
||||
};
|
||||
|
||||
const double fitted = worstAgainstRetired(ahd);
|
||||
CHECK(fitted <= 0.0876); // the measured minimax bound of phi^p against sin(pi*phi/2)
|
||||
|
||||
// The rejected alternative, evaluated rather than asserted about: the same lift at the
|
||||
// linear neutral. If the fitted exponents were ever dropped this comparison inverts.
|
||||
AhdParams neutralLift = migrated;
|
||||
neutralLift.attackCurve = util::kCurveNeutral;
|
||||
neutralLift.decayCurve = util::kCurveNeutral;
|
||||
AhdEnvelope neutral;
|
||||
neutral.configure(span, neutralLift);
|
||||
const double neutralWorst = worstAgainstRetired(neutral);
|
||||
CHECK(neutralWorst > 0.21);
|
||||
CHECK(fitted < neutralWorst * 0.5);
|
||||
|
||||
// Both agree exactly where it matters structurally: the onset, the plateau, and the end.
|
||||
CHECK(ahd.amplitudeAt(0.0) == retired(0.0));
|
||||
CHECK(ahd.amplitudeAt(600.0) == retired(600.0));
|
||||
CHECK(ahd.amplitudeAt(static_cast<double>(span)) == retired(static_cast<double>(span)));
|
||||
}
|
||||
|
||||
// A prior ZERO fade-out lands Decay = 0: the abrupt end an old Trigger instance could express
|
||||
@@ -1165,6 +1252,7 @@ int main() {
|
||||
testEnvelopePrefixBytesFrozen();
|
||||
testWriterEmitsCurrentPayloadVersion();
|
||||
testSingleZoneMigrationIsLossless();
|
||||
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
|
||||
testZeroFadeOutMigratesToZeroDecay();
|
||||
testSingleZoneMigrationLiftsLoopDisablingOverride();
|
||||
testLiftedStateReSavesInCurrentFormat();
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
// makes a pre-existing instance play unchanged); endpoint exactness at every exponent (no
|
||||
// segment can overshoot its own endpoint levels); monotonicity and finiteness across the full
|
||||
// 0.1..10 domain including both endpoints; the mid-level inverse the overlay knot drags
|
||||
// through, and its round trip against the exponent.
|
||||
// through, and its round trip against the exponent; and the inner dial's own travel — exact at
|
||||
// the neutral centre, and reachable there from a real drag grid.
|
||||
|
||||
#include "../src/core/util/curve_law.h"
|
||||
|
||||
@@ -106,6 +107,72 @@ static void testMidLevelInverseSaturates() {
|
||||
CHECK(std::fabs(curveFromMidLevel(0.5) - kCurveNeutral) < 1e-12);
|
||||
}
|
||||
|
||||
// --- The inner dial's travel ---------------------------------------------------
|
||||
|
||||
// The knob drag delivers `start - dy/kKnobDragRangePixels`. param_slider owns that constant and
|
||||
// this module deliberately does not link it, so the step is restated here; the structural
|
||||
// assertion below is what keeps the detent wide enough for whatever it is.
|
||||
static constexpr double kKnobStep = 1.0 / 128.0;
|
||||
|
||||
// The dial's centre must reach the neutral EXACTLY, in both directions — an exponent a hair off
|
||||
// 1.0 costs a std::pow per sample per voice forever on a stage the user believes is at rest.
|
||||
static void testKnobLawIsExactAtTheNeutralCentre() {
|
||||
CHECK(knobNormFromCurve(kCurveNeutral) == 0.5);
|
||||
CHECK(curveFromKnobNorm(0.5) == kCurveNeutral);
|
||||
// And the identity that exactness buys: curveMap takes its bit-identical fast path.
|
||||
for (int i = 0; i <= 100; ++i) {
|
||||
const double phi = static_cast<double>(i) / 100.0;
|
||||
CHECK(curveMap(phi, curveFromKnobNorm(0.5)) == phi);
|
||||
}
|
||||
}
|
||||
|
||||
// A dial swept THROUGH the centre has to land on the identity. The raw logarithmic travel does
|
||||
// not — the drag grid steps by 1/128 and only touches 0.5 by luck — so this is the detent's own
|
||||
// property, asserted against that raw travel as the reference.
|
||||
static void testADialSweptThroughNeutralLandsOnTheIdentity() {
|
||||
const auto rawTravel = [](double t) {
|
||||
return std::exp((2.0 * t - 1.0) * std::log(kCurveMax));
|
||||
};
|
||||
// A real drag: grabbed at a shaped value, dragged 40 steps down through the centre.
|
||||
const double grab = 0.5 + 17.0 * kKnobStep + 0.003; // deliberately off the grid
|
||||
int detented = 0;
|
||||
int rawHits = 0;
|
||||
for (int step = 0; step <= 40; ++step) {
|
||||
const double t = grab - step * kKnobStep;
|
||||
if (curveFromKnobNorm(t) == kCurveNeutral) ++detented;
|
||||
if (rawTravel(t) == kCurveNeutral) ++rawHits;
|
||||
}
|
||||
CHECK(detented >= 1); // the sweep reaches the identity
|
||||
CHECK(rawHits == 0); // and would not have without the detent
|
||||
// The structural reason it cannot be skipped: the band is wider than one drag step.
|
||||
CHECK(kCurveKnobDetent > kKnobStep);
|
||||
}
|
||||
|
||||
// Outside the detent the pair are inverses, so the dial reads back what it wrote and the
|
||||
// endpoints saturate on the domain rather than past it.
|
||||
static void testKnobLawRoundTripsOutsideTheDetent() {
|
||||
const double exps[] = {kCurveMin, 0.2, 0.5, 0.8, 1.3, 2.0, 5.0, kCurveMax};
|
||||
for (double e : exps) {
|
||||
const double back = curveFromKnobNorm(knobNormFromCurve(e));
|
||||
CHECK(std::fabs(back - e) < 1e-9);
|
||||
}
|
||||
CHECK(curveFromKnobNorm(0.0) == kCurveMin);
|
||||
CHECK(curveFromKnobNorm(-3.0) == kCurveMin); // out-of-range norm saturates
|
||||
CHECK(std::fabs(curveFromKnobNorm(1.0) - kCurveMax) < 1e-12);
|
||||
// The ENDS need only land on the domain, not on an exact norm — 0.1 is not exactly 1/10 in
|
||||
// binary, so log(kCurveMin) is a hair off -log(kCurveMax). Only the centre carries an
|
||||
// exactness requirement, and only because the neutral is a bit-identity.
|
||||
CHECK(std::fabs(knobNormFromCurve(kCurveMin)) < 1e-12);
|
||||
CHECK(knobNormFromCurve(kCurveMax) == 1.0);
|
||||
// Monotone rising across the whole travel, so the dial has one unambiguous direction.
|
||||
double prev = 0.0;
|
||||
for (int i = 0; i <= 500; ++i) {
|
||||
const double v = curveFromKnobNorm(static_cast<double>(i) / 500.0);
|
||||
CHECK(v >= prev);
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testNeutralExponentIsTheIdentity();
|
||||
testEndpointsAreExactAtEveryExponent();
|
||||
@@ -114,6 +181,9 @@ int main() {
|
||||
testClampCurveHoldsTheDomain();
|
||||
testMidLevelRoundTripsAgainstTheExponent();
|
||||
testMidLevelInverseSaturates();
|
||||
testKnobLawIsExactAtTheNeutralCentre();
|
||||
testADialSweptThroughNeutralLandsOnTheIdentity();
|
||||
testKnobLawRoundTripsOutsideTheDetent();
|
||||
if (g_fail == 0) std::printf("curve_law: all tests passed\n");
|
||||
else std::printf("curve_law: %d FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
|
||||
// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit
|
||||
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
|
||||
// law's inverse pair, and the commit-tier routing — which controls are live, and which drags
|
||||
// take the live tier.
|
||||
// law's inverse pair, the commit-tier routing — which controls are live, and which drags take
|
||||
// the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
|
||||
// and which selections are inert).
|
||||
|
||||
#include "../src/core/instrument/ui/deck_groups.h"
|
||||
#include "../src/core/instrument/ui/sample_bands.h"
|
||||
@@ -341,7 +342,66 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
||||
CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast<int>(DeckParam::kFilterCutoff)));
|
||||
}
|
||||
|
||||
// --- The overlay selection state machine ---------------------------------------
|
||||
|
||||
static int radio(DeckParam p) { return static_cast<int>(p); }
|
||||
|
||||
// EXCLUSIVITY: picking another deck's radio switches to it outright — two envelopes can never
|
||||
// be overlay-active at once, whatever the previous selection was.
|
||||
static void testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks() {
|
||||
const OverlayEnv states[] = {OverlayEnv::kNone, OverlayEnv::kAmp, OverlayEnv::kPitch,
|
||||
OverlayEnv::kFilter};
|
||||
for (OverlayEnv from : states) {
|
||||
if (from != OverlayEnv::kAmp) {
|
||||
CHECK(nextOverlaySelection(from, radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kAmp);
|
||||
}
|
||||
if (from != OverlayEnv::kPitch) {
|
||||
CHECK(nextOverlaySelection(from, radio(DeckParam::kPitchEnvSelect)) ==
|
||||
OverlayEnv::kPitch);
|
||||
}
|
||||
if (from != OverlayEnv::kFilter) {
|
||||
CHECK(nextOverlaySelection(from, radio(DeckParam::kFilterEnvSelect)) ==
|
||||
OverlayEnv::kFilter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// kNone is a RESTING STATE the user can get back to: clicking the active radio clears it.
|
||||
static void testClickingTheActiveOverlayRadioClearsToNone() {
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kAmp, radio(DeckParam::kAmpEnvSelect)) ==
|
||||
OverlayEnv::kNone);
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kPitch, radio(DeckParam::kPitchEnvSelect)) ==
|
||||
OverlayEnv::kNone);
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterEnvSelect)) ==
|
||||
OverlayEnv::kNone);
|
||||
}
|
||||
|
||||
// A control that is not one of the three radios selects nothing and clears nothing.
|
||||
static void testANonRadioIdLeavesTheOverlaySelectionAlone() {
|
||||
CHECK(overlayEnvForRadio(radio(DeckParam::kFilterCutoff)) == OverlayEnv::kNone);
|
||||
CHECK(overlayEnvForRadio(-1) == OverlayEnv::kNone);
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kFilter, radio(DeckParam::kFilterCutoff)) ==
|
||||
OverlayEnv::kFilter);
|
||||
CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp);
|
||||
}
|
||||
|
||||
// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on
|
||||
// the same params: a node drag must not reach a value the knob refuses.
|
||||
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
|
||||
CHECK(overlayEnvInert(OverlayEnv::kPitch, /*pitchEnv=*/false, /*filter=*/true));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kPitch, true, true));
|
||||
CHECK(overlayEnvInert(OverlayEnv::kFilter, true, /*filter=*/false));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kFilter, true, true));
|
||||
// Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kAmp, false, false));
|
||||
CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testOverlaySelectionIsExclusiveAcrossTheThreeEnvelopeDecks();
|
||||
testClickingTheActiveOverlayRadioClearsToNone();
|
||||
testANonRadioIdLeavesTheOverlaySelectionAlone();
|
||||
testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
|
||||
testEveryDeckControlIsClassifiedLiveOrReloading();
|
||||
testOnlyALiveControlsDragTakesTheLiveTier();
|
||||
testDeckReadsPitchThenFilterThenAmpLeftToRight();
|
||||
|
||||
@@ -688,6 +688,28 @@ static void testResolvePlayRoundsAndFloorsNegatives() {
|
||||
CHECK(p.adsr.releaseFrames == 0);
|
||||
}
|
||||
|
||||
// The retired Trigger fade pair was SOURCE frames; the AHD that replaced it stores wall-clock
|
||||
// seconds, and the codec's lift can only divide by the PROJECT rate. This is the far end of
|
||||
// that seam: the build multiplies by the DECODE rate, so a migrated fade comes back scaled by
|
||||
// decodeRate/projectRate whenever a file's own rate differs from the project's. The bound is
|
||||
// documented at the lift in component_state_io.h; this is its measured size.
|
||||
static void testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate() {
|
||||
PlaySeconds st;
|
||||
st.trigAhd.attackSeconds = 441.0 / 44100.0; // a 441-SOURCE-frame fade lifted at 44.1k
|
||||
st.trigAhd.decaySeconds = 882.0 / 44100.0;
|
||||
|
||||
// Matched rates are EXACT: the round trip through seconds loses nothing.
|
||||
const PlayParams matched = resolvePlay(st, 44100);
|
||||
CHECK(matched.trigAhd.attackFrames == 441);
|
||||
CHECK(matched.trigAhd.decayFrames == 882);
|
||||
|
||||
// A 44.1 kHz file opened in a 48 kHz project: 441 * 48000/44100 = 480 source frames, ~8.8%
|
||||
// longer than the fade the saved instance actually had.
|
||||
const PlayParams stretched = resolvePlay(st, 48000);
|
||||
CHECK(stretched.trigAhd.attackFrames == 480);
|
||||
CHECK(stretched.trigAhd.decayFrames == 960);
|
||||
}
|
||||
|
||||
// --- resolveCapture: the ONE override-beats-intrinsic fold ---------------------
|
||||
|
||||
static SelectedSample ref(const std::string& rel, int root, bool hasLoop = false,
|
||||
@@ -914,6 +936,7 @@ int main() {
|
||||
testResolvePlayConvertsWallClockAtTheRate();
|
||||
testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope();
|
||||
testResolvePlayRoundsAndFloorsNegatives();
|
||||
testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate();
|
||||
testResolveCaptureUsesIntrinsicsWhenNoOverride();
|
||||
testResolveCaptureOverridesBeatIntrinsics();
|
||||
testResolveCaptureLoopOverrideCanDisableTheLoop();
|
||||
|
||||
+163
-47
@@ -8,8 +8,12 @@
|
||||
// (finite, monotone within a stage, never past the stage's endpoint levels); the AHD span split
|
||||
// (A+H+D can never exceed the span, for any triple, with no clamp on the sum; hold at 0% and
|
||||
// 100%); the Gate/Trigger shape switch on both the amp and the filter envelope, with each
|
||||
// mode's stage values surviving the other; and the Trigger tail's terminal behaviour under
|
||||
// Preserve in both voice modes, against a Varispeed render that must not change.
|
||||
// mode's stage values surviving the other; the terminal ring-out under Preserve — its shape,
|
||||
// what it must NOT hold up (the Preserve cap, mono legato), and the Varispeed render it must
|
||||
// leave alone; and the pitch envelope's span domain under a transposed Varispeed voice.
|
||||
//
|
||||
// The migration contour of the retired fade pair is asserted where the lift lives, in
|
||||
// component_state_io_tests.
|
||||
|
||||
#include "../src/core/instrument/engine/voice_engine.h"
|
||||
|
||||
@@ -438,55 +442,163 @@ static void testVarispeedTailIsUntouched() {
|
||||
for (std::size_t i = 2000; i < out.size(); ++i) CHECK(out[i] == 0.0f);
|
||||
}
|
||||
|
||||
// --- Migration contour --------------------------------------------------------
|
||||
// --- The ring-out must not hold the voice's slot -------------------------------
|
||||
|
||||
// The retired fade pair was an EQUAL-POWER ramp (sin/cos); the AHD that replaced it is the
|
||||
// curve law's neutral, which is LINEAR. Migration preserves the stage LENGTHS exactly, so the
|
||||
// contour tracks the old one to within the fixed sin(x)-vs-x gap — max |sin(t*pi/2) - t| over
|
||||
// [0,1], which is ~0.2105 at t ~= 0.4. Stated as the measured bound rather than judged: whether
|
||||
// that difference matters is Daniel's call, not this test's.
|
||||
static void testMigratedFadeContourMatchesTheRetiredShapeWithinTheStatedBound() {
|
||||
const std::int64_t span = 1000;
|
||||
const std::int64_t fadeIn = 200;
|
||||
const std::int64_t fadeOut = 300;
|
||||
// A 4000-frame sine one-shot at unity Preserve read rate: the read head leaves the span at
|
||||
// output frame 4000 and the ~185-frame ring-out runs from there. `kPastEnd` sits inside that
|
||||
// window, so a note-on at that point is the exact case both tests below need.
|
||||
static constexpr std::size_t kRingSpanFrames = 4000;
|
||||
static constexpr std::size_t kPastEnd = 4120;
|
||||
|
||||
AhdParams migrated;
|
||||
migrated.attackFrames = fadeIn; // Attack <- fade-in
|
||||
migrated.decayFrames = fadeOut; // Decay <- fade-out
|
||||
migrated.holdFraction = 1.0; // Hold <- the whole remainder
|
||||
AhdEnvelope ahd;
|
||||
ahd.configure(span, migrated);
|
||||
// Stage LENGTHS are exact: the fades land on the same frames they always did.
|
||||
CHECK(ahd.stages().attack == fadeIn);
|
||||
CHECK(ahd.stages().decay == fadeOut);
|
||||
CHECK(ahd.stages().total == span);
|
||||
|
||||
// The pre-change evaluator, written out so the comparison is against a stated reference
|
||||
// rather than against whatever the code now does.
|
||||
const auto retired = [&](double off) {
|
||||
if (off < 0.0 || off >= static_cast<double>(span)) return 0.0;
|
||||
if (off < static_cast<double>(fadeIn)) {
|
||||
return std::sin(off / static_cast<double>(fadeIn) * (kPi / 2.0));
|
||||
}
|
||||
const double foStart = static_cast<double>(span - fadeOut);
|
||||
if (off >= foStart) {
|
||||
return std::cos((off - foStart) / static_cast<double>(fadeOut) * (kPi / 2.0));
|
||||
}
|
||||
return 1.0;
|
||||
};
|
||||
|
||||
double worst = 0.0;
|
||||
for (std::int64_t i = 0; i < span; ++i) {
|
||||
worst = std::max(worst, std::fabs(ahd.amplitudeAt(static_cast<double>(i)) -
|
||||
retired(static_cast<double>(i))));
|
||||
static SampleData ringOutSample() {
|
||||
SampleData s;
|
||||
s.frames.resize(kRingSpanFrames);
|
||||
for (std::size_t i = 0; i < s.frames.size(); ++i) {
|
||||
s.frames[i] = static_cast<float>(0.8 * std::sin(2.0 * kPi * static_cast<double>(i) / 40.0));
|
||||
}
|
||||
CHECK(worst <= 0.2106); // the sin-vs-linear bound, and nothing beyond it
|
||||
// Both agree exactly where it matters structurally: the onset, the plateau, and the end.
|
||||
CHECK(ahd.amplitudeAt(0.0) == retired(0.0));
|
||||
CHECK(ahd.amplitudeAt(600.0) == retired(600.0));
|
||||
CHECK(ahd.amplitudeAt(static_cast<double>(span)) == retired(static_cast<double>(span)));
|
||||
s.sampleRate = 48000;
|
||||
s.rootNote = 60;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.attackFrames = 0;
|
||||
s.play.trigAhd.decayFrames = 0;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
return s;
|
||||
}
|
||||
|
||||
static double peakOf(const std::vector<AudioSample>& out, std::size_t from) {
|
||||
double peak = 0.0;
|
||||
for (std::size_t i = from; i < out.size(); ++i) {
|
||||
peak = std::max(peak, std::fabs(static_cast<double>(out[i])));
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
// The Preserve CAP counts sounding notes, not ring-outs. With the cap at one, a second onset
|
||||
// fired while the first voice is past its end but still ramping must still be admitted —
|
||||
// otherwise every Preserve one-shot silently swallows the next hit for ~4 ms.
|
||||
static void testPreserveCapAdmitsANewOnsetDuringTheRingOut() {
|
||||
const SampleData s = ringOutSample();
|
||||
const auto run = [&](bool fireSecondNote) {
|
||||
VoiceEngine eng(4, s, /*preserveCap=*/1, /*window=*/512);
|
||||
eng.noteOn(67, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, kPastEnd);
|
||||
if (fireSecondNote) eng.noteOn(72, 127);
|
||||
eng.render(out, 400);
|
||||
return out;
|
||||
};
|
||||
// Precondition: the first voice really is still ringing at kPastEnd, so the second onset
|
||||
// meets the cap while a past-end voice is alive. Without it the test proves nothing.
|
||||
{
|
||||
VoiceEngine probe(4, s, /*preserveCap=*/1, /*window=*/512);
|
||||
probe.noteOn(67, 127);
|
||||
std::vector<AudioSample> discard;
|
||||
probe.render(discard, kPastEnd);
|
||||
CHECK(probe.activeVoiceCount() == 1);
|
||||
}
|
||||
// The discriminator is rendered signal, not a voice count: a refused onset leaves the two
|
||||
// renders identical from kPastEnd on (the decaying ramp and nothing else).
|
||||
const std::vector<AudioSample> withSecond = run(true);
|
||||
const std::vector<AudioSample> ringOnly = run(false);
|
||||
CHECK(peakOf(ringOnly, kPastEnd) < 0.02); // the ramp alone is already well down
|
||||
CHECK(peakOf(withSecond, kPastEnd) > 0.5); // the admitted note is at full amplitude
|
||||
}
|
||||
|
||||
// Mono LEGATO takes a voice over by retuning it — which moves the pitch of a dying ramp and
|
||||
// produces nothing if the voice is already past its own end. A note-on during the ring-out
|
||||
// must restart instead.
|
||||
static void testMonoLegatoRestartsRatherThanRetunesDuringTheRingOut() {
|
||||
const SampleData s = ringOutSample();
|
||||
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512, VoiceMode::Mono,
|
||||
MonoTrigger::Legato);
|
||||
eng.noteOn(67, 127); // held: never released, so the second press is a legato takeover
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, kPastEnd);
|
||||
CHECK(eng.activeVoiceCount() == 1); // precondition: still ringing out
|
||||
eng.noteOn(72, 127);
|
||||
eng.render(out, 400);
|
||||
// A retune of the past-end voice re-enters the run-off branch every frame and emits only
|
||||
// the decaying ramp; a restart plays the source from the top.
|
||||
CHECK(peakOf(out, kPastEnd) > 0.5);
|
||||
}
|
||||
|
||||
// The read-head exhaustion path is NOT scoped to Trigger: a held Gate note whose source runs
|
||||
// out with no sustain loop is cut at its sustain level, and under Preserve that cut lands on
|
||||
// the same recycled synthetic tail the Trigger one-shot's does.
|
||||
static void testGatePreserveRunOffRingsOutToo() {
|
||||
SampleData s = ringOutSample();
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.adsr.attackFrames = 0;
|
||||
s.play.adsr.decayFrames = 0;
|
||||
s.play.adsr.sustainLevel = 1.0; // held at full level when the source runs out
|
||||
s.loop.hasLoop = false;
|
||||
|
||||
VoiceEngine eng(1, s, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(67, 127); // held; never released
|
||||
std::vector<AudioSample> out;
|
||||
std::size_t sounding = 0;
|
||||
for (std::size_t f = 0; f < 8000; ++f) {
|
||||
eng.render(out, 1);
|
||||
if (eng.activeVoiceCount() == 0) break;
|
||||
sounding = f + 1;
|
||||
}
|
||||
CHECK(sounding > kRingSpanFrames); // it rings past the source rather than stopping dead
|
||||
// Same measurable bar as the Trigger tail: no step larger than the source waveform's own
|
||||
// steepest slope. (The audible judgement is Daniel's; this is the proxy.)
|
||||
double worst = 0.0;
|
||||
const std::size_t end = std::min(sounding + 1, out.size());
|
||||
for (std::size_t i = 1; i < end; ++i) {
|
||||
worst = std::max(worst, std::fabs(static_cast<double>(out[i]) -
|
||||
static_cast<double>(out[i - 1])));
|
||||
}
|
||||
CHECK(worst <= (0.8 * 2.0 * kPi / 40.0) * 1.5);
|
||||
}
|
||||
|
||||
// --- The pitch envelope's span domain ------------------------------------------
|
||||
|
||||
// The pitch envelope counts OUTPUT frames while the playable span is a SOURCE-frame count, so
|
||||
// a transposed Varispeed voice must have its span converted. Measured through the read rate the
|
||||
// envelope itself drives: at +12 semitones (ratio 2) with a full-span hold of -12 semitones the
|
||||
// voice reads at unity while the envelope holds and at double speed after it, so the note's
|
||||
// OUTPUT length is a direct readout of the envelope's span.
|
||||
// +12 st, span/2 = 2000 output frames of hold: 2000 + 2000/2 = 3000 output frames
|
||||
// +24 st, span/4 = 1000 output frames of hold: 1000 + 3000/4 = 1750 output frames
|
||||
// The un-converted source-frame span holds for 4000 output frames in BOTH cases, so the note
|
||||
// runs exactly 4000 at either transposition — the length stops tracking the ratio at all.
|
||||
static void testPitchEnvelopeSpanIsOutputFramesUnderVarispeed() {
|
||||
const auto soundingFrames = [](int note, double peakSemis) {
|
||||
SampleData s = dcSample(4000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.attackFrames = 0;
|
||||
s.play.trigAhd.decayFrames = 0;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
s.play.pitchEnv.enabled = true;
|
||||
s.play.pitchEnv.peakSemitones = peakSemis; // cancels the transposition while it holds
|
||||
s.play.pitchEnv.shape.attackFrames = 0;
|
||||
s.play.pitchEnv.shape.decayFrames = 0;
|
||||
s.play.pitchEnv.shape.holdFraction = 1.0;
|
||||
|
||||
VoiceEngine eng(1, s);
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> out;
|
||||
std::size_t sounding = 0;
|
||||
for (std::size_t f = 0; f < 8000; ++f) {
|
||||
eng.render(out, 1);
|
||||
if (eng.activeVoiceCount() == 0) break;
|
||||
sounding = f + 1;
|
||||
}
|
||||
return sounding;
|
||||
};
|
||||
CHECK(soundingFrames(72, -12.0) == 3000);
|
||||
CHECK(soundingFrames(84, -24.0) == 1750);
|
||||
}
|
||||
|
||||
// --- Migration shape ----------------------------------------------------------
|
||||
|
||||
// A prior ZERO fade-out migrates to Decay = 0 and keeps the abrupt end the old controls could
|
||||
// express — nothing the retired mechanism could say is lost.
|
||||
static void testZeroFadeOutMigratesToAnAbruptEnd() {
|
||||
@@ -516,8 +628,12 @@ int main() {
|
||||
testTriggerPreserveEndsWithoutATerminalDiscontinuity();
|
||||
testTriggerPreserveAhdEndingEarlyAlsoRingsOut();
|
||||
testVarispeedTailIsUntouched();
|
||||
testPreserveCapAdmitsANewOnsetDuringTheRingOut();
|
||||
testMonoLegatoRestartsRatherThanRetunesDuringTheRingOut();
|
||||
testGatePreserveRunOffRingsOutToo();
|
||||
|
||||
testPitchEnvelopeSpanIsOutputFramesUnderVarispeed();
|
||||
|
||||
testMigratedFadeContourMatchesTheRetiredShapeWithinTheStatedBound();
|
||||
testZeroFadeOutMigratesToAnAbruptEnd();
|
||||
|
||||
if (g_fail == 0) {
|
||||
|
||||
Reference in New Issue
Block a user