Service both VST3 parameter channels, and promote pitch key-track and Trigger length so all 44 ids issue
The SDK's own single-component sample drains inputParameterChanges in process() and implements setParamNormalized; automation was reading the GUI channel alone. The audio thread now patches a block it solely owns.
This commit is contained in:
@@ -210,7 +210,7 @@ int main() {
|
||||
{
|
||||
PlayParams slow = s.play;
|
||||
slow.trigAhd.attackFrames = 900;
|
||||
block.publish(instrument::engine::foldLive(slow));
|
||||
block.publish(instrument::engine::foldLive(slow, s.keyTrack));
|
||||
}
|
||||
|
||||
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
|
||||
|
||||
@@ -49,9 +49,10 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
||||
|
||||
// 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};
|
||||
// that none of these falls back into either neighbour, and the VST3 parameter surface reads
|
||||
// this classification to decide what it exposes to the host.
|
||||
const DeckParam latched[] = {DeckParam::kRate, DeckParam::kKeyTrack,
|
||||
DeckParam::kTrigLength};
|
||||
for (DeckParam p : latched) CHECK(deckParamCommit(p) == LiveCommit::NoteOnLatched);
|
||||
|
||||
// Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
|
||||
@@ -60,7 +61,6 @@ static void testEveryDeckControlIsClassifiedIntoOneOfTheThreeCommitTiers() {
|
||||
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
|
||||
DeckParam::kFilterEnable, DeckParam::kFilterLaw,
|
||||
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
|
||||
DeckParam::kKeyTrack, DeckParam::kTrigLength,
|
||||
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
|
||||
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
|
||||
DeckParam::kVoiceCount, DeckParam::kVoiceMode,
|
||||
@@ -99,7 +99,10 @@ static void testOnlyALiveControlsDragTakesTheLiveTier() {
|
||||
// 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);
|
||||
// Trigger length resolves playEnd_ and key-track the pitch ratio — both facts a voice fixes
|
||||
// at note-on, which is the latched tier's own definition rather than the reload tier's.
|
||||
CHECK(knob(DeckParam::kTrigLength) == LiveCommit::NoteOnLatched);
|
||||
CHECK(knob(DeckParam::kKeyTrack) == LiveCommit::NoteOnLatched);
|
||||
// Master gain is Live and reaches the audio BESIDE the live block rather than through it —
|
||||
// one atomic the audio thread applies as a post-sum multiply. Classifying it Reload would
|
||||
// claim a gain move re-decodes the WAV, which it never did.
|
||||
|
||||
@@ -96,7 +96,7 @@ static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames
|
||||
int changeAfter, const LiveValues* changed, int noteOffBlock = -1,
|
||||
int velocity = 100) {
|
||||
sample.live = block;
|
||||
if (block) block->publish(foldLive(sample.play));
|
||||
if (block) block->publish(foldLive(sample.play, sample.keyTrack));
|
||||
VoiceEngine engine(1, sample);
|
||||
engine.noteOn(kTestNote, velocity);
|
||||
Run r;
|
||||
@@ -320,7 +320,7 @@ static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() {
|
||||
SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
LiveValues dialled = foldLive(s.play);
|
||||
LiveValues dialled = foldLive(s.play, s.keyTrack);
|
||||
dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note
|
||||
block.publish(dialled);
|
||||
VoiceEngine engine(1, s);
|
||||
@@ -345,7 +345,7 @@ static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() {
|
||||
slow.play.adsr.attackFrames = 24000;
|
||||
LiveParams block2;
|
||||
slow.live = &block2;
|
||||
LiveValues snappy = foldLive(slow.play);
|
||||
LiveValues snappy = foldLive(slow.play, slow.keyTrack);
|
||||
snappy.adsr.attackFrames = 0;
|
||||
block2.publish(snappy);
|
||||
VoiceEngine fast(1, slow);
|
||||
@@ -368,7 +368,7 @@ static void assertLiveFieldMovesTheSoundingNote(const char* name, void (*rig)(Sa
|
||||
rig(still);
|
||||
rig(moved);
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues target = foldLive(moved.play);
|
||||
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||
mutate(target);
|
||||
|
||||
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock);
|
||||
@@ -616,7 +616,7 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
|
||||
SampleData still = filteredSine();
|
||||
SampleData moved = filteredSine();
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues target = foldLive(moved.play);
|
||||
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||
c.mutate(target);
|
||||
|
||||
const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr);
|
||||
@@ -688,7 +688,7 @@ static void testOneBlockServesTwoIndependentObservers() {
|
||||
LiveParams block;
|
||||
liveSnapshot.live = █
|
||||
drainSnapshot.live = █
|
||||
block.publish(foldLive(liveSnapshot.play));
|
||||
block.publish(foldLive(liveSnapshot.play, liveSnapshot.keyTrack));
|
||||
|
||||
VoiceEngine liveEngine(1, liveSnapshot);
|
||||
VoiceEngine drainEngine(1, drainSnapshot);
|
||||
@@ -696,7 +696,7 @@ static void testOneBlockServesTwoIndependentObservers() {
|
||||
drainEngine.noteOn(60, 100);
|
||||
|
||||
std::vector<AudioSample> a, b;
|
||||
LiveValues moved = foldLive(liveSnapshot.play);
|
||||
LiveValues moved = foldLive(liveSnapshot.play, liveSnapshot.keyTrack);
|
||||
moved.filterSettings.cutoffNorm = 0.2f;
|
||||
for (int blk = 0; blk < 24; ++blk) {
|
||||
if (blk == 8) block.publish(moved);
|
||||
@@ -746,7 +746,7 @@ static std::vector<AudioSample> renderPreserveCapable(SampleData& s, LiveParams&
|
||||
const LiveValues* changed, int changeAfter,
|
||||
int note) {
|
||||
s.live = █
|
||||
block.publish(foldLive(s.play));
|
||||
block.publish(foldLive(s.play, s.keyTrack));
|
||||
VoiceEngine engine(1, s, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
||||
engine.noteOn(note, 100);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -765,7 +765,7 @@ static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() {
|
||||
moved.play.pitchEngine = eng;
|
||||
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues halfRate = foldLive(moved.play);
|
||||
LiveValues halfRate = foldLive(moved.play, moved.keyTrack);
|
||||
halfRate.playRate = 0.5;
|
||||
|
||||
// At the ROOT note, so Preserve's shifter runs at shift 1.0 and never splices — the
|
||||
@@ -795,7 +795,7 @@ static void testARateChangeSpareTheSoundingNoteAndReachesTheNextOne() {
|
||||
fresh.play.pitchEngine = eng;
|
||||
LiveParams block;
|
||||
fresh.live = █
|
||||
LiveValues published = foldLive(fresh.play);
|
||||
LiveValues published = foldLive(fresh.play, fresh.keyTrack);
|
||||
published.playRate = rate;
|
||||
block.publish(published);
|
||||
VoiceEngine engine(1, fresh, /*preserveVoiceCap=*/0, /*preserveWindowFrames=*/2048);
|
||||
@@ -827,7 +827,7 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
|
||||
moved.play.pitchEngine = eng;
|
||||
|
||||
LiveParams blockA, blockB;
|
||||
LiveValues target = foldLive(moved.play);
|
||||
LiveValues target = foldLive(moved.play, moved.keyTrack);
|
||||
target.pitchOffsetSemitones = -12.0;
|
||||
|
||||
const std::vector<AudioSample> baseline =
|
||||
@@ -859,7 +859,7 @@ static void testAPitchOffsetChangeMovesTheSoundingNoteInBothEngines() {
|
||||
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
|
||||
LiveValues v = foldLive(s.play, s.keyTrack); // s.play keeps its own (zero) offset: the stale copy
|
||||
v.pitchOffsetSemitones = offsetSemis;
|
||||
block.publish(v);
|
||||
s.live = █
|
||||
@@ -891,7 +891,7 @@ static void testAPublishedPitchOffsetLeavesTheStagedAttackWallClock() {
|
||||
s.play.trigAhd = AhdParams{kAttack, 0, 1.0, util::kCurveNeutral, util::kCurveNeutral};
|
||||
|
||||
LiveParams block;
|
||||
LiveValues v = foldLive(s.play);
|
||||
LiveValues v = foldLive(s.play, s.keyTrack);
|
||||
v.pitchOffsetSemitones = semis;
|
||||
block.publish(v);
|
||||
s.live = █
|
||||
@@ -960,12 +960,12 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
block.publish(foldLive(s.play));
|
||||
block.publish(foldLive(s.play, s.keyTrack));
|
||||
VoiceEngine engine(1, s);
|
||||
engine.noteOn(72, 64); // an octave up: ratio 2.0
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
LiveValues hostile = foldLive(s.play);
|
||||
LiveValues hostile = foldLive(s.play, s.keyTrack);
|
||||
// Everything the block CAN carry, moved as far as it goes. None of it names velocity, the
|
||||
// note, the pitch ratio, or the PCM — that is the property under test.
|
||||
hostile.filterKeyTrack = 2.0;
|
||||
@@ -998,11 +998,11 @@ static void testPitchRatioAndVelocityGainStayLatched() {
|
||||
SampleData s2 = s;
|
||||
LiveParams block2;
|
||||
s2.live = &block2;
|
||||
block2.publish(foldLive(s2.play));
|
||||
block2.publish(foldLive(s2.play, s2.keyTrack));
|
||||
VoiceEngine engine2(1, s2);
|
||||
engine2.noteOn(72, 64);
|
||||
std::vector<AudioSample> out2;
|
||||
LiveValues quieter = foldLive(s2.play);
|
||||
LiveValues quieter = foldLive(s2.play, s2.keyTrack);
|
||||
quieter.adsr.sustainLevel = 0.25;
|
||||
for (int blk = 0; blk < 8; ++blk) {
|
||||
if (blk == 2) block2.publish(quieter);
|
||||
@@ -1024,7 +1024,7 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
|
||||
rig.play.pitchEnv.shape.decayFrames = 24000;
|
||||
rig.play.pitchEnv.peakSemitones = 3.0;
|
||||
|
||||
LiveValues hostile = foldLive(rig.play);
|
||||
LiveValues hostile = foldLive(rig.play, rig.keyTrack);
|
||||
hostile.filterKeyTrack = 2.0;
|
||||
hostile.filterSettings.cutoffNorm = 0.9f;
|
||||
hostile.filterModAmount = -1.0;
|
||||
|
||||
@@ -41,7 +41,9 @@ static void testFoldCarriesEveryContinuousControl() {
|
||||
p.pitchEnv.shape = AhdParams{7, 9, 0.4, 1.5, 0.75};
|
||||
p.pitchEnv.peakSemitones = -3.5;
|
||||
|
||||
const LiveValues v = foldLive(p);
|
||||
// Distinct from filter.keyTrack below on purpose: the two are different controls and a fold
|
||||
// that crossed them would pass under a shared value.
|
||||
const LiveValues v = foldLive(p, /*keyTrack=*/0.8);
|
||||
CHECK(v.adsr.attackFrames == 11);
|
||||
CHECK(v.adsr.holdFrames == 22);
|
||||
CHECK(v.adsr.decayFrames == 33);
|
||||
@@ -72,6 +74,21 @@ static void testFoldCarriesEveryContinuousControl() {
|
||||
CHECK(v.pitchEnv.shape.attackCurve == 1.5);
|
||||
CHECK(v.pitchEnv.shape.decayCurve == 0.75);
|
||||
CHECK(v.pitchEnv.peakSemitones == -3.5);
|
||||
CHECK(v.keyTrack == 0.8);
|
||||
// Spline-folded on the way in, so the block carries what the voice will actually play.
|
||||
CHECK(!v.splineActive);
|
||||
CHECK(v.lengthFraction == p.trigger.lengthFraction);
|
||||
}
|
||||
|
||||
// The fold, not the voice, is where a drawn contour pins the Trigger span — so the block a
|
||||
// note-on latches already carries the folded value.
|
||||
static void testADrawnEnvelopePinsTheFoldedTriggerLength() {
|
||||
PlayParams p;
|
||||
p.trigger.lengthFraction = 0.25;
|
||||
p.ampSpline.mode = EnvMode::Spline;
|
||||
const LiveValues v = foldLive(p, kKeyTrackDefault);
|
||||
CHECK(v.splineActive);
|
||||
CHECK(v.lengthFraction == 1.0);
|
||||
}
|
||||
|
||||
static void testUnpublishedBlockReadsAsNothing() {
|
||||
@@ -170,6 +187,7 @@ static void testRampStepIsRateDerived() {
|
||||
|
||||
int main() {
|
||||
testFoldCarriesEveryContinuousControl();
|
||||
testADrawnEnvelopePinsTheFoldedTriggerLength();
|
||||
testUnpublishedBlockReadsAsNothing();
|
||||
testConcurrentReaderNeverSeesAHalfAppliedEdit();
|
||||
testRampTerminatesExactlyOnTheTarget();
|
||||
|
||||
+85
-21
@@ -1,17 +1,21 @@
|
||||
// Standalone tests for the ONE formatter per unit category: the digit shapes each category
|
||||
// prints, and the property that makes the editor's knob label and the host's parameter string
|
||||
// identical — both call THIS function, over a plain value derived the way each surface derives
|
||||
// it. No VST3, no REAPER, no framework.
|
||||
// identical — both call THIS function, each over the plain value ITS OWN surface derives (the
|
||||
// editor's exponent read goes to the stored field, not through the knob law). No VST3, no
|
||||
// REAPER, no framework.
|
||||
|
||||
#include "../src/core/instrument/param/param_format.h"
|
||||
|
||||
#include "../src/core/instrument/engine/master_gain.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/param/param_units.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
@@ -61,34 +65,40 @@ static void testEachCategoryPrintsItsSpecifiedShape() {
|
||||
CHECK(digits(UnitKind::Dimensionless, 0.1) == "0.10");
|
||||
}
|
||||
|
||||
// The EDITOR's derivation, spelled the way ReaSamplerEditor::deckPlainValue spells it — a curve
|
||||
// exponent is read off its stored field, never round-tripped through the knob law. Driving the
|
||||
// sweep through this rather than through toPlain(deckParamNorm(...)) is what makes the exponent
|
||||
// half of it a real assertion instead of a round trip on both sides.
|
||||
static double editorPlainValue(DeckParam deck, PlaySeconds& play) {
|
||||
using reasampler::instrument::ui::UnitCategory;
|
||||
if (reasampler::instrument::ui::deckParamUnit(deck) == UnitCategory::Exponent) {
|
||||
const double* stored = reasampler::instrument::ui::deckDoubleField(deck, play);
|
||||
return stored ? *stored : 0.0;
|
||||
}
|
||||
return toPlain(deck, reasampler::instrument::ui::deckParamNorm(deck, play));
|
||||
}
|
||||
|
||||
static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
||||
// The host derives its plain value from the normalized one it holds; the editor derives its
|
||||
// from the STORED field, through the deck's own read. If those two derivations disagreed at
|
||||
// any reachable value the two surfaces would print different numbers for one control — this
|
||||
// is that property, swept over the whole travel of every exposed control.
|
||||
// from the STORED field. If those two derivations disagreed at any reachable value the two
|
||||
// surfaces would print different numbers for one control — this is that property, swept over
|
||||
// the whole travel of every exposed control the parameter set carries.
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
if (row.deck == DeckParam::kMasterGain) continue; // not stored in PlaySeconds
|
||||
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) continue; // own tests below
|
||||
for (int step = 0; step <= 40; ++step) {
|
||||
const double norm = step / 40.0;
|
||||
PlaySeconds play;
|
||||
reasampler::instrument::ui::setDeckParam(row.deck, play, norm, /*segment=*/0);
|
||||
const double storedNorm = reasampler::instrument::ui::deckParamNorm(row.deck, play);
|
||||
|
||||
char hostBuf[24];
|
||||
formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf));
|
||||
|
||||
const double editorNorm =
|
||||
reasampler::instrument::ui::deckParamNorm(row.deck, play);
|
||||
char editorBuf[24];
|
||||
formatPlainFor(row.deck, toPlain(row.deck, editorNorm), editorBuf, sizeof(editorBuf));
|
||||
|
||||
if (storesNormalized(row.deck)) {
|
||||
// The filter's four store their position as a FLOAT, so a norm the host has sent
|
||||
// but we have not yet stored differs from the stored one by up to a float ulp.
|
||||
// At a value landing exactly on a display rounding boundary that is worth one
|
||||
// digit, so these four are held to the PLAIN value rather than to the string —
|
||||
// the derivation is still asserted to be one derivation.
|
||||
if (row.deck == DeckParam::kFilterMorph) {
|
||||
// MORPH ALONE: its toPlain is full-double (`clamp01(n) * 100`), so the float the
|
||||
// model stores and the double the host holds are genuinely different inputs, and
|
||||
// at a value landing on a display rounding boundary that is worth one integer
|
||||
// percent. Cutoff/Q/drive cast to float INSIDE toPlain, so they are bit-identical
|
||||
// either way and are held to the string below like everything else.
|
||||
const double hostPlain = toPlain(row.deck, norm);
|
||||
const double editorPlain = toPlain(row.deck, editorNorm);
|
||||
const double editorPlain = toPlain(row.deck, storedNorm);
|
||||
const double tolerance = std::fabs(hostPlain) * 1e-6 + 1e-9;
|
||||
if (std::fabs(hostPlain - editorPlain) > tolerance) {
|
||||
std::printf("FAIL param %u at norm %.4f: host %.9g vs editor %.9g\n",
|
||||
@@ -97,6 +107,16 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A write the model CLAMPED (Trigger length's (0,1] floor) is not a settled state:
|
||||
// setParamNormalized caches what the model TOOK, so the host never holds the rejected
|
||||
// value. Only the four float-stored positions differ by a ulp rather than a clamp.
|
||||
if (!storesNormalized(row.deck) && storedNorm != norm) continue;
|
||||
|
||||
char hostBuf[24];
|
||||
char editorBuf[24];
|
||||
formatPlainFor(row.deck, toPlain(row.deck, norm), hostBuf, sizeof(hostBuf));
|
||||
formatPlainFor(row.deck, editorPlainValue(row.deck, play), editorBuf,
|
||||
sizeof(editorBuf));
|
||||
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||
std::printf("FAIL param %u at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||
row.id, norm, hostBuf, editorBuf);
|
||||
@@ -106,6 +126,48 @@ static void testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue() {
|
||||
}
|
||||
}
|
||||
|
||||
// The one place the two surfaces GENUINELY diverge, asserted so it stays a known property rather
|
||||
// than a surprise: an exponent inside curve_law's centre detent but not exactly neutral is
|
||||
// reachable only through an overlay knot drag, and the host — which holds the norm and nothing
|
||||
// else — reads it back as the neutral the knob law snaps to.
|
||||
static void testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor() {
|
||||
PlaySeconds play;
|
||||
// The detent is +/-0.01 in NORM, which is a ~+/-0.047 band in the exponent — so 1.04 is
|
||||
// inside it and still prints as a distinct number.
|
||||
play.adsr.attackCurve = 1.04;
|
||||
char editorBuf[24];
|
||||
formatPlainFor(DeckParam::kAttackCurve, editorPlainValue(DeckParam::kAttackCurve, play),
|
||||
editorBuf, sizeof(editorBuf));
|
||||
CHECK(std::string(editorBuf) == "1.04");
|
||||
const double hostNorm =
|
||||
reasampler::instrument::ui::deckParamNorm(DeckParam::kAttackCurve, play);
|
||||
char hostBuf[24];
|
||||
formatPlainFor(DeckParam::kAttackCurve, toPlain(DeckParam::kAttackCurve, hostNorm), hostBuf,
|
||||
sizeof(hostBuf));
|
||||
CHECK(std::string(hostBuf) == "1.00");
|
||||
}
|
||||
|
||||
static void testKeyTrackPrintsTheSameDigitsFromEitherSurface() {
|
||||
using reasampler::instrument::map::InstrumentParams;
|
||||
for (int step = 0; step <= 40; ++step) {
|
||||
const double norm = step / 40.0;
|
||||
InstrumentParams params;
|
||||
params.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||
const double editorNorm = reasampler::instrument::ui::keyTrackNormFrom(params.keyTrack);
|
||||
char hostBuf[24];
|
||||
char editorBuf[24];
|
||||
formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, norm), hostBuf,
|
||||
sizeof(hostBuf));
|
||||
formatPlainFor(DeckParam::kKeyTrack, toPlain(DeckParam::kKeyTrack, editorNorm), editorBuf,
|
||||
sizeof(editorBuf));
|
||||
if (std::strcmp(hostBuf, editorBuf) != 0) {
|
||||
std::printf("FAIL key-track at norm %.4f: host \"%s\" vs editor \"%s\"\n",
|
||||
norm, hostBuf, editorBuf);
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testMasterGainPrintsTheSameDigitsFromEitherSurface() {
|
||||
using reasampler::instrument::engine::masterGainLinearFromNorm;
|
||||
using reasampler::instrument::engine::masterGainNormFromLinear;
|
||||
@@ -165,7 +227,9 @@ static void testEveryExposedParameterHasAFormatterThatWritesSomething() {
|
||||
int main() {
|
||||
testEachCategoryPrintsItsSpecifiedShape();
|
||||
testTheEditorAndTheHostPrintTheSameDigitsAtTheSameStoredValue();
|
||||
testAnOffDetentExponentReadsNeutralToTheHostAndTrueToTheEditor();
|
||||
testMasterGainPrintsTheSameDigitsFromEitherSurface();
|
||||
testKeyTrackPrintsTheSameDigitsFromEitherSurface();
|
||||
testTypingBackADisplayedValueLandsOnIt();
|
||||
testAShortBufferIsNeverOverrunAndAlwaysTerminates();
|
||||
testEveryExposedParameterHasAFormatterThatWritesSomething();
|
||||
|
||||
+12
-11
@@ -150,22 +150,23 @@ static void testTheExposedSetIsExactlyThePredicateAnswer() {
|
||||
}
|
||||
}
|
||||
|
||||
static void testAReservedRowIsNumberedButNotIssued() {
|
||||
// Key-track (pitch) and Trigger length are note-on-latch candidates that still route through
|
||||
// the reload tier, so they are NOT issued to the host today. Their numbers stay reserved
|
||||
// rather than retired: nothing shipped under them, so a later promotion issues the same id
|
||||
// and no other id moves.
|
||||
static void testEveryNumberedRowIsIssued() {
|
||||
// Key-track (pitch) and Trigger length were reserved-but-unissued while they routed through
|
||||
// the reload tier; both are note-on-latched now, so both are issued under the numbers that
|
||||
// were held for them and no other id moved — which is what the block-and-step scheme bought.
|
||||
CHECK(paramIdFor(DeckParam::kKeyTrack) == 1000);
|
||||
CHECK(paramIdFor(DeckParam::kTrigLength) == 1450);
|
||||
CHECK(!isExposed(DeckParam::kKeyTrack));
|
||||
CHECK(!isExposed(DeckParam::kTrigLength));
|
||||
CHECK(exposedRowFor(1000) == nullptr);
|
||||
CHECK(exposedRowFor(1450) == nullptr);
|
||||
CHECK(isExposed(DeckParam::kKeyTrack));
|
||||
CHECK(isExposed(DeckParam::kTrigLength));
|
||||
CHECK(exposedRowFor(1000) != nullptr);
|
||||
CHECK(exposedRowFor(1450) != nullptr);
|
||||
// Master gain IS issued: one atomic store the audio thread picks up next block is the live
|
||||
// tier by that tier's own definition.
|
||||
CHECK(isExposed(DeckParam::kMasterGain));
|
||||
CHECK(exposedRowFor(1700) != nullptr);
|
||||
CHECK(exposedParams().size() == paramTable().size() - 2);
|
||||
// The whole table is issued today — 44 of 44.
|
||||
CHECK(exposedParams().size() == paramTable().size());
|
||||
CHECK(exposedParams().size() == 44);
|
||||
}
|
||||
|
||||
static void testEveryRowCarriesADistinctTitleAndShortTitle() {
|
||||
@@ -186,7 +187,7 @@ int main() {
|
||||
testAnInnerDialSitsBesideTheKnobItShapes();
|
||||
testABlockCarriesOnlyItsOwnGroup();
|
||||
testTheExposedSetIsExactlyThePredicateAnswer();
|
||||
testAReservedRowIsNumberedButNotIssued();
|
||||
testEveryNumberedRowIsIssued();
|
||||
testEveryRowCarriesADistinctTitleAndShortTitle();
|
||||
if (g_fail == 0) std::printf("param_id: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Standalone tests for the audio thread's parameter patch. The load-bearing one is the
|
||||
// EQUIVALENCE assertion: patching a control into the live block must produce, bit for bit, the
|
||||
// block the model path would have folded — which is what makes a second routing table safe.
|
||||
|
||||
#include "../src/core/instrument/param/param_live.h"
|
||||
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/param/param_units.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::param;
|
||||
using reasampler::instrument::engine::LiveValues;
|
||||
using reasampler::instrument::engine::foldLive;
|
||||
using reasampler::instrument::map::InstrumentParams;
|
||||
using reasampler::instrument::map::PlaySeconds;
|
||||
using reasampler::instrument::map::resolvePlay;
|
||||
using reasampler::instrument::ui::DeckParam;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK_ID(cond, id) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d (param %u): %s\n", __LINE__, (id), #cond); ++g_fail; } } while(0)
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kRate = 48000;
|
||||
|
||||
// The MODEL path, verbatim: the write the editor makes, resolved and folded the way every
|
||||
// publisher folds it. This is the reference the patch is measured against.
|
||||
LiveValues modelBlock(const InstrumentParams& params) {
|
||||
return foldLive(resolvePlay(params.play, kRate), params.keyTrack);
|
||||
}
|
||||
|
||||
// A parameter set deliberately away from its defaults, so an equivalence that only holds at the
|
||||
// default cannot pass. Not every field — just enough that a mis-routed patch lands on a value
|
||||
// that differs from the one it should have written.
|
||||
InstrumentParams dialledParams() {
|
||||
InstrumentParams p;
|
||||
p.keyTrack = 1.5;
|
||||
p.play.adsr.attackSeconds = 0.31;
|
||||
p.play.adsr.holdSeconds = 0.07;
|
||||
p.play.adsr.decaySeconds = 0.44;
|
||||
p.play.adsr.sustainLevel = 0.62;
|
||||
p.play.adsr.releaseSeconds = 0.9;
|
||||
p.play.adsr.attackCurve = 2.5;
|
||||
p.play.trigger.lengthFraction = 0.4;
|
||||
p.play.trigAhd.attackSeconds = 0.12;
|
||||
p.play.trigAhd.holdFraction = 0.3;
|
||||
p.play.playRate = 1.2;
|
||||
p.play.pitchOffsetSemitones = -5.0;
|
||||
p.play.pitchEnv.enabled = true;
|
||||
p.play.pitchEnv.peakSemitones = 7.0;
|
||||
p.play.pitchEnv.shape.attackSeconds = 0.02;
|
||||
p.play.filter.enabled = true;
|
||||
p.play.filter.settings.cutoffNorm = 0.42f;
|
||||
p.play.filter.settings.resonanceNorm = 0.66f;
|
||||
p.play.filter.modAmount = -0.4;
|
||||
p.play.filter.velAmount = 0.25;
|
||||
p.play.filter.keyTrack = 0.75;
|
||||
p.play.filter.env.attackSeconds = 0.05;
|
||||
p.play.filter.trigEnv.decaySeconds = 0.6;
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// THE assertion this module exists for. For every exposed control and several normalized
|
||||
// positions: writing it through the model and folding must equal patching it into the folded
|
||||
// block. Bytes, not fields — a member the patch forgot to route is caught as surely as one it
|
||||
// routed to the wrong place.
|
||||
static void testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite() {
|
||||
const double kPositions[] = {0.0, 0.137, 0.5, 0.813, 1.0};
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
// Master gain is not carried by the block at all — the processor's own atomic is its
|
||||
// route to the audio, and the patch reports that by refusing it.
|
||||
if (row.deck == DeckParam::kMasterGain) {
|
||||
LiveValues block = modelBlock(dialledParams());
|
||||
const LiveValues before = block;
|
||||
CHECK_ID(!applyLiveParam(block, row.deck, 0.25, kRate), row.id);
|
||||
CHECK_ID(std::memcmp(&before, &block, sizeof(LiveValues)) == 0, row.id);
|
||||
continue;
|
||||
}
|
||||
for (double norm : kPositions) {
|
||||
InstrumentParams written = dialledParams();
|
||||
if (row.deck == DeckParam::kKeyTrack) {
|
||||
written.keyTrack = reasampler::instrument::ui::keyTrackFromNorm(norm);
|
||||
} else {
|
||||
reasampler::instrument::ui::setDeckParam(row.deck, written.play, norm,
|
||||
/*segment=*/0);
|
||||
}
|
||||
const LiveValues expected = modelBlock(written);
|
||||
|
||||
LiveValues patched = modelBlock(dialledParams());
|
||||
CHECK_ID(applyLiveParam(patched, row.deck, norm, kRate), row.id);
|
||||
CHECK_ID(std::memcmp(&expected, &patched, sizeof(LiveValues)) == 0, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A drawn contour makes Trigger length inert — the editor's knob goes dead and the fold pins the
|
||||
// fraction at 1.0. A host lane pointed at it must be equally inert, or automation would re-open a
|
||||
// control the model says is closed.
|
||||
static void testTriggerLengthIsInertUnderADrawnEnvelope() {
|
||||
InstrumentParams p = dialledParams();
|
||||
p.play.ampSpline.mode = reasampler::EnvMode::Spline;
|
||||
LiveValues block = modelBlock(p);
|
||||
CHECK(block.splineActive);
|
||||
CHECK(block.lengthFraction == 1.0);
|
||||
CHECK(applyLiveParam(block, DeckParam::kTrigLength, 0.2, kRate));
|
||||
CHECK(block.lengthFraction == 1.0);
|
||||
}
|
||||
|
||||
// A control with no parameter row is refused rather than silently landing somewhere.
|
||||
static void testAnUnexposedControlIsRefused() {
|
||||
LiveValues block = modelBlock(dialledParams());
|
||||
const LiveValues before = block;
|
||||
CHECK(!applyLiveParam(block, DeckParam::kPlayMode, 1.0, kRate));
|
||||
CHECK(!applyLiveParam(block, DeckParam::kVoiceCount, 1.0, kRate));
|
||||
CHECK(std::memcmp(&before, &block, sizeof(LiveValues)) == 0);
|
||||
}
|
||||
|
||||
// Every exposed control resolves to a home the host's read and write paths actually reach. The
|
||||
// guard the promotion of pitch key-track needed: id 1000 was issued against a control whose value
|
||||
// is not in PlaySeconds, and nothing failed to compile.
|
||||
static void testEveryExposedControlHasAValueHome() {
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
CHECK_ID(valueHomeFor(row.deck) != ValueHome::None, row.id);
|
||||
}
|
||||
// And the instance-scalar set is exactly the two the shell branches on by name.
|
||||
CHECK(valueHomeFor(DeckParam::kMasterGain) == ValueHome::InstanceScalar);
|
||||
CHECK(valueHomeFor(DeckParam::kKeyTrack) == ValueHome::InstanceScalar);
|
||||
int instanceScalars = 0;
|
||||
for (const ParamRow& row : paramTable()) {
|
||||
if (valueHomeFor(row.deck) == ValueHome::InstanceScalar) ++instanceScalars;
|
||||
}
|
||||
CHECK(instanceScalars == 2);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPatchingAControlEqualsFoldingTheModelAfterTheSameWrite();
|
||||
testTriggerLengthIsInertUnderADrawnEnvelope();
|
||||
testAnUnexposedControlIsRefused();
|
||||
testEveryExposedControlHasAValueHome();
|
||||
if (g_fail == 0) std::printf("param_live: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
#include "../src/core/instrument/param/param_id.h"
|
||||
#include "../src/core/instrument/engine/filter/filter_params.h"
|
||||
#include "../src/core/instrument/engine/master_gain.h"
|
||||
#include "../src/core/instrument/map/play_seconds.h"
|
||||
#include "../src/core/instrument/map/sample_map.h"
|
||||
#include "../src/core/instrument/ui/deck_values.h"
|
||||
|
||||
#include <cmath>
|
||||
@@ -29,6 +31,7 @@ static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() {
|
||||
// docs/product/parameter-automation.md 6.7.1, per parameter rather than per category, so a
|
||||
// control silently reclassified into the wrong category fails here.
|
||||
const Expect kExpected[] = {
|
||||
{kParamKeyTrackPitch, "%", 0.0, 200.0},
|
||||
{kParamRate, "%", 50.0, 200.0},
|
||||
{kParamPitchOffset, "st", -24.0, 24.0},
|
||||
{kParamPitchEnvAttack, "ms", 0.0, 10000.0},
|
||||
@@ -65,6 +68,7 @@ static void testEveryUnitStringAndRangeMatchesTheSpecifiedTable() {
|
||||
{kParamAmpSustain, "%", 0.0, 100.0},
|
||||
{kParamAmpRelease, "ms", 0.0, 10000.0},
|
||||
{kParamAmpReleaseCurve, "", 0.1, 10.0},
|
||||
{kParamTriggerLength, "%", 0.0, 100.0},
|
||||
{kParamAmpTrigAttack, "ms", 0.0, 10000.0},
|
||||
{kParamAmpTrigAttackCurve, "", 0.1, 10.0},
|
||||
{kParamAmpTrigHold, "%", 0.0, 100.0},
|
||||
@@ -100,6 +104,38 @@ static void testEveryDefaultHasAnExactNormalizedPreimage() {
|
||||
}
|
||||
}
|
||||
|
||||
static void testTheHostAndTheEditorAgreeOnEveryDefaultPosition() {
|
||||
// The criterion is that a host's reset-to-default and the editor's double-click land on the
|
||||
// SAME value — and those are different code paths: ParameterInfo::defaultNormalizedValue
|
||||
// comes from defaultNormalized (a per-CATEGORY switch), the editor's needle from
|
||||
// deckParamNorm (a per-ID one). Asserting the param module against itself would not see the
|
||||
// two disagree, and they carry three independently written full scales to disagree about.
|
||||
using reasampler::instrument::ui::deckParamNorm;
|
||||
const PlaySeconds defaults;
|
||||
for (const ParamRow& row : exposedParams()) {
|
||||
const double hostNorm = defaultNormalized(row.deck);
|
||||
// The four that STORE their normalized position take it verbatim on both surfaces, and
|
||||
// the two instance scalars are not in PlaySeconds at all — each read where it lives.
|
||||
double editorNorm = 0.0;
|
||||
switch (valueHomeFor(row.deck)) {
|
||||
case ValueHome::InstanceScalar:
|
||||
editorNorm = (row.deck == DeckParam::kMasterGain)
|
||||
? reasampler::instrument::engine::masterGainNormFromLinear(1.0) // unity
|
||||
: reasampler::instrument::ui::keyTrackNormFrom(
|
||||
reasampler::instrument::map::InstrumentParams{}.keyTrack);
|
||||
break;
|
||||
case ValueHome::ParamSetNorm:
|
||||
case ValueHome::ParamSet:
|
||||
editorNorm = deckParamNorm(row.deck, defaults);
|
||||
break;
|
||||
case ValueHome::None:
|
||||
CHECK_ID(false, row.id); // an exposed control with no home reads nothing
|
||||
continue;
|
||||
}
|
||||
CHECK_ID(hostNorm == editorNorm, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
static void testTheFiltersFourTakeTheirStoredNormVerbatim() {
|
||||
// Their stored value IS the normalized one, so no taper may participate in their default:
|
||||
// this fails the moment someone routes them through toNormalized(toPlain(x)).
|
||||
@@ -181,6 +217,7 @@ static void testTheAddedDriveInverseUndoesTheFrozenLaw() {
|
||||
int main() {
|
||||
testEveryUnitStringAndRangeMatchesTheSpecifiedTable();
|
||||
testEveryDefaultHasAnExactNormalizedPreimage();
|
||||
testTheHostAndTheEditorAgreeOnEveryDefaultPosition();
|
||||
testTheFiltersFourTakeTheirStoredNormVerbatim();
|
||||
testToPlainIsMonotoneAcrossTheWholeTravel();
|
||||
testTheEndpointsAreTheDeclaredPlainRange();
|
||||
|
||||
Reference in New Issue
Block a user