Merge dev into ux-w1-t1-sample-rate: take the spline live-drag and plugin-drop work
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
#include "../src/core/instrument/bake/bake_render.h"
|
||||
|
||||
#include "../src/core/instrument/engine/limiter.h"
|
||||
#include "../src/core/instrument/engine/live_contours.h"
|
||||
#include "../src/core/instrument/engine/live_params.h"
|
||||
|
||||
#include <cmath>
|
||||
@@ -338,6 +339,36 @@ int main() {
|
||||
CHECK(sameSamples(audio, reference));
|
||||
}
|
||||
|
||||
// --- The bake detaches liveContours too, not just live -------------------------------
|
||||
// A contour drag in flight publishes into liveContours (see reasampler_processor.h); a
|
||||
// render that observed it would print whatever the user happens to be dragging instead
|
||||
// of the dialed shape already sitting in play.ampSpline.contour.
|
||||
{
|
||||
instrument::engine::LiveContourBlock block;
|
||||
SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
s.play.ampSpline.contour = instrument::engine::VelocityCurve::flat(); // dialed: unity
|
||||
|
||||
// Publish a very different contour — ramping down to silence — into the block.
|
||||
s.liveContours = █
|
||||
{
|
||||
PlayParams dragged = s.play;
|
||||
dragged.ampSpline.contour = instrument::engine::VelocityCurve::rampDown();
|
||||
block.publish(instrument::engine::foldContours(dragged));
|
||||
}
|
||||
|
||||
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
|
||||
const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
|
||||
|
||||
CHECK(s.liveContours == &block); // the caller's own snapshot was not detached
|
||||
|
||||
SampleData detached = s;
|
||||
detached.liveContours = nullptr;
|
||||
const BakeAudio reference = renderBake(detached, plan, kUnity, kNoLimiter);
|
||||
CHECK(sameSamples(audio, reference));
|
||||
}
|
||||
|
||||
// --- Regression baseline: the neutral render is the source, sample for sample --------
|
||||
// A Trigger voice at its own root under Varispeed reads at ratio exactly 1 and hits no
|
||||
// filter, so every printed frame equals its source frame PROVIDED the amp curve's gain at
|
||||
|
||||
+154
-21
@@ -3,8 +3,9 @@
|
||||
//
|
||||
// Covers:
|
||||
// * The full class matrix: every ReaperSurface x {single, multi} x {track, no track} x
|
||||
// {over a host window, off it}, inside and outside the client rect, plus the half-open edge
|
||||
// and a non-zero panel origin.
|
||||
// {over a host window, off it} x {plugin code under the pointer, not}, inside and outside the
|
||||
// client rect, plus the half-open edge and a non-zero panel origin.
|
||||
// * The OS drag loop's per-turn verdict (decideOsDragTurn), exhaustively.
|
||||
// * Reversibility and speed-independence, stated as properties: a class transition sequence
|
||||
// resolves the same forwards and backwards, and one unresolvable evaluation cannot change
|
||||
// any later evaluation's outcome.
|
||||
@@ -50,6 +51,16 @@ static DropContext offHost(ReaperSurface surface = ReaperSurface::Other, bool si
|
||||
return c;
|
||||
}
|
||||
|
||||
// The same drag with the shell's plugin-code proof set: the window under the pointer is a plugin's
|
||||
// own editor. Defaults to where it matters — an FX surface REAPER attributed to a track.
|
||||
static DropContext overPlugin(ReaperSurface surface = ReaperSurface::FxSurface, bool single = true,
|
||||
bool haveTrack = true, bool offHostToo = false) {
|
||||
DropContext c = ctx(surface, single, haveTrack);
|
||||
c.pointerOverPluginEditor = true;
|
||||
c.pointerOffHost = offHostToo;
|
||||
return c;
|
||||
}
|
||||
|
||||
// Every surface the law enumerates, so the matrix tests iterate rather than list.
|
||||
static const ReaperSurface kAllSurfaces[] = {
|
||||
ReaperSurface::TrackPanel, ReaperSurface::FxSurface, ReaperSurface::FxEmbed,
|
||||
@@ -78,10 +89,11 @@ static void testInsideClientIsAlwaysInternal() {
|
||||
CHECK(decideDropClass(200, 150, kPanel, ctx(s, single)) == DropClass::Internal);
|
||||
CHECK(decideDropClass(0, 0, kPanel, ctx(s, single)) == DropClass::Internal);
|
||||
CHECK(decideDropClass(399, 299, kPanel, ctx(s, single)) == DropClass::Internal);
|
||||
// Even a stale off-host proof cannot reinterpret a point inside our own client rect:
|
||||
// the inside-client answer is resolved ahead of the hand-off gate, so re-entering the
|
||||
// panel always resumes the bank-to-bank gesture.
|
||||
// Even a stale proof of either kind cannot reinterpret a point inside our own client
|
||||
// rect: the inside-client answer is resolved ahead of both hand-off gates, so
|
||||
// re-entering the panel always resumes the bank-to-bank gesture.
|
||||
CHECK(decideDropClass(200, 150, kPanel, offHost(s, single)) == DropClass::Internal);
|
||||
CHECK(decideDropClass(200, 150, kPanel, overPlugin(s, single)) == DropClass::Internal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +131,8 @@ static void testSingleOverTrackPanelIsInstrumentDrop() {
|
||||
DropClass::InstrumentDrop);
|
||||
}
|
||||
|
||||
// The FX chain / floating-FX windows keep their existing outcome.
|
||||
// The FX chain / floating-FX windows' own chrome — REAPER's preset bar, the chain list — keeps its
|
||||
// existing outcome: no plugin-code proof, so the instrument drop.
|
||||
static void testSingleOverFxSurfaceIsInstrumentDrop() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
@@ -198,7 +211,8 @@ static void testNullTrackWithSurfaceRefuses() {
|
||||
// --- The hand-off gate ---------------------------------------------------------
|
||||
|
||||
// THE REGRESSION FLOOR. No reading of REAPER's hit-test can reach the modal OLE loop while the
|
||||
// pointer is over a host window — asserted over EVERY surface the probe can report, both payload
|
||||
// pointer is over a host window and no plugin code owns it (ctx() sets neither proof) — asserted
|
||||
// over EVERY surface the probe can report, both payload
|
||||
// sizes and both track verdicts, not just the toolbar/transport cell that was reported. The
|
||||
// defect was structural (an unnamed token read as "the user left REAPER"), so the guarantee has
|
||||
// to be structural too.
|
||||
@@ -231,9 +245,10 @@ static void testOffHostHandsOffOnlyWhereReaperNamedNothing() {
|
||||
}
|
||||
|
||||
// The shipped exception the token condition exists for: REAPER runs a bridged plugin's UI in
|
||||
// reaper_host*.exe, so the window-ownership test reports off-host over a floating bridged FX
|
||||
// editor even though the pointer never left REAPER. There the recognised token vetoes the
|
||||
// hand-off and the surface's own REAPER-internal outcome stands — identical to the native case.
|
||||
// reaper_host*.exe, so the process test reports off-host over a bridged FX window even though the
|
||||
// pointer never left REAPER. The recognised token vetoes the PROCESS route, so on its own that
|
||||
// proof changes nothing over a named surface — whether the bridged editor then hands off is the
|
||||
// plugin-code route's call alone (testBridgedEditorHandsOffThroughThePluginRoute).
|
||||
static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::FxSurface, true, true)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
@@ -264,13 +279,71 @@ static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- The plugin-editor route ---------------------------------------------------
|
||||
|
||||
// THE FEATURE: over a plugin's own editor inside an FX window, the files go to the plugin through
|
||||
// the OS — whatever the payload size and whether or not REAPER attributed a track, since the
|
||||
// plugin, not a track, is the receiver.
|
||||
static void testPluginEditorOverFxSurfaceHandsOff() {
|
||||
for (bool single : {true, false}) {
|
||||
for (bool haveTrack : {true, false}) {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel,
|
||||
overPlugin(ReaperSurface::FxSurface, single, haveTrack)) ==
|
||||
DropClass::OsHandoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same point WITHOUT the proof is the FX window's chrome: the instrument drop for a single
|
||||
// card, the multi refusal otherwise. The proof is the whole difference, in both directions.
|
||||
static void testSameFxSurfaceWithoutTheProofKeepsItsOutcome() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin(ReaperSurface::FxSurface)) ==
|
||||
DropClass::OsHandoff);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface, false)) ==
|
||||
DropClass::Refuse);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin(ReaperSurface::FxSurface, false)) ==
|
||||
DropClass::OsHandoff);
|
||||
}
|
||||
|
||||
// Plugin code under the pointer anywhere REAPER did not name an FX surface — the TCP, the MCP, the
|
||||
// embed strip, the arrange, an extension window docked in REAPER (Other) — changes nothing: the
|
||||
// token confines the route to FX windows. Asserted over every such cell, on-host and off.
|
||||
static void testPluginProofOffAnFxSurfaceChangesNothing() {
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
if (s == ReaperSurface::FxSurface) continue;
|
||||
for (bool single : {true, false}) {
|
||||
for (bool haveTrack : {true, false}) {
|
||||
for (bool off : {false, true}) {
|
||||
const DropContext without =
|
||||
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel,
|
||||
overPlugin(s, single, haveTrack, off)) ==
|
||||
decideDropClass(kOutX, kOutY, kPanel, without));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A bridged editor: the process proof AND the plugin proof are both set, REAPER names the FX
|
||||
// window. The token vetoed the process route; the plugin route still hands off.
|
||||
static void testBridgedEditorHandsOffThroughThePluginRoute() {
|
||||
for (bool haveTrack : {true, false}) {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel,
|
||||
overPlugin(ReaperSurface::FxSurface, true, haveTrack,
|
||||
/*offHostToo=*/true)) == DropClass::OsHandoff);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Not-a-drag ----------------------------------------------------------------
|
||||
|
||||
// No armed samples, or not dragging -> None regardless of position or surface.
|
||||
static void testNoDragOrNoSamplesIsNone() {
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
for (bool off : {false, true}) {
|
||||
DropContext c = off ? offHost(s) : ctx(s);
|
||||
for (int proof = 0; proof < 3; ++proof) { // none, off-host, plugin editor
|
||||
DropContext c = proof == 1 ? offHost(s) : proof == 2 ? overPlugin(s) : ctx(s);
|
||||
c.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/false};
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::None);
|
||||
CHECK(decideDropClass(200, 150, kPanel, c) == DropClass::None);
|
||||
@@ -299,12 +372,22 @@ static void testClassTransitionsAreReversible() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
|
||||
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
|
||||
|
||||
// The hand-off leg is irreversible only because the SHELL goes modal on it. The law itself
|
||||
// stays stateless across it: evaluating an off-host context leaves the next in-REAPER
|
||||
// evaluation exactly where it was.
|
||||
// The hand-off legs stay stateless too: evaluating an off-host or plugin-editor context leaves
|
||||
// the next in-REAPER evaluation exactly where it was — which is what lets the OS loop withdraw
|
||||
// on the law's own answer (decideOsDragTurn) rather than on anything remembered.
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost()) == DropClass::OsHandoff);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
|
||||
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
|
||||
|
||||
// Through a plugin's editor and back out over its own window's chrome: the instrument drop is
|
||||
// still there after the pass, and so is everything after it.
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin()) == DropClass::OsHandoff);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, fx) == DropClass::InstrumentDrop);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::TrackPanel)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin()) == DropClass::OsHandoff);
|
||||
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
|
||||
}
|
||||
|
||||
// One unresolvable evaluation (a surface with no track, which refuses) followed by a resolvable
|
||||
@@ -331,10 +414,11 @@ static void testDragSpeedCannotChangeTheOutcome() {
|
||||
const DropContext destination = ctx(ReaperSurface::TrackPanel);
|
||||
const DropClass flick = decideDropClass(kOutX, kOutY, kPanel, destination);
|
||||
|
||||
// The slow path crosses everything else first, off-host legs included.
|
||||
// The slow path crosses everything else first, off-host and plugin-editor legs included.
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
(void)decideDropClass(kOutX - 10, kOutY, kPanel, ctx(s));
|
||||
(void)decideDropClass(kOutX - 10, kOutY, kPanel, offHost(s));
|
||||
(void)decideDropClass(kOutX - 10, kOutY, kPanel, overPlugin(s));
|
||||
(void)decideDropClass(200, 150, kPanel, ctx(s)); // and back through the client
|
||||
}
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, destination) == flick);
|
||||
@@ -350,11 +434,14 @@ static void testNoLiveOutsideCombinationResolvesToNone() {
|
||||
for (bool single : {true, false}) {
|
||||
for (bool haveTrack : {true, false}) {
|
||||
for (bool off : {false, true}) {
|
||||
const DropClass c = decideDropClass(
|
||||
kOutX, kOutY, kPanel,
|
||||
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack));
|
||||
CHECK(c != DropClass::None);
|
||||
CHECK(c != DropClass::Internal);
|
||||
for (bool plugin : {false, true}) {
|
||||
DropContext c0 =
|
||||
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack);
|
||||
c0.pointerOverPluginEditor = plugin;
|
||||
const DropClass c = decideDropClass(kOutX, kOutY, kPanel, c0);
|
||||
CHECK(c != DropClass::None);
|
||||
CHECK(c != DropClass::Internal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,6 +577,44 @@ static void testAllSkippedSelectionKeepsInternalDrag() {
|
||||
CHECK(!h.handOffToOs);
|
||||
}
|
||||
|
||||
// --- The OS drag loop, once handed off ---------------------------------------------
|
||||
|
||||
// The whole truth table, one cell per line so a regression names its cell.
|
||||
static void testOsDragTurnTruthTable() {
|
||||
// Button held: stay with the OS while the law still hands off, withdraw the moment it doesn't.
|
||||
CHECK(decideOsDragTurn(false, true, true) == OsDragTurn::Continue);
|
||||
CHECK(decideOsDragTurn(false, true, false) == OsDragTurn::Withdraw);
|
||||
// Released: the OS drops only where the law hands off; over a REAPER surface the release is
|
||||
// given back to the panel, so REAPER's own outcome runs instead of a CF_HDROP import.
|
||||
CHECK(decideOsDragTurn(false, false, true) == OsDragTurn::Drop);
|
||||
CHECK(decideOsDragTurn(false, false, false) == OsDragTurn::WithdrawAtRelease);
|
||||
// Escape ends the drag outright in every cell — never read as a release, never a withdrawal
|
||||
// that would resume a drag the user just cancelled.
|
||||
CHECK(decideOsDragTurn(true, true, true) == OsDragTurn::Cancel);
|
||||
CHECK(decideOsDragTurn(true, true, false) == OsDragTurn::Cancel);
|
||||
CHECK(decideOsDragTurn(true, false, true) == OsDragTurn::Cancel);
|
||||
CHECK(decideOsDragTurn(true, false, false) == OsDragTurn::Cancel);
|
||||
}
|
||||
|
||||
// End to end with the law: a hand-off started over a plugin's editor, then the pointer moves onto
|
||||
// that FX window's chrome with the button held — withdrawn, and the resumed drag's release there is
|
||||
// the instrument drop, not an OS drop. Moving back onto the editor keeps the OS drag.
|
||||
static void testPassOverAPluginEditorIsWithdrawnOnReaperSurfaces() {
|
||||
auto handsOff = [](const DropContext& c) {
|
||||
return decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::OsHandoff;
|
||||
};
|
||||
CHECK(decideOsDragTurn(false, true, handsOff(overPlugin())) == OsDragTurn::Continue);
|
||||
CHECK(decideOsDragTurn(false, true, handsOff(ctx(ReaperSurface::FxSurface))) ==
|
||||
OsDragTurn::Withdraw);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
CHECK(decideOsDragTurn(false, false, handsOff(ctx(ReaperSurface::Arrange))) ==
|
||||
OsDragTurn::WithdrawAtRelease);
|
||||
CHECK(decideOsDragTurn(false, false, handsOff(overPlugin())) == OsDragTurn::Drop);
|
||||
// Leaving REAPER entirely is handed off by the other route and held the same way.
|
||||
CHECK(decideOsDragTurn(false, true, handsOff(offHost())) == OsDragTurn::Continue);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testInsideClientIsAlwaysInternal();
|
||||
testBoundaryHalfOpen();
|
||||
@@ -513,6 +638,11 @@ int main() {
|
||||
testOffHostOverANamedSurfaceKeepsTheReaperOutcome();
|
||||
testNoDragOrNoSamplesIsNone();
|
||||
|
||||
testPluginEditorOverFxSurfaceHandsOff();
|
||||
testSameFxSurfaceWithoutTheProofKeepsItsOutcome();
|
||||
testPluginProofOffAnFxSurfaceChangesNothing();
|
||||
testBridgedEditorHandsOffThroughThePluginRoute();
|
||||
|
||||
testClassTransitionsAreReversible();
|
||||
testUnresolvableEvaluationDoesNotAffectTheNext();
|
||||
testDragSpeedCannotChangeTheOutcome();
|
||||
@@ -532,6 +662,9 @@ int main() {
|
||||
testResolvablePayloadHandsOff();
|
||||
testAllSkippedSelectionKeepsInternalDrag();
|
||||
|
||||
testOsDragTurnTruthTable();
|
||||
testPassOverAPluginEditorIsWithdrawnOnReaperSurfaces();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// Standalone tests for LIVE CONTOUR DELIVERY — a drawn EG edited while notes sound — no VST3,
|
||||
// no REAPER, no framework. Asserts that each of the three contours (amp, pitch, filter) moves
|
||||
// the note already sounding and the next one struck, that the swap is absorbed rather than
|
||||
// stepped, that what start() latched (a Staged EG) stays latched, that an unchanged publish is
|
||||
// byte-identical, that two engines sharing one block both follow (the drain slot), and that the
|
||||
// whole audio-side path allocates nothing. This executable replaces the global allocator to
|
||||
// prove the last one, which is why these tests are not in live_delivery_tests.
|
||||
|
||||
#include "../src/core/instrument/engine/live_contours.h"
|
||||
#include "../src/core/instrument/engine/voice_engine.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <new>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using instrument::engine::ContourPoints;
|
||||
using instrument::engine::CurveDomain;
|
||||
using instrument::engine::LiveContourBlock;
|
||||
using instrument::engine::contourPoints;
|
||||
using instrument::engine::foldContours;
|
||||
|
||||
// --- Allocation counter -----------------------------------------------------------------
|
||||
// Counts every global allocation while armed. Replacing the global operator new is the only way
|
||||
// to observe allocations made inside sampler_core without instrumenting it.
|
||||
|
||||
static std::atomic<long> g_allocations{0};
|
||||
static std::atomic<bool> g_countAllocations{false};
|
||||
|
||||
static void* countedAlloc(std::size_t n) {
|
||||
if (g_countAllocations.load(std::memory_order_relaxed)) {
|
||||
g_allocations.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
if (void* p = std::malloc(n ? n : 1)) return p;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
|
||||
void* operator new(std::size_t n) { return countedAlloc(n); }
|
||||
void* operator new[](std::size_t n) { return countedAlloc(n); }
|
||||
void operator delete(void* p) noexcept { std::free(p); }
|
||||
void operator delete[](void* p) noexcept { std::free(p); }
|
||||
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
|
||||
void operator delete[](void* p, std::size_t) noexcept { std::free(p); }
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
constexpr int kRate = 48000;
|
||||
constexpr std::size_t kBlock = 256;
|
||||
constexpr int kRoot = 60;
|
||||
|
||||
// Every frame 1.0, Trigger (a drawn EG is a Trigger fact — resolvePlay's job on the real path),
|
||||
// played at the root with a flat velocity curve: an output frame IS the amplitude.
|
||||
static SampleData dcSample(std::size_t frames) {
|
||||
SampleData s;
|
||||
s.frames.assign(frames, 1.0f);
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = kRoot;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
return s;
|
||||
}
|
||||
|
||||
static VelocityCurve flatAt(double y, CurveDomain domain) {
|
||||
return VelocityCurve::fromPoints({{0.0, y}, {127.0, y}}, domain);
|
||||
}
|
||||
|
||||
// Three points rather than two, so a publish that changes the point COUNT is what lands.
|
||||
static VelocityCurve flatAt3(double y, CurveDomain domain) {
|
||||
return VelocityCurve::fromPoints({{0.0, y}, {63.0, y}, {127.0, y}}, domain);
|
||||
}
|
||||
|
||||
static double maxAbsDelta(const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
|
||||
double worst = 0.0;
|
||||
for (std::size_t i = from + 1; i < to && i < v.size(); ++i) {
|
||||
worst = (std::max)(worst, std::fabs(static_cast<double>(v[i]) - v[i - 1]));
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
static double peakOf(const std::vector<AudioSample>& v, std::size_t from, std::size_t to) {
|
||||
double peak = 0.0;
|
||||
for (std::size_t i = from; i < to && i < v.size(); ++i) {
|
||||
peak = (std::max)(peak, std::fabs(static_cast<double>(v[i])));
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
// One held note through a one-voice engine observing `block`, with `edited`'s contours
|
||||
// published at the top of block `swapBlock` (-1: never). The initial publish is `sample.play`'s
|
||||
// own contours, the state a reload leaves.
|
||||
static std::vector<AudioSample> renderHeld(SampleData& sample, LiveContourBlock& block,
|
||||
const PlayParams* edited, int swapBlock, int blocks) {
|
||||
sample.liveContours = █
|
||||
block.publish(foldContours(sample.play));
|
||||
VoiceEngine engine(1, sample);
|
||||
engine.noteOn(kRoot, 127);
|
||||
std::vector<AudioSample> out;
|
||||
for (int b = 0; b < blocks; ++b) {
|
||||
if (edited && b == swapBlock) block.publish(foldContours(*edited));
|
||||
engine.render(out, kBlock);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- The fold ------------------------------------------------------------------------------
|
||||
|
||||
static void testTheFoldCarriesEveryPointAndItsHardFlag() {
|
||||
std::vector<VelocityPoint> pts = {{0.0, 0.1}, {40.0, 0.9}, {90.0, 0.2}, {127.0, 0.7}};
|
||||
VelocityCurve c = VelocityCurve::fromPoints(pts, CurveDomain::Unipolar);
|
||||
c.setHard(2, true);
|
||||
const ContourPoints folded = contourPoints(c);
|
||||
CHECK(folded.count == 4);
|
||||
for (std::size_t i = 0; i < 4; ++i) {
|
||||
CHECK(folded.points[i].velocity == c.points()[i].velocity);
|
||||
CHECK(folded.points[i].value == c.points()[i].value);
|
||||
CHECK(folded.points[i].hard == c.points()[i].hard);
|
||||
}
|
||||
CHECK(folded.points[2].hard);
|
||||
|
||||
PlayParams p;
|
||||
p.ampSpline.contour = flatAt(0.3, CurveDomain::Unipolar);
|
||||
p.pitchSpline.contour = c;
|
||||
p.filterSpline.contour = flatAt3(0.6, CurveDomain::Unipolar);
|
||||
const auto all = foldContours(p);
|
||||
CHECK(all.amp.count == 2 && all.amp.points[1].value == 0.3);
|
||||
CHECK(all.pitch.count == 4 && all.pitch.points[1].value == c.points()[1].value);
|
||||
CHECK(all.filter.count == 3 && all.filter.points[2].value == 0.6);
|
||||
}
|
||||
|
||||
// --- Amp -----------------------------------------------------------------------------------
|
||||
|
||||
static void testAHeldNoteFollowsAnAmpContourEditWithoutAStep() {
|
||||
SampleData s = dcSample(200000);
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
|
||||
PlayParams edited = s.play;
|
||||
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
|
||||
|
||||
SampleData base = s;
|
||||
LiveContourBlock blockA, blockB;
|
||||
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 12);
|
||||
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 12);
|
||||
const std::size_t swapAt = 4 * kBlock;
|
||||
|
||||
CHECK(moved[swapAt - 1] == 1.0f);
|
||||
// Absorbed: the first frame read through the new contour reproduces the old level exactly...
|
||||
CHECK(moved[swapAt] == 1.0f);
|
||||
// ...and glides from there. An unabsorbed swap would step by 0.75 in one frame.
|
||||
CHECK(maxAbsDelta(moved, swapAt - 1, swapAt + 64) < 0.05);
|
||||
CHECK(moved[swapAt + 32] < 0.9f);
|
||||
// The held note lands on the edited contour, where the unedited run stays on the old one.
|
||||
CHECK(std::fabs(moved.back() - 0.25) < 1e-6);
|
||||
CHECK(still.back() == 1.0f);
|
||||
}
|
||||
|
||||
// The processor marshals note-ons BEFORE the block renders, so a publish and a note-on landing
|
||||
// in one block reach the engine through startVoice first. The voice already sounding must be
|
||||
// rebound there too: left on the buffer the read just displaced, it would never see this edit.
|
||||
static void testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne() {
|
||||
SampleData s = dcSample(200000);
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
|
||||
LiveContourBlock block;
|
||||
s.liveContours = █
|
||||
block.publish(foldContours(s.play));
|
||||
|
||||
VoiceEngine engine(2, s);
|
||||
engine.noteOn(kRoot, 127);
|
||||
std::vector<AudioSample> out;
|
||||
for (int b = 0; b < 4; ++b) engine.render(out, kBlock);
|
||||
|
||||
PlayParams edited = s.play;
|
||||
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
|
||||
block.publish(foldContours(edited));
|
||||
engine.noteOn(kRoot, 127); // the second voice, struck in the swap block
|
||||
for (int b = 0; b < 8; ++b) engine.render(out, kBlock);
|
||||
|
||||
const std::size_t swapAt = 4 * kBlock;
|
||||
// The sounding voice's absorbed 1.0 plus the fresh voice's snapped 0.25 — the fresh one
|
||||
// takes the edit outright even though the snapshot still carries the old contour.
|
||||
CHECK(std::fabs(out[swapAt] - 1.25) < 1e-6);
|
||||
// Both land on the edit: 0.25 + 0.25. A sounding voice that missed it would leave 1.25.
|
||||
CHECK(std::fabs(out.back() - 0.5) < 1e-6);
|
||||
}
|
||||
|
||||
static void testANoteStruckAfterAnEditPlaysItFromItsFirstFrame() {
|
||||
SampleData s = dcSample(200000);
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
|
||||
LiveContourBlock block;
|
||||
s.liveContours = █
|
||||
block.publish(foldContours(s.play));
|
||||
VoiceEngine engine(1, s);
|
||||
|
||||
PlayParams edited = s.play;
|
||||
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
|
||||
block.publish(foldContours(edited));
|
||||
engine.noteOn(kRoot, 127);
|
||||
std::vector<AudioSample> out;
|
||||
engine.render(out, kBlock);
|
||||
CHECK(out[0] == 0.25f);
|
||||
|
||||
// The snapshot really does still say 1.0: with no block the same engine plays it.
|
||||
SampleData bare = s;
|
||||
bare.liveContours = nullptr;
|
||||
VoiceEngine bareEngine(1, bare);
|
||||
bareEngine.noteOn(kRoot, 127);
|
||||
std::vector<AudioSample> bareOut;
|
||||
bareEngine.render(bareOut, kBlock);
|
||||
CHECK(bareOut[0] == 1.0f);
|
||||
}
|
||||
|
||||
// --- Pitch ---------------------------------------------------------------------------------
|
||||
|
||||
// A ramp source under Varispeed: an output frame is the read head, so the frame-to-frame step
|
||||
// IS the pitch ratio.
|
||||
static void testAHeldNoteFollowsAPitchContourEdit() {
|
||||
SampleData s;
|
||||
s.frames.resize(1u << 15);
|
||||
for (std::size_t i = 0; i < s.frames.size(); ++i) s.frames[i] = static_cast<float>(i);
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = kRoot;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEnv.enabled = true;
|
||||
s.play.pitchEnv.peakSemitones = 12.0;
|
||||
s.play.pitchSpline.mode = EnvMode::Spline;
|
||||
s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar);
|
||||
PlayParams edited = s.play;
|
||||
edited.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar); // +1 x 12 st = an octave
|
||||
|
||||
SampleData base = s;
|
||||
LiveContourBlock blockA, blockB;
|
||||
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 12);
|
||||
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 12);
|
||||
const std::size_t swapAt = 4 * kBlock;
|
||||
const std::size_t n = moved.size();
|
||||
|
||||
CHECK(std::fabs((moved[swapAt - 1] - moved[swapAt - 2]) - 1.0) < 1e-3);
|
||||
CHECK(std::fabs((moved[n - 1] - moved[n - 2]) - 2.0) < 1e-3);
|
||||
CHECK(std::fabs((still[n - 1] - still[n - 2]) - 1.0) < 1e-3);
|
||||
// Absorbed here too: the first frame read through the new contour still advances at the old
|
||||
// ratio, where an unabsorbed swap would already step by 2.
|
||||
CHECK(std::fabs((moved[swapAt + 1] - moved[swapAt]) - 1.0) < 1e-3);
|
||||
}
|
||||
|
||||
// --- Filter --------------------------------------------------------------------------------
|
||||
|
||||
static void testAHeldNoteFollowsAFilterContourEdit() {
|
||||
SampleData s;
|
||||
s.frames.resize(100000);
|
||||
for (std::size_t i = 0; i < s.frames.size(); ++i) {
|
||||
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / 8.0));
|
||||
}
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = kRoot;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.filter.enabled = true;
|
||||
s.play.filter.settings.cutoffNorm = 0.1f;
|
||||
s.play.filter.settings.morphNorm = 1.0f; // low-pass
|
||||
s.play.filter.modAmount = 0.8;
|
||||
s.play.filterSpline.mode = EnvMode::Spline;
|
||||
s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar);
|
||||
PlayParams edited = s.play;
|
||||
edited.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar); // corner 0.1 -> 0.9
|
||||
|
||||
SampleData base = s;
|
||||
LiveContourBlock blockA, blockB;
|
||||
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 16);
|
||||
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 16);
|
||||
const std::size_t tail = moved.size() - 1024;
|
||||
|
||||
const double openedPeak = peakOf(moved, tail, moved.size());
|
||||
const double closedPeak = peakOf(still, tail, still.size());
|
||||
// A 6 kHz tone through the corner the edit opened, against the unedited note's, which stays
|
||||
// filtered away (measured ~0.18 vs ~0.0002 — the margins below are loose on purpose).
|
||||
CHECK(openedPeak > 0.1);
|
||||
CHECK(openedPeak > 10.0 * closedPeak);
|
||||
}
|
||||
|
||||
// --- What stays latched, and what must not move --------------------------------------------
|
||||
|
||||
// Staged|Spline is a note-on fact: an EG that start() left Staged ignores any contour publish.
|
||||
static void testAStagedEnvelopeIgnoresContourPublishes() {
|
||||
SampleData s = dcSample(200000);
|
||||
// A staged shape that is not flat from frame 0, so a leak would show.
|
||||
s.play.trigAhd.decayFrames = 20000;
|
||||
s.play.trigAhd.holdFraction = 0.0;
|
||||
PlayParams hostile = s.play;
|
||||
hostile.ampSpline.contour = flatAt3(0.0, CurveDomain::Unipolar);
|
||||
hostile.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar);
|
||||
hostile.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar);
|
||||
|
||||
SampleData base = s;
|
||||
LiveContourBlock blockA, blockB;
|
||||
const std::vector<AudioSample> published = renderHeld(s, blockA, &hostile, 3, 10);
|
||||
const std::vector<AudioSample> none = renderHeld(base, blockB, nullptr, -1, 10);
|
||||
CHECK(published == none);
|
||||
CHECK(none[3 * kBlock + 100] < 1.0f); // the staged decay is really running
|
||||
}
|
||||
|
||||
// Republishing the contour a voice is already reading rebinds it mid-read; the read must not
|
||||
// move by a bit. A curved, multi-segment contour with a hard point so the re-located segment
|
||||
// and its tangents are genuinely exercised.
|
||||
static void testRepublishingAnUnchangedContourIsByteIdentical() {
|
||||
SampleData s = dcSample(20000);
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
VelocityCurve c = VelocityCurve::fromPoints(
|
||||
{{0.0, 0.0}, {20.0, 1.0}, {60.0, 0.3}, {100.0, 0.9}, {127.0, 0.0}},
|
||||
CurveDomain::Unipolar);
|
||||
c.setHard(2, true);
|
||||
s.play.ampSpline.contour = c;
|
||||
const PlayParams same = s.play;
|
||||
|
||||
SampleData base = s;
|
||||
LiveContourBlock blockA, blockB;
|
||||
const std::vector<AudioSample> republished = renderHeld(s, blockA, &same, 20, 60);
|
||||
const std::vector<AudioSample> none = renderHeld(base, blockB, nullptr, -1, 60);
|
||||
CHECK(republished == none);
|
||||
CHECK(peakOf(none, 0, none.size()) > 0.9); // the contour really is being read
|
||||
}
|
||||
|
||||
// The drain slot: a displaced snapshot and the live one both observe the instance's ONE block,
|
||||
// so a note ringing out of either follows the edit.
|
||||
static void testTwoEnginesSharingOneBlockBothFollow() {
|
||||
SampleData a = dcSample(200000);
|
||||
a.play.ampSpline.mode = EnvMode::Spline;
|
||||
a.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
|
||||
SampleData b = a;
|
||||
LiveContourBlock block;
|
||||
a.liveContours = █
|
||||
b.liveContours = █
|
||||
block.publish(foldContours(a.play));
|
||||
|
||||
VoiceEngine live(1, a), drain(1, b);
|
||||
live.noteOn(kRoot, 127);
|
||||
drain.noteOn(kRoot, 127);
|
||||
std::vector<AudioSample> outLive, outDrain;
|
||||
for (int i = 0; i < 2; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); }
|
||||
|
||||
PlayParams edited = a.play;
|
||||
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
|
||||
block.publish(foldContours(edited));
|
||||
for (int i = 0; i < 8; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); }
|
||||
CHECK(std::fabs(outLive.back() - 0.25) < 1e-6);
|
||||
CHECK(std::fabs(outDrain.back() - 0.25) < 1e-6);
|
||||
}
|
||||
|
||||
// --- Allocation ----------------------------------------------------------------------------
|
||||
|
||||
// The counter itself must be live, or the zero below would prove nothing.
|
||||
static void testTheAllocationCounterSeesAnAllocation() {
|
||||
g_allocations.store(0);
|
||||
g_countAllocations.store(true);
|
||||
std::vector<float>* v = new std::vector<float>(16);
|
||||
g_countAllocations.store(false);
|
||||
delete v;
|
||||
CHECK(g_allocations.load() >= 2); // the vector object and its buffer
|
||||
}
|
||||
|
||||
// Every audio-side step of a contour swap — the block-boundary read, the rebind of every
|
||||
// sounding voice on all three EGs, a note struck in the swap block, and the renders around them
|
||||
// — runs with the counter armed. Only the UI thread's publish is outside it (it allocates
|
||||
// nothing either, but it is not the audio side).
|
||||
static void testTheAudioSideOfAContourSwapAllocatesNothing() {
|
||||
SampleData s = dcSample(200000);
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
|
||||
s.play.pitchEnv.enabled = true;
|
||||
s.play.pitchEnv.peakSemitones = 0.0; // bound, so rebound, but transposing nothing
|
||||
s.play.pitchSpline.mode = EnvMode::Spline;
|
||||
s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar);
|
||||
s.play.filter.enabled = true;
|
||||
s.play.filter.settings.cutoffNorm = 1.0f;
|
||||
s.play.filter.modAmount = 0.0;
|
||||
s.play.filterSpline.mode = EnvMode::Spline;
|
||||
s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar);
|
||||
PlayParams edited = s.play;
|
||||
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
|
||||
edited.pitchSpline.contour = flatAt3(0.0, CurveDomain::Bipolar);
|
||||
edited.filterSpline.contour = flatAt3(0.5, CurveDomain::Unipolar);
|
||||
|
||||
LiveContourBlock block;
|
||||
s.liveContours = █
|
||||
block.publish(foldContours(s.play));
|
||||
VoiceEngine engine(4, s);
|
||||
std::vector<AudioSample> buf(kBlock, 0.0f);
|
||||
const auto renderBlock = [&] {
|
||||
std::fill(buf.begin(), buf.end(), 0.0f);
|
||||
engine.render(buf.data(), kBlock);
|
||||
};
|
||||
|
||||
g_allocations.store(0);
|
||||
g_countAllocations.store(true);
|
||||
engine.noteOn(kRoot, 127);
|
||||
for (int b = 0; b < 4; ++b) renderBlock();
|
||||
g_countAllocations.store(false);
|
||||
block.publish(foldContours(edited)); // UI thread
|
||||
g_countAllocations.store(true);
|
||||
engine.noteOn(kRoot, 127); // startVoice's rebind + snap
|
||||
renderBlock(); // the block boundary's read finds nothing newer: already taken
|
||||
g_countAllocations.store(false);
|
||||
const double afterEdit = buf[kBlock - 1];
|
||||
block.publish(foldContours(s.play)); // and back, so the render path does the read itself
|
||||
g_countAllocations.store(true);
|
||||
for (int b = 0; b < 8; ++b) renderBlock();
|
||||
g_countAllocations.store(false);
|
||||
|
||||
CHECK(g_allocations.load() == 0);
|
||||
// Not vacuous: both swaps really landed inside the counted window, through a wide-open
|
||||
// low-pass whose DC gain is unity. Two voices on the edited 0.25 contour by the end of the
|
||||
// swap block (2.0 had it not landed), then both back on 1.0 through the render path's read.
|
||||
CHECK(std::fabs(afterEdit - 0.5) < 1e-3);
|
||||
CHECK(std::fabs(buf[kBlock - 1] - 2.0) < 1e-3);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testTheFoldCarriesEveryPointAndItsHardFlag();
|
||||
testAHeldNoteFollowsAnAmpContourEditWithoutAStep();
|
||||
testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne();
|
||||
testANoteStruckAfterAnEditPlaysItFromItsFirstFrame();
|
||||
testAHeldNoteFollowsAPitchContourEdit();
|
||||
testAHeldNoteFollowsAFilterContourEdit();
|
||||
testAStagedEnvelopeIgnoresContourPublishes();
|
||||
testRepublishingAnUnchangedContourIsByteIdentical();
|
||||
testTwoEnginesSharingOneBlockBothFollow();
|
||||
testTheAllocationCounterSeesAnAllocation();
|
||||
testTheAudioSideOfAContourSwapAllocatesNothing();
|
||||
if (g_fail == 0) std::printf("live_contours tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -1167,6 +1167,38 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
|
||||
CHECK(proportional);
|
||||
}
|
||||
|
||||
// A note-on that lands in the same block boundary as a publish must not let the already-
|
||||
// sounding voice miss it: startVoice used to call refreshLive() bare, which consumes the
|
||||
// generation stamp without applying it to the voices already active — a held note that shared
|
||||
// a block with a note-on then never saw the publish at all (not merely delayed a block).
|
||||
static void testHeldVoiceReceivesALivePublishThatCoincidesWithANoteOn() {
|
||||
SampleData s;
|
||||
s.frames.assign(200000, 1.0f); // DC: the envelope alone shapes the output
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = 60;
|
||||
s.play.adsr.attackFrames = 0;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 480000; // long enough that release never intrudes
|
||||
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
VoiceEngine engine(2, s);
|
||||
engine.noteOn(60, 100); // voice A, sustaining at the dialed 1.0
|
||||
|
||||
LiveValues quieter = foldLive(s.play, s.keyTrack);
|
||||
quieter.adsr.sustainLevel = 0.2;
|
||||
block.publish(quieter);
|
||||
|
||||
engine.noteOn(61, 100); // voice B starts in the SAME generation as the publish
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
for (int i = 0; i < 20; ++i) engine.render(out, 512); // let voice A's glide settle
|
||||
|
||||
// Both voices settle at 0.2 — a held voice that missed the publish would still sit at 1.0,
|
||||
// for a combined 1.2 rather than 0.4.
|
||||
CHECK(std::fabs(static_cast<double>(out.back()) - 0.4) < 0.01);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testStageDurationChangeHoldsPhase();
|
||||
testShortenedStageStillLandsContinuously();
|
||||
@@ -1189,6 +1221,7 @@ int main() {
|
||||
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
|
||||
testPitchRatioAndVelocityGainStayLatched();
|
||||
testVelocityGainSurvivesAHostilePublishThatReallyLands();
|
||||
testHeldVoiceReceivesALivePublishThatCoincidesWithANoteOn();
|
||||
if (g_fail == 0) std::printf("live_delivery tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Standalone tests for reasampler::ui::window_origin — no REAPER, no Win32, no test framework.
|
||||
// Asserts the per-window code fold and the chain rule the plugin-editor hand-off proof rests on.
|
||||
|
||||
#include "../src/core/ui/window_origin.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler::ui;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static const std::string kWinDir = "C:\\Windows";
|
||||
|
||||
static WindowFacts inModule(const std::string& path, bool host = false) {
|
||||
WindowFacts f;
|
||||
f.procResolved = true;
|
||||
f.hostModule = host;
|
||||
f.modulePath = path;
|
||||
return f;
|
||||
}
|
||||
|
||||
static WindowFacts ownModule(const std::string& path) {
|
||||
WindowFacts f;
|
||||
f.procResolved = true;
|
||||
f.modulePath = path;
|
||||
f.ownModule = true;
|
||||
return f;
|
||||
}
|
||||
|
||||
// --- classifyWindowCode --------------------------------------------------------
|
||||
|
||||
// REAPER's own chrome and containers: the class procedure lives in the executable.
|
||||
static void testHostExecutableIsHost() {
|
||||
CHECK(classifyWindowCode(inModule("C:\\Program Files\\REAPER (x64)\\reaper.exe", true),
|
||||
kWinDir) == WindowCode::Host);
|
||||
}
|
||||
|
||||
// Standard controls and the dialog class are implemented by OS modules, wherever the OS keeps
|
||||
// them — System32, SysWOW64 for a 32-bit host, and the side-by-side store comctl32 v6 loads from.
|
||||
static void testOsModulesAreSystem() {
|
||||
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), kWinDir) ==
|
||||
WindowCode::System);
|
||||
CHECK(classifyWindowCode(inModule("C:\\Windows\\SysWOW64\\user32.dll"), kWinDir) ==
|
||||
WindowCode::System);
|
||||
CHECK(classifyWindowCode(
|
||||
inModule("C:\\Windows\\WinSxS\\amd64_microsoft.windows.common-controls_6595b64144ccf1df"
|
||||
"_6.0.26100.1_none_x\\comctl32.dll"),
|
||||
kWinDir) == WindowCode::System);
|
||||
}
|
||||
|
||||
// The same directory spelled differently by two APIs still compares equal: case, separator
|
||||
// direction, a trailing separator on the root, and the long-path prefix.
|
||||
static void testSystemMatchIgnoresSpelling() {
|
||||
CHECK(classifyWindowCode(inModule("c:/WINDOWS/system32/USER32.DLL"), kWinDir) ==
|
||||
WindowCode::System);
|
||||
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), "C:\\WINDOWS\\") ==
|
||||
WindowCode::System);
|
||||
CHECK(classifyWindowCode(inModule("\\\\?\\C:\\Windows\\System32\\user32.dll"), kWinDir) ==
|
||||
WindowCode::System);
|
||||
}
|
||||
|
||||
// Containment, not string prefix: a sibling directory whose name merely starts with the OS
|
||||
// directory's is plugin territory, and so is the OS directory itself named as a file path.
|
||||
static void testSiblingDirectoryIsNotSystem() {
|
||||
CHECK(classifyWindowCode(inModule("C:\\WindowsApps\\thing.dll"), kWinDir) ==
|
||||
WindowCode::Plugin);
|
||||
CHECK(classifyWindowCode(inModule("C:\\Windows2\\thing.dll"), kWinDir) == WindowCode::Plugin);
|
||||
CHECK(classifyWindowCode(inModule("C:\\Windows"), kWinDir) == WindowCode::Plugin);
|
||||
}
|
||||
|
||||
// A plugin binary anywhere outside the OS directory — the VST3 bundle, a VST2 folder, a helper
|
||||
// DLL a plugin loads beside itself — is Plugin.
|
||||
static void testPluginBinariesArePlugin() {
|
||||
CHECK(classifyWindowCode(
|
||||
inModule("C:\\Program Files\\Common Files\\VST3\\Serum2.vst3\\Contents\\x86_64-win\\"
|
||||
"Serum2.vst3"),
|
||||
kWinDir) == WindowCode::Plugin);
|
||||
CHECK(classifyWindowCode(inModule("D:\\VstPlugins\\Qt5Widgets.dll"), kWinDir) ==
|
||||
WindowCode::Plugin);
|
||||
}
|
||||
|
||||
// Another process's window is plugin code by the process boundary alone, whatever its other facts
|
||||
// claim: a procedure address from another process resolved in ours is meaningless.
|
||||
static void testOtherProcessIsPluginRegardlessOfModule() {
|
||||
WindowFacts f = inModule("C:\\Program Files\\REAPER (x64)\\reaper.exe", true);
|
||||
f.otherProcess = true;
|
||||
CHECK(classifyWindowCode(f, kWinDir) == WindowCode::Plugin);
|
||||
WindowFacts g;
|
||||
g.otherProcess = true; // nothing resolved at all
|
||||
CHECK(classifyWindowCode(g, kWinDir) == WindowCode::Plugin);
|
||||
}
|
||||
|
||||
// No module (a charset-mismatch handle, generated code) is no evidence — never Plugin.
|
||||
static void testUnresolvedProcIsUnknown() {
|
||||
WindowFacts f;
|
||||
f.modulePath = "C:\\VST\\x.dll"; // ignored without a resolved procedure
|
||||
CHECK(classifyWindowCode(f, kWinDir) == WindowCode::Unknown);
|
||||
}
|
||||
|
||||
// Without the OS directory a standard control cannot be told from plugin code, so every non-host
|
||||
// module degrades to Unknown — the failure must not read REAPER's buttons as a plugin.
|
||||
static void testMissingSystemRootNeverYieldsPlugin() {
|
||||
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), "") ==
|
||||
WindowCode::Unknown);
|
||||
CHECK(classifyWindowCode(inModule("C:\\VST\\x.dll"), "") == WindowCode::Unknown);
|
||||
CHECK(classifyWindowCode(inModule("C:\\VST\\x.dll"), "\\") == WindowCode::Unknown);
|
||||
CHECK(classifyWindowCode(inModule("C:\\REAPER\\reaper.exe", true), "") == WindowCode::Host);
|
||||
}
|
||||
|
||||
// Our own ReaSampler 9000 module — stable or beta, whichever the shell set ownModule for — is
|
||||
// Instrument, not Plugin: FX chrome we own, so a drag over it must not hand off to the OS.
|
||||
static void testOwnModuleIsInstrument() {
|
||||
CHECK(classifyWindowCode(
|
||||
ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000.vst3"), kWinDir) ==
|
||||
WindowCode::Instrument);
|
||||
CHECK(classifyWindowCode(
|
||||
ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000_beta.vst3"),
|
||||
kWinDir) == WindowCode::Instrument);
|
||||
}
|
||||
|
||||
// --- isPluginEditorChain -------------------------------------------------------
|
||||
|
||||
// Pointer on REAPER's FX-window chrome: a standard control or a REAPER container, under the
|
||||
// dialog-class root. No plugin code anywhere in the chain.
|
||||
static void testChromeChainIsNotPlugin() {
|
||||
CHECK(!isPluginEditorChain({WindowCode::System, WindowCode::Host, WindowCode::System}));
|
||||
CHECK(!isPluginEditorChain({WindowCode::Host, WindowCode::System}));
|
||||
CHECK(!isPluginEditorChain({WindowCode::Host}));
|
||||
}
|
||||
|
||||
// Pointer on the plugin's editor window itself.
|
||||
static void testPluginWindowChainIsPlugin() {
|
||||
CHECK(isPluginEditorChain({WindowCode::Plugin, WindowCode::Host, WindowCode::System}));
|
||||
}
|
||||
|
||||
// A standard control INSIDE the plugin's editor (a native text box) is System on its own; the
|
||||
// plugin's window above it is what makes the point the plugin's.
|
||||
static void testStandardControlInsidePluginIsPlugin() {
|
||||
CHECK(isPluginEditorChain(
|
||||
{WindowCode::System, WindowCode::Plugin, WindowCode::Host, WindowCode::System}));
|
||||
}
|
||||
|
||||
// A bridged plugin's editor embedded across the process boundary: the hit window is Plugin by
|
||||
// process, REAPER's container and root above it are in-process.
|
||||
static void testBridgedEmbeddedEditorIsPlugin() {
|
||||
WindowFacts bridged;
|
||||
bridged.otherProcess = true;
|
||||
CHECK(isPluginEditorChain({classifyWindowCode(bridged, kWinDir), WindowCode::Host,
|
||||
WindowCode::System}));
|
||||
}
|
||||
|
||||
// A pointer over our own instrument's editor — Instrument code, not Plugin — is not hand-off
|
||||
// proof: it stays REAPER's FX chrome, unlike a genuine plugin editor (testPluginWindowChainIsPlugin).
|
||||
static void testOwnInstrumentChainIsNotPlugin() {
|
||||
CHECK(!isPluginEditorChain({WindowCode::Instrument, WindowCode::Host, WindowCode::System}));
|
||||
}
|
||||
|
||||
// Another plugin's editor sitting alongside our own instrument in the same chain is still proof —
|
||||
// only OUR module is exempted, nobody else's.
|
||||
static void testOtherPluginBesideOwnInstrumentIsStillPlugin() {
|
||||
CHECK(isPluginEditorChain(
|
||||
{WindowCode::Plugin, WindowCode::Instrument, WindowCode::Host, WindowCode::System}));
|
||||
}
|
||||
|
||||
// Unknown anywhere, and nothing else, is not proof; an empty chain (no window at all) is not.
|
||||
static void testUnknownAndEmptyChainsAreNotPlugin() {
|
||||
CHECK(!isPluginEditorChain({WindowCode::Unknown, WindowCode::Host, WindowCode::System}));
|
||||
CHECK(!isPluginEditorChain({WindowCode::Unknown}));
|
||||
CHECK(!isPluginEditorChain({}));
|
||||
}
|
||||
|
||||
// --- isOwnModulePath -----------------------------------------------------------
|
||||
|
||||
static void testOwnModulePathCaseFolds() {
|
||||
CHECK(isOwnModulePath("C:\\VST3\\reasampler_9000.vst3", "reasampler_9000.vst3"));
|
||||
CHECK(isOwnModulePath("C:\\VST3\\ReaSampler_9000.VST3", "reasampler_9000.vst3"));
|
||||
}
|
||||
|
||||
static void testOwnModulePathBundlePathForwardAndBackSlash() {
|
||||
CHECK(isOwnModulePath(
|
||||
"C:\\VST3\\reasampler_9000.vst3\\Contents\\x86_64-win\\reasampler_9000.vst3",
|
||||
"reasampler_9000.vst3"));
|
||||
CHECK(isOwnModulePath(
|
||||
"C:/VST3/reasampler_9000.vst3/Contents/x86_64-win/reasampler_9000.vst3",
|
||||
"reasampler_9000.vst3"));
|
||||
}
|
||||
|
||||
static void testOwnModulePathStableAndBetaDoNotMatchEachOther() {
|
||||
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000_beta.vst3", "reasampler_9000.vst3"));
|
||||
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000.vst3", "reasampler_9000_beta.vst3"));
|
||||
}
|
||||
|
||||
static void testOwnModulePathBareFilenameWithNoSeparatorMatches() {
|
||||
CHECK(isOwnModulePath("reasampler_9000.vst3", "reasampler_9000.vst3"));
|
||||
}
|
||||
|
||||
static void testOwnModulePathEmptyPathDoesNotMatch() {
|
||||
CHECK(!isOwnModulePath("", "reasampler_9000.vst3"));
|
||||
}
|
||||
|
||||
static void testOwnModulePathTrailingSeparatorDoesNotMatch() {
|
||||
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000.vst3\\", "reasampler_9000.vst3"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testHostExecutableIsHost();
|
||||
testOsModulesAreSystem();
|
||||
testSystemMatchIgnoresSpelling();
|
||||
testSiblingDirectoryIsNotSystem();
|
||||
testPluginBinariesArePlugin();
|
||||
testOtherProcessIsPluginRegardlessOfModule();
|
||||
testUnresolvedProcIsUnknown();
|
||||
testMissingSystemRootNeverYieldsPlugin();
|
||||
testOwnModuleIsInstrument();
|
||||
|
||||
testChromeChainIsNotPlugin();
|
||||
testPluginWindowChainIsPlugin();
|
||||
testStandardControlInsidePluginIsPlugin();
|
||||
testBridgedEmbeddedEditorIsPlugin();
|
||||
testOwnInstrumentChainIsNotPlugin();
|
||||
testOtherPluginBesideOwnInstrumentIsStillPlugin();
|
||||
testUnknownAndEmptyChainsAreNotPlugin();
|
||||
|
||||
testOwnModulePathCaseFolds();
|
||||
testOwnModulePathBundlePathForwardAndBackSlash();
|
||||
testOwnModulePathStableAndBetaDoNotMatchEachOther();
|
||||
testOwnModulePathBareFilenameWithNoSeparatorMatches();
|
||||
testOwnModulePathEmptyPathDoesNotMatch();
|
||||
testOwnModulePathTrailingSeparatorDoesNotMatch();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user