From 1af85c97e06b696657a09f3e7e7510b74d130f57 Mon Sep 17 00:00:00 2001 From: vlazzarini Date: Thu, 15 Oct 2026 10:44:46 +0100 Subject: [PATCH 1/2] Add DSSI host regression tests and allow omitting audio inputs Move the DSSI/LADSPA host regression test and its fixture plugin out of the Csound core tree into the plugin that they exercise. The fixture is built from the vendored DSSI/LADSPA headers so its ABI matches the host, and the suite now talks to Csound through the public API (csound.h) instead of the private csoundCore.h internals. Register the tests with CTest: enable_testing() at the top level, a GoogleTest-gated target in dssi4cs/CMakeLists.txt (C++14, as required by recent GoogleTest), and a "Run plugin tests" step in the 7.x build workflow. While adapting the tests, replace the single variable-argument OENTRY for dssiaudio/dssisynth ("iyyyyyyyyy") with one entry per input count ("i".."iaaaaaaaaa"). The parser only treats the M/N/m/W lists as omittable, so the previous entry made every audio input mandatory even though the manual documents them as optional. Fixed a-rate inputs are still a-rate only, so the opcode can never read past a scalar, and the matching entry is selected by argument count. --- .github/workflows/plugins_build_7.yml | 4 + 7.x/CMakeLists.txt | 3 + 7.x/src/dssi4cs/CMakeLists.txt | 32 ++ 7.x/src/dssi4cs/dssi4cs.c | 29 +- 7.x/src/dssi4cs/tests/dssi_test.cpp | 432 ++++++++++++++++++ .../dssi4cs/tests/fixtures/dssi_test_plugin.c | 195 ++++++++ 6 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 7.x/src/dssi4cs/tests/dssi_test.cpp create mode 100644 7.x/src/dssi4cs/tests/fixtures/dssi_test_plugin.c 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..b7b00ec --- /dev/null +++ b/7.x/src/dssi4cs/tests/dssi_test.cpp @@ -0,0 +1,432 @@ +#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(); + 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, 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 From d398d2b25bdc04151e39e83a013486ec532bdcf3 Mon Sep 17 00:00:00 2001 From: vlazzarini Date: Thu, 15 Oct 2026 10:56:33 +0100 Subject: [PATCH 2/2] Cover zero-input dssiaudio and reset audio between runs Address review feedback: exercise dssiaudio with all audio inputs omitted (only dssisynth was covered), guarding the optional-input behaviour, and clear the captured audio at the start of run() so a future test that calls it twice cannot read stale samples. --- 7.x/src/dssi4cs/tests/dssi_test.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/7.x/src/dssi4cs/tests/dssi_test.cpp b/7.x/src/dssi4cs/tests/dssi_test.cpp index b7b00ec..0733dd2 100644 --- a/7.x/src/dssi4cs/tests/dssi_test.cpp +++ b/7.x/src/dssi4cs/tests/dssi_test.cpp @@ -34,6 +34,7 @@ class DssiTests : public ::testing::Test { } 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 + @@ -234,6 +235,19 @@ endin 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);