diff --git a/.github/workflows/plugins_build_7.yml b/.github/workflows/plugins_build_7.yml index 64b8eaf..989c4e0 100644 --- a/.github/workflows/plugins_build_7.yml +++ b/.github/workflows/plugins_build_7.yml @@ -73,6 +73,7 @@ jobs: libeigen3-dev libfluidsynth-dev libpng-dev \ libwebsockets-dev libuv1-dev libhdf5-dev \ ocl-icd-opencl-dev libtinfo-dev \ + libgtest-dev \ flex bison - name: Cache Csound install @@ -136,6 +137,9 @@ jobs: -DFAUST_STDLIB_DIR_HINT="$FAUST_STDLIB_DIR_HINT" cmake --build build --parallel "$(nproc)" + - name: Run plugin tests + run: ctest --test-dir build --output-on-failure + - name: Stage plugins run: | mkdir -p package diff --git a/7.x/CMakeLists.txt b/7.x/CMakeLists.txt index 35fa501..734f933 100644 --- a/7.x/CMakeLists.txt +++ b/7.x/CMakeLists.txt @@ -318,6 +318,9 @@ if(WIN32) add_definitions(-DWIN32) endif() +# Register CTest so subdirectory regression tests (e.g. dssi4cs) can be run. +enable_testing() + add_subdirectory(./src/chua) add_subdirectory(./src/linear_algebra) add_subdirectory(./src/fluidOpcodes) diff --git a/7.x/src/dssi4cs/CMakeLists.txt b/7.x/src/dssi4cs/CMakeLists.txt index ea3de70..b7b2849 100644 --- a/7.x/src/dssi4cs/CMakeLists.txt +++ b/7.x/src/dssi4cs/CMakeLists.txt @@ -31,4 +31,36 @@ if(BUILD_DSSI_OPCODES) target_compile_definitions(dssi4cs PRIVATE "DSSI4CS_DEFAULT_PATH=\"/usr/lib/${CMAKE_LIBRARY_ARCHITECTURE}/dssi:/usr/lib/${CMAKE_LIBRARY_ARCHITECTURE}/ladspa:/usr/local/lib/dssi:/usr/lib/dssi:/usr/lib64/dssi:/usr/local/lib/ladspa:/usr/lib/ladspa:/usr/lib64/ladspa\"") endif() + + # Regression tests for the host, built and registered with CTest when + # GoogleTest is available. The fixture is a small real DSSI/LADSPA plugin + # built from the vendored headers, so its ABI matches the host. + find_package(GTest QUIET) + if(GTest_FOUND) + add_library(dssi_test_plugin MODULE tests/fixtures/dssi_test_plugin.c) + add_library(ladspa_test_plugin MODULE tests/fixtures/dssi_test_plugin.c) + target_compile_definitions(ladspa_test_plugin PRIVATE DSSI_TEST_LADSPA) + foreach(plugin dssi_test_plugin ladspa_test_plugin) + target_include_directories(${plugin} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include/dssi + ${CMAKE_CURRENT_SOURCE_DIR}/include/ladspa + ${DSSI_ALSA_ASOUNDLIB_H}) + endforeach() + + add_executable(dssi_tests tests/dssi_test.cpp) + # Recent GoogleTest releases require C++14, which is newer than the + # C++11 default used for the plugin itself. + set_target_properties(dssi_tests PROPERTIES + CXX_STANDARD 14 + CXX_STANDARD_REQUIRED ON) + target_include_directories(dssi_tests PRIVATE ${CSOUND_INCLUDE_DIRS}) + target_link_libraries(dssi_tests PRIVATE + GTest::gtest_main ${CSOUND_LIBRARY} ${CMAKE_DL_LIBS}) + target_compile_definitions(dssi_tests PRIVATE + CSOUND_TEST_DSSI_HOST="$" + CSOUND_TEST_DSSI_PLUGIN="$" + CSOUND_TEST_LADSPA_PLUGIN="$") + add_dependencies(dssi_tests dssi4cs dssi_test_plugin ladspa_test_plugin) + add_test(NAME dssi_tests COMMAND dssi_tests) + endif() endif() diff --git a/7.x/src/dssi4cs/dssi4cs.c b/7.x/src/dssi4cs/dssi4cs.c index 0f6f2e0..3939a2b 100644 --- a/7.x/src/dssi4cs/dssi4cs.c +++ b/7.x/src/dssi4cs/dssi4cs.c @@ -803,8 +803,33 @@ static int32_t dssilist(CSOUND *csound, DSSILIST *p) static OENTRY localops[] = { {"dssiinit", sizeof(DSSIINIT), 0, "i", "Tip", (SUBR)dssiinit}, {"dssiactivate", sizeof(DSSIACTIVATE), 0, "", "ik", (SUBR)dssiactivate_init, (SUBR)dssiactivate}, - {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iyyyyyyyyy", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, - {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iyyyyyyyyy", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + /* The audio inputs are optional (see README) and must be a-rate. A single + 'y' (indefinite a-rate list) entry would make every input mandatory, + because the parser only treats the 'M'/'N'/'m'/'W' lists as omittable. + Register one entry per possible input count instead: fixed 'a' inputs + are a-rate only (so the opcode never reads past a scalar) and the + matching entry is selected by argument count, allowing the inputs to be + omitted. */ + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "i", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "ia", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaa", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaa", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaa", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaa", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaaa", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaaaa", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaaaaa", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssiaudio", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaaaaaa", (SUBR)dssiaudio_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "i", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "ia", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaa", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaa", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaa", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaa", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaaa", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaaaa", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaaaaa", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, + {"dssisynth", sizeof(DSSIAUDIO), 0, "mmmmmmmmm", "iaaaaaaaaa", (SUBR)dssisynth_init, (SUBR)dssiaudio, (SUBR)dssiaudio_deinit}, {"dssictls", sizeof(DSSICTLS), 0, "", "iikk", (SUBR)dssictls_init, (SUBR)dssictls}, {"dssiget", sizeof(DSSIGET), 0, "k", "ii", (SUBR)dssiget_init, (SUBR)dssiget}, {"dssinote", sizeof(DSSINOTE), 0, "", "kikkko", (SUBR)dssinote_init, (SUBR)dssinote}, diff --git a/7.x/src/dssi4cs/tests/dssi_test.cpp b/7.x/src/dssi4cs/tests/dssi_test.cpp new file mode 100644 index 0000000..0733dd2 --- /dev/null +++ b/7.x/src/dssi4cs/tests/dssi_test.cpp @@ -0,0 +1,446 @@ +#include "csound.h" +#include "gtest/gtest.h" +#include +#include +#include + +#ifndef CSOUND_TEST_DSSI_HOST +#error DSSI host path is required +#endif + +class DssiTests : public ::testing::Test { +protected: + CSOUND *csound = nullptr; + void *fixture = nullptr; + int (*stat)(int) = nullptr; + std::vector audio; + std::string pending; + // Move any queued messages into 'pending' so that run() can report their + // text without reading Csound internals. + void drain() { + while (csoundGetMessageCnt(csound)) { + pending += csoundGetFirstMessage(csound); + csoundPopFirstMessage(csound); + } + } + std::string messages() { + drain(); + std::string result = pending; + pending.clear(); + return result; + } + std::string load(int index = 0, const char *id = "gih", const char *path = CSOUND_TEST_DSSI_PLUGIN) { + return std::string(id) + " dssiinit \"" + path + "\", " + std::to_string(index) + ", 1\n"; + } + int run(const std::string &orc, const std::string &score = "i1 0 .01", int ksmps = 16, int dbfs = 1) { + pending.clear(); + audio.clear(); + std::string csd = "\n\n-n -d -m0 --sample-accurate\n\n" + "\nsr=8192\nksmps=" + std::to_string(ksmps) + + "\nnchnls=2\n0dbfs=" + std::to_string(dbfs) + "\n" + orc + + "\n\n\n" + score + + "\ne\n\n\n"; + // Initialisation errors (bad handle, missing DSSI/LADSPA descriptor, + // two renderers for one instance, ...) make csoundStart() fail, while + // performance errors are counted by csoundErrCnt(). Together these + // replace reading the private inerrcnt/perferrcnt fields. + int result = csoundCompileCSD(csound, csd.c_str(), 1, 0); + if (result == 0) result = csoundStart(csound); + if (result) { drain(); return result; } + for (int blocks = 0; blocks < 1000; ++blocks) { + int done = csoundPerformKsmps(csound); + const cs_float *out = csoundGetSpout(csound); + audio.insert(audio.end(), out, out + ksmps * 2); + if (done) { drain(); return csoundErrCnt(csound); } + } + ADD_FAILURE() << "Performance did not terminate"; + return -1; + } + void samples(int begin, int end, cs_double expected) { + ASSERT_GE(audio.size(), size_t(end * 2)); + for (int i = begin; i < end; ++i) { + EXPECT_NEAR(audio[2*i], expected, 1e-6) << "sample " << i; + EXPECT_NEAR(audio[2*i+1], expected, 1e-6) << "sample " << i; + } + } + void SetUp() override { + fixture = dlopen(CSOUND_TEST_DSSI_PLUGIN, RTLD_NOW | RTLD_LOCAL); + ASSERT_NE(fixture, nullptr) << dlerror(); + stat = reinterpret_cast(dlsym(fixture, "dssi_test_stat")); + ASSERT_NE(stat, nullptr); + reinterpret_cast(dlsym(fixture, "dssi_test_reset"))(); + csound = csoundCreate(nullptr, nullptr); + csoundCreateMessageBuffer(csound, 0); + csoundSetOption(csound, "-n"); + csoundSetOption(csound, "-d"); + csoundSetOption(csound, "--sample-accurate"); + csoundSetOption(csound, "--opcode-lib=" CSOUND_TEST_DSSI_HOST); + } + void TearDown() override { + csoundDestroy(csound); + EXPECT_EQ(stat(0), 0) << "plugin instances leaked"; + EXPECT_EQ(stat(2), 0) << "plugin callback contract failed"; + dlclose(fixture); + } +}; + +TEST_F(DssiTests, SynthAndNoteOpcodesAreAvailable) { + const char *orc = "sr=48000\nksmps=16\nnchnls=2\n0dbfs=1\n" + "instr 1\n" + "dssinote 1, 0, 60, 100, 1\n" + "dssievent 1, 0, 60, 100\n" + "a1, a2 dssisynth 0\n" + "endin\n"; + EXPECT_EQ(csoundCompileOrc(csound, orc, 0), 0); +} + +TEST_F(DssiTests, DurationSchedulesNoteOffAcrossBlocks) { + ASSERT_EQ(run(load() + R"( +instr 1 + dssiactivate gih, 1 + konce init 1 + dssinote konce, gih, 60, 127, 20/sr + konce = 0 + a1,a2 dssisynth gih + outs a1,a2 +endin +)"), 0) << messages(); + samples(0,20,1); + samples(20,64,0); + csoundReset(csound); + EXPECT_EQ(stat(1),1); +} + +TEST_F(DssiTests, MidiOffsetsAreSortedAndZeroVelocityBecomesNoteOff) { + ASSERT_EQ(run(load() + R"( +instr 1 + dssiactivate gih,1 + konce init 1 + dssievent konce,gih,144,0,60,0,11 + dssievent konce,gih,144,0,60,127,3 + konce=0 + a1,a2 dssisynth gih + outs a1,a2 +endin +)"), 0) << messages(); + samples(0,3,0); samples(3,11,1); samples(11,64,0); +} + +TEST_F(DssiTests, ProgramAndMappedControllerChangesSplitAtTheirSample) { + ASSERT_EQ(run(load() + R"( +instr 1 + dssiactivate gih,1 + konce init 1 + dssievent konce,gih,144,0,60,127 + dssievent konce,gih,176,0,0,1,4 + dssievent konce,gih,176,0,32,2,4 + dssievent konce,gih,192,0,5,0,4 + dssievent konce,gih,176,0,7,0,10 + konce=0 + a1,a2 dssisynth gih + kprogram dssiget gih,6 + chnset kprogram,"program" + outs a1,a2 +endin +)"), 0) << messages(); + samples(0,4,1); samples(4,10,.5); samples(10,64,0); + EXPECT_EQ(csoundGetControlChannel(csound,"program",nullptr),130*128+5); +} + +TEST_F(DssiTests, InterleavedControlPortsAndConfiguration) { + ASSERT_EQ(run(load() + R"( +dssiconfigure gih,"bias",".125" +Sname,ibank,iprog dssiprograminfo gih,1 +chnset ibank,"bank" +chnset iprog,"preset" +instr 1 + dssiactivate gih,1 + dssictls gih,4,.25,1 + dssievent 1,gih,60,127 + a1,a2 dssisynth gih + kvalue dssiget gih,4 + chnset kvalue,"bias" + outs a1,a2 +endin +)"), 0) << messages(); + samples(0,64,1.25); + EXPECT_EQ(csoundGetControlChannel(csound,"bias",nullptr),.25); + EXPECT_EQ(csoundGetControlChannel(csound,"bank",nullptr),130); + EXPECT_EQ(csoundGetControlChannel(csound,"preset",nullptr),5); +} + +TEST_F(DssiTests, OptionalCallbacksAndPartialBlocks) { + ASSERT_EQ(run(load(2) + R"( +instr 1 + dssiactivate gih,1 + konce init 1 + dssievent konce,gih,60,127 + konce=0 + a1,a2 dssisynth gih + outs a1,a2 +endin +)","i1 [3/8192] [8/8192]"), 0) << messages(); + samples(0,3,0); samples(3,11,1); samples(11,16,0); +} + +TEST_F(DssiTests, LocalKsmpsAndDbfsConversion) { + ASSERT_EQ(run(load() + R"( +opcode render,aa,i + setksmps 4 + ih xin + dssiactivate ih,1 + konce init 1 + dssinote konce,ih,60,127,7/sr + konce=0 + a1,a2 dssisynth ih + xout a1,a2 +endop +instr 1 + a1,a2 render gih + outs a1,a2 +endin +)","i1 0 .01",16,32768),0) << messages(); + samples(0,7,32768); samples(7,64,0); +} + +TEST_F(DssiTests, MultipleSynthCallbackReceivesEveryActiveInstance) { + ASSERT_EQ(run(load(1,"gih") + load(1,"gih2") + R"( +instr 1 + dssiactivate gih,1 + dssiactivate gih2,1 + konce init 1 + dssievent konce,gih,60,127 + dssievent konce,gih2,64,127 + konce=0 + a1,a2 dssisynth gih + a3,a4 dssisynth gih2 + outs a1+a3,a2+a4 +endin +)","i1 0 [64/8192]"),0) << messages(); + samples(0,64,2); + EXPECT_EQ(stat(4),2); + EXPECT_EQ(stat(3),4); +} + +TEST_F(DssiTests, LadspaEffectUsesInputAndOutputScaling) { + ASSERT_EQ(run(load(0,"gih",CSOUND_TEST_LADSPA_PLUGIN) + R"( +instr 1 + dssiactivate gih,1 + dssictls gih,1,.5,1 + ain init 16384 + a1,a2 dssiaudio gih,ain + outs a1,a2 +endin +)","i1 0 .01",16,32768),0) << messages(); + samples(0,64,8192); +} + +TEST_F(DssiTests, DssiAudioAllowsOmittedInputs) { + // The audio inputs are optional. Every other dssiaudio call in this suite + // supplies them, so a regression that made them mandatory would go + // unnoticed without this case. + ASSERT_EQ(run(load() + R"( +instr 1 + dssiactivate gih,1 + a1,a2 dssiaudio gih + outs a1,a2 +endin +)"), 0) << messages(); +} + +TEST_F(DssiTests, RejectsBadDescriptorWithoutPublishingAnInstance) { + EXPECT_NE(run(load(99)),0); + EXPECT_EQ(stat(0),0); +} + +TEST_F(DssiTests, RejectsBankBeyondUnsigned32BitRange) { + // 2^32 is exact in float, but UINT32_MAX is not. + EXPECT_NE(run(load() + R"( +instr 1 + dssiprogram gih,4294967296,0,1 +endin +)"), 0); + const auto error = messages(); + EXPECT_NE(error.find("invalid bank or program"), std::string::npos) << error; +} + +TEST_F(DssiTests, RejectsProgramBeyondUnsigned32BitRange) { + EXPECT_NE(run(load() + R"( +instr 1 + dssiprogram gih,0,4294967296,1 +endin +)"), 0); + const auto error = messages(); + EXPECT_NE(error.find("invalid bank or program"), std::string::npos) << error; +} + +TEST_F(DssiTests, RejectsMalformedDescriptor) { + EXPECT_NE(run(load(3)),0); + EXPECT_EQ(stat(0),0); +} + +TEST_F(DssiTests, RejectsAudioPortUsedAsControl) { + EXPECT_NE(run(load()+"instr 1\ndssictls gih,3,1,1\nendin\n"),0); + EXPECT_NE(messages().find("input control port"),std::string::npos); +} + +TEST_F(DssiTests, RejectsInvalidActivationHandle) { + EXPECT_NE(run("instr 1\ndssiactivate -1,1\nendin\n"),0); + EXPECT_NE(messages().find("invalid handle"),std::string::npos); +} + +TEST_F(DssiTests, RejectsTwoRenderersForOneInstance) { + EXPECT_NE(run(load()+R"( +instr 1 + a1,a2 dssisynth gih + a3,a4 dssisynth gih +endin +)"),0); + EXPECT_NE(messages().find("exactly one audio renderer"),std::string::npos); +} + +TEST_F(DssiTests, QueueOverflowReportsAnError) { + EXPECT_NE(run(load()+R"( +instr 1 + dssiactivate gih,1 + kindex=0 + while kindex < 1025 do + dssievent 1,gih,60,127 + kindex+=1 + od + a1,a2 dssisynth gih +endin +)"),0); + EXPECT_NE(messages().find("event queue is full"),std::string::npos); +} + +TEST_F(DssiTests, DiscoveryIncludesDssiOnlyLibrary) { + std::string path = CSOUND_TEST_DSSI_PLUGIN; + csoundSetOption(csound,("--env:DSSI_PATH="+path.substr(0,path.find_last_of('/'))).c_str()); + ASSERT_EQ(run("dssilist\ninstr 1\nendin\n"),0) << messages(); + EXPECT_NE(messages().find("fixture (fixture)"),std::string::npos); +} + +TEST_F(DssiTests, ConfigurationErrorsReachCsound) { + EXPECT_NE(run(load()+"dssiconfigure gih,\"bad\",\"value\"\n"),0); + EXPECT_NE(messages().find("unknown fixture key"),std::string::npos); +} + +TEST_F(DssiTests, NrpnMappingChangesControlsWithoutDuplicateEvents) { + ASSERT_EQ(run(load()+R"( +instr 1 + dssiactivate gih,1 + konce init 1 + dssievent konce,gih,60,127 + dssinrpn konce,gih,0,42,0,9 + konce=0 + a1,a2 dssisynth gih + kcount dssiget gih,2 + chnset kcount,"events" + outs a1,a2 +endin +)"),0) << messages(); + samples(0,9,1); samples(9,64,0); + EXPECT_EQ(csoundGetControlChannel(csound,"events",nullptr),1); +} + +TEST_F(DssiTests, ResumeStopsOldNotesWhenPluginOmitsActivationCallbacks) { + ASSERT_EQ(run(load(2)+R"( +instr 1 + kcycle timeinstk + kactive = (kcycle == 2 ? 0 : 1) + dssiactivate gih,kactive + dssievent (kcycle == 1 ? 1 : 0),gih,60,127 + a1,a2 dssisynth gih + outs a1,a2 +endin +)"),0) << messages(); + samples(0,16,1); samples(16,64,0); +} + +TEST_F(DssiTests, GlobalConfigurationAppliesToEachMatchingInstance) { + ASSERT_EQ(run(load()+load(0,"gih2")+R"( +dssiconfigure gih,"GLOBAL:bias",".125" +instr 1 + dssiactivate gih,1 + dssiactivate gih2,1 + a1,a2 dssisynth gih + a3,a4 dssisynth gih2 + outs a1+a3,a2+a4 +endin +)"),0) << messages(); + samples(0,64,.25); +} + +TEST_F(DssiTests, RejectsEventsSentAfterTheRenderer) { + EXPECT_NE(run(load()+R"( +instr 1 + dssiactivate gih,1 + a1,a2 dssisynth gih + dssievent 1,gih,60,127 +endin +)"),0); + EXPECT_NE(messages().find("before rendering"),std::string::npos); +} + +TEST_F(DssiTests, ProgramNamesAreCopiedBeforeNextEnumeration) { + ASSERT_EQ(run(load()+R"( +Sfirst,ibank,iprog dssiprograminfo gih,0 +Ssecond,ibank2,iprog2 dssiprograminfo gih,1 +idiff strcmp Sfirst,"first" +chnset idiff,"diff" +Snone,ibank3,iprog3 dssiprograminfo gih,2 +chnset ibank3,"end" +instr 1 +endin +)"),0) << messages(); + EXPECT_EQ(csoundGetControlChannel(csound,"diff",nullptr),0); + EXPECT_EQ(csoundGetControlChannel(csound,"end",nullptr),-1); +} + +TEST_F(DssiTests, SustainedAndRepeatedNotesDoNotSurviveResume) { + ASSERT_EQ(run(load(2)+R"( +instr 1 + kcycle timeinstk + kactive = (kcycle == 2 ? 0 : 1) + dssiactivate gih,kactive + konce = (kcycle == 1 ? 1 : 0) + dssievent konce,gih,176,0,64,127 + dssievent konce,gih,144,0,60,127 + dssievent konce,gih,144,0,60,127 + dssievent konce,gih,128,0,60,0,8 + dssievent konce,gih,128,0,60,0,8 + a1,a2 dssisynth gih + outs a1,a2 +endin +)"),0) << messages(); + samples(0,16,1); samples(16,64,0); +} + +TEST_F(DssiTests, LateActivationCannotRenderAGroupTwice) { + EXPECT_NE(run(load(1)+load(1,"gih2")+R"( +instr 1 + dssiactivate gih,1 + dssievent 1,gih,60,127 + a1,a2 dssisynth gih +endin +instr 2 + dssiactivate gih2,1 + a1,a2 dssisynth gih2 +endin +)","i1 0 .01\ni2 0 .01"),0); + EXPECT_NE(messages().find("activate all grouped instances"),std::string::npos); + EXPECT_EQ(stat(4),1); +} + +TEST_F(DssiTests, FailedFirstLoadThenInvalidHandleReportsInitErrors) { + EXPECT_NE(run(R"( +instr 1 + ih dssiinit "/does/not/exist.so",0,0 +endin +instr 2 + dssiactivate 4096,1 +endin +)","i1 0 .002\ni2 .004 .002"),0); + EXPECT_NE(messages().find("invalid handle"),std::string::npos); +} diff --git a/7.x/src/dssi4cs/tests/fixtures/dssi_test_plugin.c b/7.x/src/dssi4cs/tests/fixtures/dssi_test_plugin.c new file mode 100644 index 0000000..2f354da --- /dev/null +++ b/7.x/src/dssi4cs/tests/fixtures/dssi_test_plugin.c @@ -0,0 +1,195 @@ +/* Deterministic native plugin for DSSI/LADSPA host integration tests. + Built against the same vendored headers as the host, so the ABI matches. */ +#include +#include +#include +#include + +#define EXPORT __attribute__((visibility("default"))) +static int live, cleanups, failures, group_calls, largest_group; +static int active[4]; +typedef struct { + LADSPA_Data *ports[8]; + float voice; + int held, sustain; + int kind; + unsigned int events, program; +} Instance; + +EXPORT int dssi_test_stat(int which) +{ + switch (which) { + case 0: return live; + case 1: return cleanups; + case 2: return failures; + case 3: return group_calls; + case 4: return largest_group; + default: return -1; + } +} +EXPORT void dssi_test_reset(void) +{ + if (live) ++failures; + cleanups = failures = group_calls = largest_group = 0; + memset(active, 0, sizeof(active)); +} + +static LADSPA_Handle instantiate(const LADSPA_Descriptor *d, unsigned long sr) +{ + (void)sr; + Instance *p = calloc(1, sizeof(*p)); + p->kind = (int)(d->UniqueID - 900001); + ++live; + return p; +} +static void connect_port(LADSPA_Handle h, unsigned long port, LADSPA_Data *data) +{ ((Instance *)h)->ports[port] = data; } +static void activate(LADSPA_Handle h) +{ Instance *p = h; p->voice = 0; ++active[p->kind]; } +static void deactivate(LADSPA_Handle h) +{ Instance *p = h; p->voice = 0; --active[p->kind]; } +static void cleanup(LADSPA_Handle h) +{ --live; ++cleanups; free(h); } + +static void run_synth(LADSPA_Handle h, unsigned long n, snd_seq_event_t *events, + unsigned long count) +{ + Instance *p = h; + unsigned long e = 0; + for (unsigned long i = 0; i < count; ++i) + if (events[i].time.tick >= n || (i && events[i].time.tick < events[i-1].time.tick)) ++failures; + for (unsigned long i = 0; i < n; ++i) { + while (e < count && events[e].time.tick == i) { + snd_seq_event_t *event = &events[e++]; + ++p->events; + *p->ports[7] = event->time.tick; + if (event->type == SND_SEQ_EVENT_NOTEON) { + if (!event->data.note.velocity) ++failures; + ++p->held; + p->voice = event->data.note.velocity / 127.f; + } + else if (event->type == SND_SEQ_EVENT_NOTEOFF) { + if (p->held) --p->held; + if (!p->held && !p->sustain) p->voice = 0; + } + else if (event->type == SND_SEQ_EVENT_CONTROLLER && event->data.control.param == 64) { + p->sustain = event->data.control.value >= 64; + if (!p->sustain && !p->held) p->voice = 0; + } + else if (event->type == SND_SEQ_EVENT_CONTROLLER && + (event->data.control.param == 120 || event->data.control.param == 123)) { + p->held = 0; + p->voice = 0; + } + else if (event->type == SND_SEQ_EVENT_PGMCHANGE || event->type == SND_SEQ_EVENT_NOTE) + ++failures; + else if (event->type == SND_SEQ_EVENT_CONTROLLER && + (event->data.control.param == 0 || event->data.control.param == 32 || + event->data.control.param == 7)) ++failures; + } + float input = p->kind == 1 ? 0 : p->ports[3][i]; +#ifdef DSSI_TEST_LADSPA + p->voice = 0; +#endif + p->ports[0][i] = (input + p->voice) * *p->ports[1] + *p->ports[4]; + p->ports[5][i] = p->ports[0][i]; + } + *p->ports[2] = p->events; + *p->ports[6] = p->program; +} +#ifdef DSSI_TEST_LADSPA +static void run(LADSPA_Handle h, unsigned long n) { run_synth(h, n, NULL, 0); } +#endif +static void run_multiple(unsigned long count, LADSPA_Handle *handles, + unsigned long n, snd_seq_event_t **events, + unsigned long *counts) +{ + ++group_calls; + if ((int)count > largest_group) largest_group = (int)count; + if ((int)count != active[1]) ++failures; + for (unsigned long i = 0; i < count; ++i) run_synth(handles[i], n, events[i], counts[i]); +} +static int midi_mapping(LADSPA_Handle h, unsigned long port) +{ (void)h; return port == 1 ? DSSI_CC(7) | DSSI_NRPN(42) : DSSI_NONE; } +static const DSSI_Program_Descriptor *get_program(LADSPA_Handle h, unsigned long index) +{ + (void)h; + static DSSI_Program_Descriptor p; + static char name[32]; + if (index > 1) return NULL; + strcpy(name, index ? "second" : "first"); + p.Bank = index ? 130 : 0; + p.Program = index ? 5 : 0; + p.Name = name; + return &p; +} +static void select_program(LADSPA_Handle h, unsigned long bank, unsigned long program) +{ + Instance *p = h; + p->program = (unsigned int)(bank * 128 + program); + *p->ports[1] = program == 5 ? .5f : 1.f; +} +static char *configure(LADSPA_Handle h, const char *key, const char *value) +{ + Instance *p = h; + if (!strcmp(key, "bias") || !strcmp(key, "GLOBAL:bias")) { + *p->ports[4] = atof(value); + return NULL; + } + return strdup("unknown fixture key"); +} + +static const LADSPA_PortDescriptor ports[] = { + LADSPA_PORT_AUDIO | LADSPA_PORT_OUTPUT, LADSPA_PORT_CONTROL | LADSPA_PORT_INPUT, + LADSPA_PORT_CONTROL | LADSPA_PORT_OUTPUT, LADSPA_PORT_AUDIO | LADSPA_PORT_INPUT, + LADSPA_PORT_CONTROL | LADSPA_PORT_INPUT, LADSPA_PORT_AUDIO | LADSPA_PORT_OUTPUT, + LADSPA_PORT_CONTROL | LADSPA_PORT_OUTPUT, LADSPA_PORT_CONTROL | LADSPA_PORT_OUTPUT +}; +static const char *names[] = {"left", "gain", "events", "input", "bias", "right", "program", "tick"}; +static const LADSPA_PortRangeHint hints[] = { + {0,0,0}, {LADSPA_HINT_BOUNDED_BELOW | LADSPA_HINT_BOUNDED_ABOVE | LADSPA_HINT_DEFAULT_1, 0,1}, + {0,0,0}, {0,0,0}, {LADSPA_HINT_DEFAULT_0,0,0}, {0,0,0}, {0,0,0}, {0,0,0} +}; +static LADSPA_Descriptor descriptors[4]; +static DSSI_Descriptor synths[4]; +static LADSPA_PortDescriptor multi_ports[8]; +static int initialized; +static void init(void) +{ + if (initialized) return; + initialized = 1; + memcpy(multi_ports, ports, sizeof(ports)); + multi_ports[3] = LADSPA_PORT_CONTROL | LADSPA_PORT_INPUT; + const char *labels[] = {"fixture", "multi", "optional", "invalid"}; + for (int i = 0; i < 4; ++i) { + descriptors[i] = (LADSPA_Descriptor){ + .UniqueID = 900001 + i, .Label = labels[i], .Name = labels[i], + .Maker = "Csound", .Copyright = "LGPL-2.1-or-later", .PortCount = 8, + .PortDescriptors = i == 1 ? multi_ports : ports, .PortNames = names, + .PortRangeHints = hints, .instantiate = instantiate, .connect_port = connect_port, + .activate = i == 2 ? NULL : activate, .deactivate = i == 2 ? NULL : deactivate, + .cleanup = cleanup, +#ifdef DSSI_TEST_LADSPA + .run = run +#else + .run = NULL +#endif + }; + synths[i] = (DSSI_Descriptor){.DSSI_API_Version = 1, .LADSPA_Plugin = &descriptors[i], + .configure = i == 2 ? NULL : configure, + .get_program = i == 2 ? NULL : get_program, + .select_program = i == 2 ? NULL : select_program, + .get_midi_controller_for_port = i == 2 ? NULL : midi_mapping, + .run_synth = i == 1 ? NULL : run_synth, + .run_multiple_synths = i == 1 ? run_multiple : NULL}; + } + descriptors[3].connect_port = NULL; +} +#ifdef DSSI_TEST_LADSPA +EXPORT const LADSPA_Descriptor *ladspa_descriptor(unsigned long i) +{ init(); return i < 4 ? &descriptors[i] : NULL; } +#else +/* Deliberately export only dssi_descriptor: discovery must handle this. */ +EXPORT const DSSI_Descriptor *dssi_descriptor(unsigned long i) +{ init(); return i < 4 ? &synths[i] : NULL; } +#endif