diff --git a/src/plugins/score-lib-process/Process/Dataflow/Cable.cpp b/src/plugins/score-lib-process/Process/Dataflow/Cable.cpp index 94fb2720da..62963ab70a 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/Cable.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/Cable.cpp @@ -49,6 +49,11 @@ CableData Cable::toCableData() const return c; } +bool operator==(const CableData& lhs, const CableData& rhs) +{ + return lhs.type == rhs.type && lhs.source == rhs.source && lhs.sink == rhs.sink; +} + CableType Cable::type() const { return m_type; diff --git a/src/plugins/score-lib-process/Process/Dataflow/Port.cpp b/src/plugins/score-lib-process/Process/Dataflow/Port.cpp index 1f6d88ad1f..c57224f27a 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/Port.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/Port.cpp @@ -337,7 +337,7 @@ void ControlInlet::loadData(const QByteArray& arr, PortLoadDataFlags flags) noex bool has_value{}; op >> m_cables >> m_address >> has_value; - if(!((uint32_t)flags & (uint32_t)PortLoadDataFlags::DontReloadValue)) + if(!((uint32_t)flags & (uint32_t)PortLoadDataFlags::ReloadValue)) has_value = false; if(has_value) diff --git a/src/plugins/score-lib-process/Process/Dataflow/PortType.hpp b/src/plugins/score-lib-process/Process/Dataflow/PortType.hpp index 1514642eab..3b002a688b 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/PortType.hpp +++ b/src/plugins/score-lib-process/Process/Dataflow/PortType.hpp @@ -25,7 +25,7 @@ enum class PortType enum class PortLoadDataFlags { NoFlag = 0, - DontReloadValue = (1 << 0) + ReloadValue = (1 << 0) }; SCORE_LIB_PROCESS_EXPORT diff --git a/src/plugins/score-plugin-analysis/Analysis/GistState.hpp b/src/plugins/score-plugin-analysis/Analysis/GistState.hpp index 8e4315f2a3..279cfc083a 100644 --- a/src/plugins/score-plugin-analysis/Analysis/GistState.hpp +++ b/src/plugins/score-plugin-analysis/Analysis/GistState.hpp @@ -128,7 +128,7 @@ struct GistState if(g1.getAudioFrameSize() != samples) g1.setAudioFrameSize(samples); - g1.processAudioFrame(data(c0), samples); + g1.processAudioFrame(data(c1), samples); ret[1] = (g1.*Func)(); } } @@ -300,7 +300,7 @@ struct GistState if(g1.getAudioFrameSize() != samples) g1.setAudioFrameSize(samples); - g1.processAudioFrame(data(c0), samples, gain, gate); + g1.processAudioFrame(data(c1), samples, gain, gate); ret[1] = (g1.*Func)(); } } diff --git a/src/plugins/score-plugin-curve/Curve/Segment/EasingSegment.hpp b/src/plugins/score-plugin-curve/Curve/Segment/EasingSegment.hpp index 77aab7e234..bd08813438 100644 --- a/src/plugins/score-plugin-curve/Curve/Segment/EasingSegment.hpp +++ b/src/plugins/score-plugin-curve/Curve/Segment/EasingSegment.hpp @@ -74,9 +74,8 @@ class EasingSegment final : public ::Curve::SegmentModel double valueAt(double x) const override { - return start().y() - + (end().y() - start().y()) * (Easing_T{}(x)-start().x()) - / (end().x() - start().x()); + const double ratio = (x - start().x()) / (end().x() - start().x()); + return start().y() + (end().y() - start().y()) * Easing_T{}(ratio); } std::optional verticalParameter() const override { return {}; } @@ -160,7 +159,7 @@ inline void JSONWriter::write(Curve::EasingData& segmt) { } -SCORE_SERIALIZE_DATASTREAM_DECLARE(, Curve::EasingData) +SCORE_SERIALIZE_DATASTREAM_DECLARE(SCORE_PLUGIN_CURVE_EXPORT, Curve::EasingData) Q_DECLARE_METATYPE(Curve::EasingData) W_REGISTER_ARGTYPE(Curve::EasingData) diff --git a/src/plugins/score-plugin-curve/Curve/Segment/Power/PowerSegment.cpp b/src/plugins/score-plugin-curve/Curve/Segment/Power/PowerSegment.cpp index 094c9d9dd9..f40658d038 100644 --- a/src/plugins/score-plugin-curve/Curve/Segment/Power/PowerSegment.cpp +++ b/src/plugins/score-plugin-curve/Curve/Segment/Power/PowerSegment.cpp @@ -115,9 +115,8 @@ double PowerSegment::valueAt(double x) const } else { - return start().y() - + (end().y() - start().y()) * (std::pow(x, gamma) - start().x()) - / (end().x() - start().x()); + const double ratio = (x - start().x()) / (end().x() - start().x()); + return start().y() + (end().y() - start().y()) * std::pow(ratio, gamma); } } diff --git a/src/plugins/score-plugin-curve/Curve/Segment/Power/PowerSegment.hpp b/src/plugins/score-plugin-curve/Curve/Segment/Power/PowerSegment.hpp index e332ebf961..98e3ce5dd1 100644 --- a/src/plugins/score-plugin-curve/Curve/Segment/Power/PowerSegment.hpp +++ b/src/plugins/score-plugin-curve/Curve/Segment/Power/PowerSegment.hpp @@ -81,6 +81,6 @@ SCORE_PLUGIN_CURVE_EXPORT Curve::SegmentData flatCurveSegment(double val, double min, double max); } -SCORE_SERIALIZE_DATASTREAM_DECLARE(, Curve::PowerSegmentData) +SCORE_SERIALIZE_DATASTREAM_DECLARE(SCORE_PLUGIN_CURVE_EXPORT, Curve::PowerSegmentData) Q_DECLARE_METATYPE(Curve::PowerSegmentData) W_REGISTER_ARGTYPE(Curve::PowerSegmentData) diff --git a/src/plugins/score-plugin-fx/Fx/Envelope.hpp b/src/plugins/score-plugin-fx/Fx/Envelope.hpp index e9675e7f27..ee94f5a841 100644 --- a/src/plugins/score-plugin-fx/Fx/Envelope.hpp +++ b/src/plugins/score-plugin-fx/Fx/Envelope.hpp @@ -44,8 +44,7 @@ struct Node max = std::max(max, std::abs(sample)); rms += sample * sample; } - rms = std::sqrt(rms); - rms /= chan.size(); + rms = std::sqrt(rms / chan.size()); return std::make_pair(rms, max); } diff --git a/src/plugins/score-plugin-fx/Fx/MathAudioFilter.hpp b/src/plugins/score-plugin-fx/Fx/MathAudioFilter.hpp index abeb7501b9..59eb322486 100644 --- a/src/plugins/score-plugin-fx/Fx/MathAudioFilter.hpp +++ b/src/plugins/score-plugin-fx/Fx/MathAudioFilter.hpp @@ -154,7 +154,7 @@ struct Node { channels[j][i] = self.cur_out[j]; } - std::swap(self.cur_in, self.prev_in); + self.prev_in = self.cur_in; } self.pa = self.a; self.pb = self.b; diff --git a/src/plugins/score-plugin-scenario/Scenario/Commands/LoadPresetCommand.hpp b/src/plugins/score-plugin-scenario/Scenario/Commands/LoadPresetCommand.hpp index 450769d3bb..890c6e04f1 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Commands/LoadPresetCommand.hpp +++ b/src/plugins/score-plugin-scenario/Scenario/Commands/LoadPresetCommand.hpp @@ -122,7 +122,7 @@ class LoadPresetWithCablesBackup final : public score::Command auto cables = Dataflow::reloadPortsInNewProcess( m_oldInlets, m_oldOutlets, m_oldCables, cmt, - Process::PortLoadDataFlags::DontReloadValue, ctx); + Process::PortLoadDataFlags::ReloadValue, ctx); cmt.inletsChanged(); cmt.outletsChanged(); diff --git a/tests/unit/AnalysisGistTest.cpp b/tests/unit/AnalysisGistTest.cpp new file mode 100644 index 0000000000..0763c76f72 --- /dev/null +++ b/tests/unit/AnalysisGistTest.cpp @@ -0,0 +1,460 @@ +#include +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include + +using Catch::Approx; + +namespace +{ +constexpr double INV_SQRT2 = 0.70710678118654752440; + +struct MultiBuf +{ + std::vector> ch; + std::vector ptrs; + + Analysis::audio_in bus() + { + ptrs.clear(); + for(auto& c : ch) + ptrs.push_back(c.data()); + Analysis::audio_in b; + b.samples = ptrs.data(); + b.channels = (int)ptrs.size(); + return b; + } +}; + +std::vector make_sine(int n, double amp, double cycles, double shift = 0.5) +{ + std::vector v(n); + for(int i = 0; i < n; i++) + v[i] = amp * std::sin(2.0 * M_PI * cycles * (i + shift) / n); + return v; +} + +std::vector gist_hann(int n) +{ + std::vector w(n); + for(int i = 0; i < n; i++) + w[i] = 0.5 * (1.0 - std::cos(2.0 * M_PI * (double(i) / double(n - 1)))); + return w; +} + +// Reference DFT magnitude of the windowed frame at one bin. +double ref_dft_mag(const std::vector& x, const std::vector& w, int bin) +{ + const int n = (int)x.size(); + std::complex acc{}; + for(int i = 0; i < n; i++) + { + const double ph = -2.0 * M_PI * double(bin) * double(i) / double(n); + acc += x[i] * w[i] * std::complex{std::cos(ph), std::sin(ph)}; + } + return std::abs(acc); +} + +struct PulseCounter +{ + int count = 0; + void operator()() { ++count; } +}; + +float mono(const Analysis::value_out& out) +{ + auto* f = ossia::get_if(&out.value); + REQUIRE(f); + return *f; +} + +std::array stereo(const Analysis::value_out& out) +{ + auto* a = ossia::get_if>(&out.value); + REQUIRE(a); + return *a; +} +} + +TEST_CASE( + "GistState: RMS, peak and zero-crossings of a known sine (mono)", + "[analysis][gist][envelope]") +{ + constexpr int N = 512, FS = 48000; + constexpr double A = 0.5, K = 8; // 8 cycles per frame = 750 Hz + + MultiBuf buf; + buf.ch.push_back(make_sine(N, A, K)); + auto bus = buf.bus(); + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + + // RMS over an integer number of periods is exactly A/sqrt(2). + st.process<&Gist::rootMeanSquare>(bus, 1.f, 0.f, out, N); + CHECK(mono(out) == Approx(A * INV_SQRT2).margin(1e-6)); + + // Peak energy is the max absolute sample of the frame. + double expected_peak = 0; + for(double s : buf.ch[0]) + expected_peak = std::max(expected_peak, std::abs(s)); + st.process<&Gist::peakEnergy>(bus, 1.f, 0.f, out, N); + CHECK(mono(out) == Approx(expected_peak).margin(1e-6)); + + st.process<&Gist::zeroCrossingRate>(bus, 1.f, 0.f, out, N); + CHECK(mono(out) == Approx(15.0).margin(1e-9)); +} + +TEST_CASE( + "GistState: gain scales and gate mutes the analyzed signal", + "[analysis][gist][envelope]") +{ + constexpr int N = 512, FS = 48000; + constexpr double A = 0.5; + + MultiBuf buf; + buf.ch.push_back(make_sine(N, A, 8)); + auto bus = buf.bus(); + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + + // gain 2 doubles the RMS + st.process<&Gist::rootMeanSquare>(bus, 2.f, 0.f, out, N); + CHECK(mono(out) == Approx(2.0 * A * INV_SQRT2).margin(1e-6)); + + // A gate above the (post-gain) peak zeroes every sample. + st.process<&Gist::rootMeanSquare>(bus, 1.f, 0.6f, out, N); + CHECK(mono(out) == Approx(0.0).margin(1e-12)); + st.process<&Gist::peakEnergy>(bus, 1.f, 0.6f, out, N); + CHECK(mono(out) == Approx(0.0).margin(1e-12)); +} + +TEST_CASE( + "Gist FFT: sine magnitude peaks in its bin, DC in bin 0, values match a " + "reference DFT", + "[analysis][gist][fft]") +{ + constexpr int N = 512, FS = 48000; + constexpr double A = 1.0; + constexpr int K = 32; // bin-centered tone: 3 kHz at 48 kHz / 512 + + const auto w = gist_hann(N); + + SECTION("bin-centered sine") + { + const auto x = make_sine(N, A, K, 0.0); + + Gist g{N, FS}; + g.processAudioFrame(x.data(), N); + const auto& mag = g.getMagnitudeSpectrum(); + REQUIRE((int)mag.size() == N / 2); + + // argmax lands exactly on bin K + int argmax = 0; + for(int i = 1; i < N / 2; i++) + if(mag[i] > mag[argmax]) + argmax = i; + CHECK(argmax == K); + + // Hann window: adjacent bins carry ~half the peak + CHECK(mag[K - 1] / mag[K] == Approx(0.5).margin(0.05)); + CHECK(mag[K + 1] / mag[K] == Approx(0.5).margin(0.05)); + + // Away from the tone the spectrum is (near) empty + for(int b : {0, 8, 16, 64, 128, 255}) + CHECK(mag[b] < 0.02 * mag[K]); + + // Exact values against the reference DFT of the windowed frame + for(int b : {0, 16, 31, 32, 33, 100, 255}) + CHECK(mag[b] == Approx(ref_dft_mag(x, w, b)).margin(1e-6 * N).epsilon(1e-7)); + } + + SECTION("DC goes to bin 0") + { + std::vector x(N, 0.7); + + Gist g{N, FS}; + g.processAudioFrame(x.data(), N); + const auto& mag = g.getMagnitudeSpectrum(); + + int argmax = 0; + for(int i = 1; i < N / 2; i++) + if(mag[i] > mag[argmax]) + argmax = i; + CHECK(argmax == 0); + + // |X[0]| of a windowed constant = level * sum(window) + double wsum = 0; + for(double v : w) + wsum += v; + CHECK(mag[0] == Approx(0.7 * wsum).epsilon(1e-9)); + } +} + +TEST_CASE( + "GistState: spectral centroid of a pure tone lands on its bin index", + "[analysis][gist][spectral]") +{ + constexpr int N = 512, FS = 48000; + constexpr int K = 32; + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + + MultiBuf buf; + buf.ch.push_back(make_sine(N, 1.0, K, 0.0)); + auto bus = buf.bus(); + + // NB: Gist's spectralCentroid is expressed in *bins*, not Hz. + st.process<&Gist::spectralCentroid>(bus, 1.f, 0.f, out, N); + CHECK(mono(out) == Approx((double)K).margin(1.0)); + + // DC: all the energy sits at the bottom of the spectrum. + MultiBuf dc; + dc.ch.push_back(std::vector(N, 1.0)); + auto dcbus = dc.bus(); + st.process<&Gist::spectralCentroid>(dcbus, 1.f, 0.f, out, N); + CHECK(mono(out) < 1.0); +} + +TEST_CASE( + "GistState: spectral flatness and crest separate tones from flat spectra", + "[analysis][gist][spectral]") +{ + constexpr int N = 512, FS = 48000; + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + + MultiBuf delta; + delta.ch.push_back(std::vector(N, 0.0)); + delta.ch[0][N / 2] = 1.0; + auto dbus = delta.bus(); + + st.process<&Gist::spectralFlatness>(dbus, 1.f, 0.f, out, N); + CHECK(mono(out) == Approx(1.0).margin(1e-6)); + st.process<&Gist::spectralCrest>(dbus, 1.f, 0.f, out, N); + CHECK(mono(out) == Approx(1.0).margin(1e-6)); + + // A pure tone concentrates its energy: low flatness, high crest. + MultiBuf tone; + tone.ch.push_back(make_sine(N, 1.0, 32, 0.0)); + auto tbus = tone.bus(); + + st.process<&Gist::spectralFlatness>(tbus, 1.f, 0.f, out, N); + CHECK(mono(out) < 0.7); + st.process<&Gist::spectralCrest>(tbus, 1.f, 0.f, out, N); + CHECK(mono(out) > 20.0); +} + +TEST_CASE("GistState: Yin pitch tracking of a 440 Hz sine", "[analysis][gist][pitch]") +{ + constexpr int N = 2048, FS = 48000; + constexpr double F = 440.0; + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + + MultiBuf buf; + buf.ch.push_back(make_sine(N, 0.8, F * N / FS)); + auto bus = buf.bus(); + + // Same call the Pitch node makes (the no-gain process overload). + st.process<&Gist::pitch>(bus, out, N); + CHECK(mono(out) == Approx(F).margin(5.0)); +} + +TEST_CASE( + "GistState: stereo gain/gate analysis reports per-channel values", + "[analysis][gist][stereo]") +{ + constexpr int N = 512, FS = 48000; + + MultiBuf buf; + buf.ch.push_back(make_sine(N, 0.4, 8)); + buf.ch.push_back(make_sine(N, 0.8, 8)); + auto bus = buf.bus(); + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + st.process<&Gist::rootMeanSquare>(bus, 1.f, 0.f, out, N); + + auto v = stereo(out); + CHECK(v[0] == Approx(0.4 * INV_SQRT2).margin(1e-6)); + CHECK(v[1] == Approx(0.8 * INV_SQRT2).margin(1e-6)); +} + +TEST_CASE( + "GistState: stereo no-gain analysis analyzes the right channel", + "[analysis][gist][stereo]") +{ + constexpr int N = 512, FS = 48000; + + MultiBuf buf; + buf.ch.push_back(make_sine(N, 0.4, 8)); + buf.ch.push_back(make_sine(N, 0.8, 8)); + auto bus = buf.bus(); + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + st.process<&Gist::rootMeanSquare>(bus, out, N); + + auto v = stereo(out); + CHECK(v[0] == Approx(0.4 * INV_SQRT2).margin(1e-6)); + CHECK(v[1] == Approx(0.8 * INV_SQRT2).margin(1e-6)); +} + +TEST_CASE( + "GistState: stereo pulse analysis analyzes the right channel", + "[analysis][gist][stereo]") +{ + constexpr int N = 512, FS = 48000; + + MultiBuf buf; + buf.ch.push_back(make_sine(N, 0.4, 8)); + buf.ch.push_back(make_sine(N, 0.8, 8)); + auto bus = buf.bus(); + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + PulseCounter pulse; + st.process<&Gist::rootMeanSquare>(bus, 1.f, 0.f, out, pulse, N); + + auto v = stereo(out); + CHECK(v[0] == Approx(0.4 * INV_SQRT2).margin(1e-6)); + CHECK(v[1] == Approx(0.8 * INV_SQRT2).margin(1e-6)); +} + +TEST_CASE( + "GistState: pulse fires when the tracked value reaches 1", + "[analysis][gist][onset]") +{ + constexpr int N = 512, FS = 48000; + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + + // peak = 2.0 >= 1 -> bang + MultiBuf loud; + loud.ch.push_back(make_sine(N, 2.0, 8)); + auto lbus = loud.bus(); + PulseCounter p1; + st.process<&Gist::peakEnergy>(lbus, 1.f, 0.f, out, p1, N); + CHECK(p1.count == 1); + + // peak = 0.5 < 1 -> no bang + MultiBuf quiet; + quiet.ch.push_back(make_sine(N, 0.5, 8)); + auto qbus = quiet.bus(); + PulseCounter p2; + st.process<&Gist::peakEnergy>(qbus, 1.f, 0.f, out, p2, N); + CHECK(p2.count == 0); +} + +TEST_CASE( + "GistState: 4-channel analysis returns one value per channel", + "[analysis][gist][multichannel]") +{ + constexpr int N = 512, FS = 48000; + const double amps[4] = {0.1, 0.2, 0.3, 0.4}; + + MultiBuf buf; + for(double a : amps) + buf.ch.push_back(make_sine(N, a, 8)); + auto bus = buf.bus(); + + Analysis::GistState st{N, FS}; + Analysis::value_out out; + st.process<&Gist::rootMeanSquare>(bus, 1.f, 0.f, out, N); + + auto* v = ossia::get_if(&out.value); + REQUIRE(v); + REQUIRE(v->size() == 4); + for(int c = 0; c < 4; c++) + CHECK((*v)[c] == Approx(amps[c] * INV_SQRT2).margin(1e-6)); +} + +TEST_CASE( + "EnvelopeFollower: exact half-life step response", + "[analysis][envelope-follower]") +{ + Analysis::EnvelopeFollower env; + halp::setup s{}; + s.rate = 48000; + env.prepare(s); + + Analysis::EnvelopeFollower::inputs ins{}; // defaults: 50 ms up, 15 ms down + REQUIRE(ins.a.value == Approx(50.0)); + REQUIRE(ins.b.value == Approx(15.0)); + + double y = 0; + for(int i = 0; i < 2400; i++) + y = env(1.0, ins, {}); + CHECK(y == Approx(0.5).margin(1e-6)); + + // ... and 0.75 after another 50 ms. + for(int i = 0; i < 2400; i++) + y = env(1.0, ins, {}); + CHECK(y == Approx(0.75).margin(1e-6)); + + // Release: 15 ms half-life. After 720 samples of silence y halves. + for(int i = 0; i < 720; i++) + y = env(0.0, ins, {}); + CHECK(y == Approx(0.375).margin(1e-6)); + + // rate <= 0 resets to zero instead of dividing by zero. + Analysis::EnvelopeFollower dead; + halp::setup zs{}; + zs.rate = 0; + dead.prepare(zs); + CHECK(dead(1.0, ins, {}) == 0.0); +} + +TEST_CASE( + "GistState: hostile buffers (NaN, denormals, empty, resize) do not crash", + "[analysis][gist][fuzz]") +{ + constexpr int N = 512, FS = 48000; + Analysis::GistState st{N, FS}; + Analysis::value_out out; + + // NaN-filled frame + MultiBuf nan_buf; + nan_buf.ch.push_back( + std::vector(N, std::numeric_limits::quiet_NaN())); + auto nbus = nan_buf.bus(); + st.process<&Gist::rootMeanSquare>(nbus, 1.f, 0.f, out, N); + st.process<&Gist::spectralCentroid>(nbus, 1.f, 0.f, out, N); + + // Denormal-filled frame + MultiBuf den; + den.ch.push_back(std::vector(N, 1e-320)); + auto dbus = den.bus(); + st.process<&Gist::rootMeanSquare>(dbus, 1.f, 0.f, out, N); + st.process<&Gist::spectralFlatness>(dbus, 1.f, 0.f, out, N); + + // Zero channels + MultiBuf empty; + auto ebus = empty.bus(); + st.process<&Gist::rootMeanSquare>(ebus, 1.f, 0.f, out, N); + + // Frame-size change re-inits the Gist FFT on the fly; values stay correct. + MultiBuf small; + small.ch.push_back(make_sine(256, 0.5, 4)); + auto sbus = small.bus(); + st.process<&Gist::rootMeanSquare>(sbus, 1.f, 0.f, out, 256); + CHECK(mono(out) == Approx(0.5 * INV_SQRT2).margin(1e-6)); + + SUCCEED("no crash on hostile input"); +} diff --git a/tests/unit/AudioGainPanTest.cpp b/tests/unit/AudioGainPanTest.cpp new file mode 100644 index 0000000000..be81041692 --- /dev/null +++ b/tests/unit/AudioGainPanTest.cpp @@ -0,0 +1,250 @@ +#include +#include + +#include + +#include +#include + +namespace +{ +void fill(ossia::audio_channel& chan, std::initializer_list vals) +{ + chan.assign(vals.begin(), vals.end()); +} +} + +TEST_CASE("audio_outlet::post_process applies the outlet gain to a mono channel", "[audio][gain]") +{ + ossia::audio_outlet out; + out->set_channels(1); + fill(out->channel(0), {1., -2., 0.5, 0., 1e-30}); + + SECTION("gain 0.5 halves every sample, in place") + { + out.gain = 0.5; + out.post_process(); + CHECK(out->channel(0)[0] == 0.5); + CHECK(out->channel(0)[1] == -1.0); + CHECK(out->channel(0)[2] == 0.25); + CHECK(out->channel(0)[3] == 0.0); + CHECK(out->channel(0)[4] == 0.5e-30); + } + + SECTION("unity gain leaves samples bit-identical (fast path)") + { + out.gain = 1.; + out.post_process(); + CHECK(out->channel(0)[0] == 1.); + CHECK(out->channel(0)[1] == -2.); + CHECK(out->channel(0)[2] == 0.5); + } + + SECTION("zero gain silences everything") + { + out.gain = 0.; + out.post_process(); + for(auto s : out->channel(0)) + CHECK(s == 0.0); + } + + SECTION("NaN samples do not crash the gain stage") + { + out->channel(0)[1] = std::numeric_limits::quiet_NaN(); + out.gain = 2.; + out.post_process(); + CHECK(out->channel(0)[0] == 2.); + CHECK(std::isnan(out->channel(0)[1])); + } +} + +TEST_CASE("audio_outlet::post_process applies per-channel pan * gain on stereo", "[audio][pan]") +{ + ossia::audio_outlet out; + out->set_channels(2); + fill(out->channel(0), {1., 2., 3., 4.}); + fill(out->channel(1), {4., 3., 2., 1.}); + + SECTION("default pan {1,1} + unity gain: passthrough") + { + out.post_process(); + CHECK(out->channel(0)[0] == 1.); + CHECK(out->channel(1)[0] == 4.); + } + + SECTION("pan weights scale each channel independently") + { + out.pan = {0.25, 0.75}; + out.gain = 1.; + out.post_process(); + for(int i = 0; i < 4; i++) + { + CHECK(out->channel(0)[i] == (i + 1.) * 0.25); + CHECK(out->channel(1)[i] == (4. - i) * 0.75); + } + } + + SECTION("gain multiplies on top of the pan weights: vol = pan[c] * gain") + { + out.pan = {0.25, 0.75}; + out.gain = 2.; + out.post_process(); + for(int i = 0; i < 4; i++) + { + CHECK(out->channel(0)[i] == (i + 1.) * 0.5); + CHECK(out->channel(1)[i] == (4. - i) * 1.5); + } + } + + SECTION("hard-left pan {1,0} silences the right channel exactly") + { + out.pan = {1., 0.}; + out.post_process(); + for(int i = 0; i < 4; i++) + { + CHECK(out->channel(0)[i] == i + 1.); + CHECK(out->channel(1)[i] == 0.0); + } + } +} + +TEST_CASE("audio_outlet::post_process extends the pan vector for >2 channels", "[audio][pan]") +{ + ossia::audio_outlet out; + out->set_channels(4); + for(std::size_t c = 0; c < 4; c++) + fill(out->channel(c), {1., 1.}); + + out.pan = {0.5, 0.25}; // channels 2,3 have no explicit pan weight + out.gain = 2.; + out.post_process(); + + // pan is auto-extended with 1. for the extra channels + REQUIRE(out.pan.size() == 4); + CHECK(out.pan[2] == 1.); + CHECK(out.pan[3] == 1.); + CHECK(out->channel(0)[0] == 1.0); // 1 * 0.5 * 2 + CHECK(out->channel(1)[0] == 0.5); // 1 * 0.25 * 2 + CHECK(out->channel(2)[0] == 2.0); // 1 * 1 * 2 + CHECK(out->channel(3)[0] == 2.0); +} + +TEST_CASE("audio_outlet::post_process reads the gain from the gain inlet messages", "[audio][gain]") +{ + ossia::audio_outlet out; + out->set_channels(1); + fill(out->channel(0), {1., 2.}); + + out.gain = 1.; + (*out.gain_inlet).write_value(0.25f, 0); + out.post_process(); + + // The last gain message overrides the member before processing… + CHECK(out.gain == 0.25); + CHECK(out->channel(0)[0] == 0.25); + CHECK(out->channel(0)[1] == 0.5); +} + +TEST_CASE("audio_outlet::post_process edge cases", "[audio][gain][fuzz]") +{ + SECTION("no channels at all: early return, no crash") + { + ossia::audio_outlet out; + out->set_channels(0); + out.gain = 0.5; + out.post_process(); + CHECK(out->channels() == 0); + } + + SECTION("zero-length channels: no crash") + { + ossia::audio_outlet out; + out->set_channels(2); + out.gain = 0.5; + out.pan = {0.5, 0.5}; + out.post_process(); + CHECK(out->channel(0).empty()); + } + + SECTION("single-sample channel") + { + ossia::audio_outlet out; + out->set_channels(1); + fill(out->channel(0), {2.}); + out.gain = 0.25; + out.post_process(); + CHECK(out->channel(0)[0] == 0.5); + } +} + +TEST_CASE("ossia::mix is a sample-exact summing bus", "[audio][mix]") +{ + ossia::audio_port src, sink; + + SECTION("mix into an empty sink copies the source") + { + src.set_channels(2); + fill(src.channel(0), {1., 2.}); + fill(src.channel(1), {3., 4.}); + sink.set_channels(0); + + ossia::mix(src.get(), sink.get()); + REQUIRE(sink.channels() == 2); + CHECK(sink.channel(0)[0] == 1.); + CHECK(sink.channel(0)[1] == 2.); + CHECK(sink.channel(1)[0] == 3.); + CHECK(sink.channel(1)[1] == 4.); + } + + SECTION("mix into a matching sink adds sample-wise") + { + src.set_channels(2); + fill(src.channel(0), {1., 2.}); + fill(src.channel(1), {3., 4.}); + sink.set_channels(2); + fill(sink.channel(0), {10., 20.}); + fill(sink.channel(1), {30., 40.}); + + ossia::mix(src.get(), sink.get()); + CHECK(sink.channel(0)[0] == 11.); + CHECK(sink.channel(0)[1] == 22.); + CHECK(sink.channel(1)[0] == 33.); + CHECK(sink.channel(1)[1] == 44.); + } + + SECTION("two sources summed = the sum of both (mixing is associative)") + { + src.set_channels(1); + fill(src.channel(0), {1., -1.}); + ossia::audio_port src2; + src2.set_channels(1); + fill(src2.channel(0), {0.5, 0.5}); + sink.set_channels(1); + fill(sink.channel(0), {0., 0.}); + + ossia::mix(src.get(), sink.get()); + ossia::mix(src2.get(), sink.get()); + CHECK(sink.channel(0)[0] == 1.5); + CHECK(sink.channel(0)[1] == -0.5); + } + + SECTION("sink shorter than source is grown, samples preserved") + { + src.set_channels(1); + fill(src.channel(0), {1., 2., 3.}); + sink.set_channels(1); + fill(sink.channel(0), {10.}); + + ossia::mix(src.get(), sink.get()); + REQUIRE(sink.channel(0).size() == 3); + CHECK(sink.channel(0)[0] == 11.); + CHECK(sink.channel(0)[1] == 2.); + CHECK(sink.channel(0)[2] == 3.); + } + + SECTION("empty source into empty sink: no crash") + { + ossia::mix(src.get(), sink.get()); + SUCCEED("no crash"); + } +} diff --git a/tests/unit/AutomationValueTest.cpp b/tests/unit/AutomationValueTest.cpp new file mode 100644 index 0000000000..62b800549a --- /dev/null +++ b/tests/unit/AutomationValueTest.cpp @@ -0,0 +1,212 @@ +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +using Catch::Approx; + +namespace +{ +// Mirror of Automation::RecreateOnPlay::Component::on_curveChanged_impl. +std::shared_ptr> +make_execution_curve(const Automation::ProcessModel& proc) +{ + const double min = proc.min(); + const double max = proc.max(); + + auto scale_x = [](double val) -> double { return val; }; + auto scale_y = [=](double val) -> float { return val * (max - min) + min; }; + + auto segt_data = proc.curve().sortedSegments(); + REQUIRE(!segt_data.empty()); + return Engine::score_to_ossia::curve(scale_x, scale_y, segt_data, {}); +} +} + +TEST_CASE("Automation default curve scales into [min,max]", "[automation][values]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto doc = score::test::new_document(ctx); + REQUIRE(doc); + QObject* owner = new QObject{&doc->model()}; + + Automation::ProcessModel proc{ + TimeVal::fromMsecs(1000.), Id{1}, owner}; + + // Fresh automation: a single default (power, linear-gamma) segment 0 -> 1. + REQUIRE(proc.curve().segments().size() == 1); + CHECK(proc.min() == 0.); + CHECK(proc.max() == 1.); + + proc.setMin(2.); + proc.setMax(6.); + CHECK(proc.min() == 2.); + CHECK(proc.max() == 6.); + + auto curve = make_execution_curve(proc); + // out(t) = t * (6 - 2) + 2 + CHECK(curve->value_at(0.) == Approx(2.f).margin(1e-6)); + CHECK(curve->value_at(0.25) == Approx(3.f).margin(1e-6)); + CHECK(curve->value_at(0.5) == Approx(4.f).margin(1e-6)); + CHECK(curve->value_at(0.75) == Approx(5.f).margin(1e-6)); + CHECK(curve->value_at(1.) == Approx(6.f).margin(1e-6)); + + // Inverted domain: min > max still follows the same affine formula. + proc.setMin(10.); + proc.setMax(0.); + auto inv = make_execution_curve(proc); + CHECK(inv->value_at(0.25) == Approx(7.5f).margin(1e-6)); + }); +} + +TEST_CASE("Automation multi-segment curve evaluates piecewise and clamps", "[automation][values]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto doc = score::test::new_document(ctx); + REQUIRE(doc); + QObject* owner = new QObject{&doc->model()}; + + Automation::ProcessModel proc{ + TimeVal::fromMsecs(1000.), Id{1}, owner}; + proc.setMin(0.); + proc.setMax(10.); + + // Triangle: 0 -> 1 on [0, 0.5], 1 -> 0 on [0.5, 1]. + auto& cm = proc.curve(); + cm.clear(); + auto up = new Curve::LinearSegment{Id{1}, &cm}; + up->setStart({0., 0.}); + up->setEnd({0.5, 1.}); + auto down = new Curve::LinearSegment{Id{2}, &cm}; + down->setStart({0.5, 1.}); + down->setEnd({1., 0.}); + cm.addSegment(up); + cm.addSegment(down); + + auto curve = make_execution_curve(proc); + CHECK(curve->value_at(0.) == Approx(0.f).margin(1e-6)); + CHECK(curve->value_at(0.25) == Approx(5.f).margin(1e-6)); + CHECK(curve->value_at(0.5) == Approx(10.f).margin(1e-6)); + CHECK(curve->value_at(0.75) == Approx(5.f).margin(1e-6)); + CHECK(curve->value_at(1.) == Approx(0.f).margin(1e-6)); + + // Out-of-domain t clamps to the curve boundary values. + CHECK(curve->value_at(-0.5) == Approx(0.f).margin(1e-6)); // y0 + CHECK(curve->value_at(1.5) == Approx(0.f).margin(1e-6)); // last point + CHECK(curve->value_at(0.5000001) == Approx(10.f).margin(1e-4)); + + // Power curve: gamma 2 on [0,1] with domain [0, 8] => out(t) = 8 t^2. + cm.clear(); + auto pw = new Curve::PowerSegment{Id{3}, &cm}; + pw->setStart({0., 0.}); + pw->setEnd({1., 1.}); + pw->gamma = 2.; + cm.addSegment(pw); + proc.setMax(8.); + + auto pcurve = make_execution_curve(proc); + CHECK(pcurve->value_at(0.5) == Approx(2.f).margin(1e-6)); + CHECK(pcurve->value_at(0.25) == Approx(0.5f).margin(1e-6)); + CHECK(pcurve->value_at(1.) == Approx(8.f).margin(1e-6)); + + // Empty curve: execution has no behavior to build (guarded upstream). + cm.clear(); + CHECK(proc.curve().sortedSegments().empty()); + }); +} + +TEST_CASE("Automation serialization round-trips values, domain and address", "[automation][serialization]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto doc = score::test::new_document(ctx); + REQUIRE(doc); + QObject* owner = new QObject{&doc->model()}; + + Automation::ProcessModel proc{ + TimeVal::fromMsecs(1000.), Id{1}, owner}; + proc.setMin(-1.); + proc.setMax(1.); + proc.setTween(true); + + auto addr = State::Address::fromString("dev:/out"); + REQUIRE(addr); + proc.setAddress(State::AddressAccessor{*addr}); + + auto& cm = proc.curve(); + cm.clear(); + auto pw = new Curve::PowerSegment{Id{1}, &cm}; + pw->setStart({0., 0.}); + pw->setEnd({1., 1.}); + pw->gamma = 2.; + cm.addSegment(pw); + + auto& pl = ctx.interfaces(); + const Process::ProcessModel& as_base = proc; + + const auto check_clone = [&](Process::ProcessModel* clone) { + REQUIRE(clone); + auto autom = dynamic_cast(clone); + REQUIRE(autom); + + CHECK(autom->min() == Approx(-1.).margin(1e-9)); + CHECK(autom->max() == Approx(1.).margin(1e-9)); + CHECK(autom->tween() == true); + CHECK(autom->address() == State::AddressAccessor{*addr}); + + // out(t) = 2 t^2 - 1 must survive the round-trip. + auto curve = make_execution_curve(*autom); + CHECK(curve->value_at(0.) == Approx(-1.f).margin(1e-6)); + CHECK(curve->value_at(0.5) == Approx(-0.5f).margin(1e-6)); + CHECK(curve->value_at(1.) == Approx(1.f).margin(1e-6)); + }; + + // DataStream + { + const QByteArray bytes = score::marshall(as_base); + auto clone = deserialize_interface( + pl, DataStream::Deserializer{bytes}, doc->context(), owner); + check_clone(clone); + const Process::ProcessModel& clone_base = *clone; + CHECK(score::marshall(clone_base) == bytes); + delete clone; + } + + // JSON + { + JSONReader reader; + reader.readFrom(as_base); + const rapidjson::Document jdoc = toValue(reader); + JSONWriter writer{jdoc}; + auto clone = deserialize_interface(pl, writer, doc->context(), owner); + check_clone(clone); + + JSONReader reader2; + reader2.readFrom(static_cast(*clone)); + CHECK(reader2.toByteArray() == reader.toByteArray()); + delete clone; + } + }); +} diff --git a/tests/unit/AvndAudioEffectsTest.cpp b/tests/unit/AvndAudioEffectsTest.cpp new file mode 100644 index 0000000000..fb66b3dedc --- /dev/null +++ b/tests/unit/AvndAudioEffectsTest.cpp @@ -0,0 +1,338 @@ +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +using Catch::Approx; + +TEST_CASE("ao::Gain scales every sample by exactly the gain value", "[avnd][audio][gain]") +{ + ao::Gain fx; + ao::Gain::inputs in; + + SECTION("unity gain is a bit-exact passthrough") + { + in.gain.value = 1.f; + for(double s : {0., 1., -1., 0.5, 1e-30, -1e-30}) + CHECK(fx(s, in) == s); + } + + SECTION("gain k multiplies exactly") + { + in.gain.value = 0.5f; + CHECK(fx(1.0, in) == 0.5); + CHECK(fx(-2.0, in) == -1.0); + CHECK(fx(0.25, in) == 0.125); + + in.gain.value = 2.5f; + CHECK(fx(1.0, in) == 2.5); + CHECK(fx(-0.5, in) == -1.25); + } + + SECTION("zero gain fully mutes, including denormal input") + { + in.gain.value = 0.f; + CHECK(fx(1.0, in) == 0.0); + CHECK(fx(std::numeric_limits::denorm_min(), in) == 0.0); + } + + SECTION("an impulse through gain k has impulse response {k}") + { + in.gain.value = 0.75f; + // impulse + CHECK(fx(1.0, in) == 0.75); + // and the tail stays exactly zero: the node is memoryless + CHECK(fx(0.0, in) == 0.0); + CHECK(fx(0.0, in) == 0.0); + } + + SECTION("NaN input does not crash and propagates NaN") + { + in.gain.value = 1.f; + CHECK(std::isnan(fx(std::numeric_limits::quiet_NaN(), in))); + } +} + +TEST_CASE("ao::AudioSum sums all input channels into its mono output", "[avnd][audio][sum]") +{ + ao::AudioSum fx; + + static constexpr int N = 8; + std::array c0{1, 2, 3, 4, -1, -2, -3, -4}; + std::array c1{0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5}; + std::array c2{-1, 0, 1, 0, -1, 0, 1, 0}; + std::array ins{c0.data(), c1.data(), c2.data()}; + + std::array out; + out.fill(123.); // poison: the node must overwrite, not accumulate + std::array outs{out.data()}; + fx.outputs.audio.samples = outs.data(); + + SECTION("three channels: out = c0 + c1 + c2, exact") + { + fx.inputs.audio.samples = ins.data(); + fx.inputs.audio.channels = 3; + fx(N); + for(int i = 0; i < N; i++) + CHECK(out[i] == c0[i] + c1[i] + c2[i]); + } + + SECTION("one channel: passthrough") + { + fx.inputs.audio.samples = ins.data(); + fx.inputs.audio.channels = 1; + fx(N); + for(int i = 0; i < N; i++) + CHECK(out[i] == c0[i]); + } + + SECTION("zero input channels: output is cleared to exactly 0") + { + fx.inputs.audio.samples = nullptr; + fx.inputs.audio.channels = 0; + fx(N); + for(int i = 0; i < N; i++) + CHECK(out[i] == 0.0); + } + + SECTION("zero-length buffer does not crash and writes nothing") + { + fx.inputs.audio.samples = ins.data(); + fx.inputs.audio.channels = 3; + fx(0); + CHECK(out[0] == 123.); // untouched + } + + SECTION("single-sample buffer") + { + fx.inputs.audio.samples = ins.data(); + fx.inputs.audio.channels = 2; + fx(1); + CHECK(out[0] == c0[0] + c1[0]); + CHECK(out[1] == 123.); // rest untouched + } +} + +TEST_CASE("ao::MonoMix computes the exact gain-weighted sum of all bus channels", "[avnd][audio][mix]") +{ + ao::MonoMix fx; + + static constexpr int N = 4; + // Bus 0: stereo + std::array b0l{1, 2, 3, 4}; + std::array b0r{10, 20, 30, 40}; + std::array b0{b0l.data(), b0r.data()}; + // Bus 1: mono + std::array b1m{-4, -3, -2, -1}; + std::array b1{b1m.data()}; + + fx.inputs.c0.samples = b0.data(); + fx.inputs.c0.channels = 2; + fx.inputs.c1.samples = b1.data(); + fx.inputs.c1.channels = 1; + // c2..c7 stay {nullptr, 0} + + std::array out; + out.fill(-99.); + std::array outs{out.data()}; + fx.outputs.audio.samples = outs.data(); + + SECTION("per-bus gains weight every channel of that bus") + { + fx.inputs.g0.value = 0.5f; + fx.inputs.g1.value = 0.25f; + fx(N); + for(int i = 0; i < N; i++) + { + const double expected + = (b0l[i] + b0r[i]) * double(0.5f) + b1m[i] * double(0.25f); + CHECK(out[i] == Approx(expected).margin(0)); + } + } + + SECTION("all gains zero: exact silence") + { + fx.inputs.g0.value = 0.f; + fx.inputs.g1.value = 0.f; + fx(N); + for(int i = 0; i < N; i++) + CHECK(out[i] == 0.0); + } + + SECTION("no connected bus at all: output cleared") + { + ao::MonoMix empty; + empty.outputs.audio.samples = outs.data(); + out.fill(7.); + empty(N); + for(int i = 0; i < N; i++) + CHECK(out[i] == 0.0); + } + + SECTION("zero frames: no crash, nothing written") + { + fx.inputs.g0.value = 1.f; + fx(0); + CHECK(out[0] == -99.); + } + + SECTION("NaN input does not crash; NaN propagates to the mix") + { + b1m[2] = std::numeric_limits::quiet_NaN(); + fx.inputs.g0.value = 1.f; + fx.inputs.g1.value = 1.f; + fx(N); + CHECK(std::isnan(out[2])); + CHECK(out[0] == b0l[0] + b0r[0] + b1m[0]); + } +} + +TEST_CASE("ao::StereoMixer applies the linear pan law lp=g*(1-p), rp=g*p", "[avnd][audio][pan]") +{ + ao::StereoMixer fx; + + static constexpr int N = 4; + std::array mono{1., -0.5, 0.25, 2.}; + std::array in_mono{mono.data()}; + + std::array outl, outr; + std::array outs{outl.data(), outr.data()}; + fx.outputs.audio.samples = outs.data(); + + // Only bus 0 connected by default. + fx.inputs.a0.samples = in_mono.data(); + fx.inputs.a0.channels = 1; + // gains/pans for unconnected buses are irrelevant (channels == 0 -> skip) + + const auto run = [&](float gain, float pan) { + fx.inputs.c0g.value = gain; + fx.inputs.c0p.value = pan; + fx(N); + }; + + SECTION("hard left (pan = 0): all signal to L, R exactly silent") + { + run(1.f, 0.f); + for(int i = 0; i < N; i++) + { + CHECK(outl[i] == mono[i]); + CHECK(outr[i] == 0.0); + } + } + + SECTION("hard right (pan = 1): all signal to R, L exactly silent") + { + run(1.f, 1.f); + for(int i = 0; i < N; i++) + { + CHECK(outl[i] == 0.0); + CHECK(outr[i] == mono[i]); + } + } + + SECTION("center (pan = 0.5): both channels at exactly half amplitude") + { + run(1.f, 0.5f); + for(int i = 0; i < N; i++) + { + CHECK(outl[i] == mono[i] * (1. - double(0.5f))); + CHECK(outr[i] == mono[i] * double(0.5f)); + } + } + + SECTION("gain scales both sides of the pan law") + { + run(0.5f, 0.25f); + for(int i = 0; i < N; i++) + { + const double lp = double(0.5f) * (1. - double(0.25f)); + const double rp = double(0.5f) * double(0.25f); + CHECK(outl[i] == Approx(mono[i] * lp).margin(0)); + CHECK(outr[i] == Approx(mono[i] * rp).margin(0)); + } + } + + SECTION("stereo input: L->left path, R->right path, separately panned") + { + std::array l{1, 2, 3, 4}, r{8, 6, 4, 2}; + std::array in_st{l.data(), r.data()}; + fx.inputs.a0.samples = in_st.data(); + fx.inputs.a0.channels = 2; + run(1.f, 0.25f); + for(int i = 0; i < N; i++) + { + CHECK(outl[i] == l[i] * (1. - double(0.25f))); + CHECK(outr[i] == r[i] * double(0.25f)); + } + } + + SECTION(">2 channels are downmixed (summed) then panned") + { + std::array c0{1, 1, 1, 1}, c1{2, 2, 2, 2}, c2{3, 3, 3, 3}; + std::array in3{c0.data(), c1.data(), c2.data()}; + fx.inputs.a0.samples = in3.data(); + fx.inputs.a0.channels = 3; + run(1.f, 0.5f); + for(int i = 0; i < N; i++) + { + CHECK(outl[i] == 6. * (1. - double(0.5f))); + CHECK(outr[i] == 6. * double(0.5f)); + } + } + + SECTION("two buses mix additively into the same stereo pair") + { + std::array m2{0.5, 0.5, 0.5, 0.5}; + std::array in2{m2.data()}; + fx.inputs.a1.samples = in2.data(); + fx.inputs.a1.channels = 1; + fx.inputs.c1g.value = 1.f; + fx.inputs.c1p.value = 1.f; // hard right + run(1.f, 0.f); // bus 0 hard left + for(int i = 0; i < N; i++) + { + CHECK(outl[i] == mono[i]); + CHECK(outr[i] == m2[i]); + } + } + + SECTION("zero frames: no crash") + { + run(1.f, 0.5f); + fx(0); + SUCCEED("no crash on empty buffer"); + } +} + +TEST_CASE("ao::Silence requests exactly the configured channel count", "[avnd][audio][silence]") +{ + ao::Silence fx; + int requested = -1; + fx.outputs.audio.request_channels = [&](int n) { + requested = n; + fx.outputs.audio.channels = n; // emulate the host honoring the request + }; + + fx.inputs.channels.value = 5; + fx.prepare(halp::setup{.input_channels = 0, .output_channels = 2, .frames = 64, .rate = 48000.}); + CHECK(requested == 5); + + // Once the host honored the request, the node settles. + requested = -1; + fx(); + CHECK(requested == -1); + + // Changing the control re-requests on the next tick. + fx.inputs.channels.value = 3; + fx(); + CHECK(requested == 3); +} diff --git a/tests/unit/AvndAudioRoundtripTest.cpp b/tests/unit/AvndAudioRoundtripTest.cpp new file mode 100644 index 0000000000..81d07731fc --- /dev/null +++ b/tests/unit/AvndAudioRoundtripTest.cpp @@ -0,0 +1,161 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include + +#include + +namespace +{ +static const auto gain_uuid = UuidKey::fromString( + QStringLiteral("6c158669-0f81-41c9-8cc6-45820dcda867")); +static const auto math_audio_uuid = UuidKey::fromString( + QStringLiteral("13e1f4b0-1c2c-40e6-93ad-dfc91aac5335")); + +std::vector controls(const Process::ProcessModel& p) +{ + std::vector res; + for(auto* inlet : p.inlets()) + if(auto* ctl = qobject_cast(inlet)) + res.push_back(ctl); + return res; +} + +Process::ProcessModel* make_process( + const score::DocumentContext& dctx, const UuidKey& key, + int id, QObject* parent) +{ + auto& pl = dctx.app.interfaces(); + auto* fac = pl.get(key); + if(!fac) + return nullptr; + return fac->make( + TimeVal::fromMsecs(1000), fac->customConstructionData(), + Id{id}, dctx, parent); +} + +Process::ProcessModel* roundtrip_datastream( + const Process::ProcessModel& proc, const score::DocumentContext& dctx, + QObject* parent) +{ + auto& pl = dctx.app.interfaces(); + const QByteArray bytes = DataStreamReader::marshall(proc); + DataStream::Deserializer des{bytes}; + return deserialize_interface(pl, des, dctx, parent); +} + +Process::ProcessModel* roundtrip_json( + const Process::ProcessModel& proc, const score::DocumentContext& dctx, + QObject* parent) +{ + auto& pl = dctx.app.interfaces(); + JSONReader reader; + reader.readFrom(proc); + const auto doc = readJson(reader.toByteArray()); + JSONObject::Deserializer des{doc}; + return deserialize_interface(pl, des, dctx, parent); +} +} + +TEST_CASE("avnd audio process models: controls survive save/load", "[avnd][audio][serialization]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + score::Document* doc = score::test::new_document(ctx); + REQUIRE(doc != nullptr); + auto& dctx = doc->context(); + QObject* parent = &doc->model(); + + // --- ao::Gain -------------------------------------------------------- + { + auto* proc = make_process(dctx, gain_uuid, 4501, parent); + REQUIRE(proc != nullptr); + CHECK(proc->concreteKey() == gain_uuid); + + // ao::Gain has exactly one control: the Gain knob (range 0..5, init 0). + auto ctls = controls(*proc); + REQUIRE(ctls.size() == 1); + ctls[0]->setValue(3.5f); + + // DataStream + { + auto* loaded = roundtrip_datastream(*proc, dctx, parent); + REQUIRE(loaded != nullptr); + CHECK(loaded->concreteKey() == gain_uuid); + CHECK(loaded->inlets().size() == proc->inlets().size()); + CHECK(loaded->outlets().size() == proc->outlets().size()); + + auto loaded_ctls = controls(*loaded); + REQUIRE(loaded_ctls.size() == 1); + CHECK(loaded_ctls[0]->value() == ossia::value{3.5f}); + delete loaded; + } + + // JSON + { + auto* loaded = roundtrip_json(*proc, dctx, parent); + REQUIRE(loaded != nullptr); + CHECK(loaded->concreteKey() == gain_uuid); + + auto loaded_ctls = controls(*loaded); + REQUIRE(loaded_ctls.size() == 1); + CHECK(loaded_ctls[0]->value() == ossia::value{3.5f}); + delete loaded; + } + delete proc; + } + + // --- Nodes::MathAudioFilter ------------------------------------------ + { + auto* proc = make_process(dctx, math_audio_uuid, 4502, parent); + REQUIRE(proc != nullptr); + + // Controls: Expression (line edit), a, b, c. + auto ctls = controls(*proc); + REQUIRE(ctls.size() == 4); + + const std::string expr = "out[0] := x[0] * 0.25;"; + ctls[0]->setValue(expr); + ctls[1]->setValue(0.75f); // a + + // DataStream + { + auto* loaded = roundtrip_datastream(*proc, dctx, parent); + REQUIRE(loaded != nullptr); + CHECK(loaded->concreteKey() == math_audio_uuid); + + auto loaded_ctls = controls(*loaded); + REQUIRE(loaded_ctls.size() == 4); + CHECK(loaded_ctls[0]->value() == ossia::value{expr}); + CHECK(loaded_ctls[1]->value() == ossia::value{0.75f}); + delete loaded; + } + + // JSON + { + auto* loaded = roundtrip_json(*proc, dctx, parent); + REQUIRE(loaded != nullptr); + CHECK(loaded->concreteKey() == math_audio_uuid); + + auto loaded_ctls = controls(*loaded); + REQUIRE(loaded_ctls.size() == 4); + CHECK(loaded_ctls[0]->value() == ossia::value{expr}); + CHECK(loaded_ctls[1]->value() == ossia::value{0.75f}); + delete loaded; + } + delete proc; + } + }); +} diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index c741454cd9..ddeeb17002 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -16,3 +16,118 @@ score_add_test(test_unit_port_factory_reuse APP PLUGINS score_lib_process) +# --- core data model (score-lib-state / score-lib-process, P3R2) ----------- +score_add_test(test_unit_state_serialization + SOURCES StateSerializationTest.cpp + PLUGINS score_lib_state) + +# Port / Cable / ProcessModel object graph + DataStream & JSON round-trips. +score_add_test(test_unit_process_model + SOURCES ProcessModelTest.cpp + PLUGINS score_lib_process score_lib_state score_lib_device) + +score_add_test(test_unit_curve_sample + SOURCES CurveSampleTest.cpp + PLUGINS score_plugin_curve) + +score_add_test(test_unit_curve_roundtrip + SOURCES CurveRoundtripTest.cpp + APP + PLUGINS score_plugin_curve score_lib_process) + +score_add_test(test_unit_automation_values + SOURCES AutomationValueTest.cpp + APP + PLUGINS score_plugin_automation score_plugin_curve score_lib_process) + +score_add_test(test_unit_mapping_values + SOURCES MappingTest.cpp + APP + PLUGINS score_plugin_mapping score_plugin_curve score_lib_process) + +score_add_test(test_unit_spline_values + SOURCES SplineValueTest.cpp + PLUGINS score_plugin_spline) + +# --- P3R2: score-plugin-midi + score-plugin-dataflow value tests ----------- +if(TARGET score_plugin_midi) + set(_midi_src "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-midi") + score_add_test(test_unit_midi_message + SOURCES + MidiMessageTest.cpp + "${_midi_src}/Midi/MidiNote.cpp" + "${_midi_src}/Midi/MidiProcess.cpp" + "${_midi_src}/Patternist/PatternParsing.cpp" + PLUGINS score_plugin_midi score_lib_process + LIBS ${QT_PREFIX}::Gui) +endif() + +if(TARGET score_plugin_dataflow) + score_add_test(test_unit_dataflow_value + SOURCES DataflowValueTest.cpp + PLUGINS score_plugin_dataflow score_lib_process) +endif() + + +# --- analysis DSP ------------------------------------------------------------ +if(TARGET score_plugin_analysis) + set(_gist_dir "${3RDPARTY_FOLDER}/Gist/src") + score_add_test(test_unit_analysis_gist + SOURCES + AnalysisGistTest.cpp + "${_gist_dir}/CoreFrequencyDomainFeatures.cpp" + "${_gist_dir}/CoreTimeDomainFeatures.cpp" + "${_gist_dir}/Gist.cpp" + "${_gist_dir}/MFCC.cpp" + "${_gist_dir}/OnsetDetectionFunction.cpp" + "${_gist_dir}/WindowFunctions.cpp" + "${_gist_dir}/Yin.cpp" + PLUGINS score_plugin_analysis score_plugin_avnd) + target_compile_definitions(test_unit_analysis_gist PRIVATE USE_OSSIA_FFT=1) + target_include_directories(test_unit_analysis_gist SYSTEM PRIVATE "${_gist_dir}") + target_include_directories(test_unit_analysis_gist PRIVATE + "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-analysis") +endif() + +# --- faust JIT DSP ----------------------------------------------------------- +if(TARGET score_plugin_faust) + score_add_test(test_unit_faust_dsp + SOURCES FaustDspTest.cpp + PLUGINS score_plugin_faust) + target_include_directories(test_unit_faust_dsp PRIVATE + "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-faust") +else() + find_package(Faust QUIET) + if(FAUST_FOUND AND TARGET score_lib_process) + score_add_test(test_unit_faust_dsp + SOURCES FaustDspTest.cpp + PLUGINS score_lib_process + LIBS ${FAUST_LIBRARIES}) + target_compile_definitions(test_unit_faust_dsp PRIVATE FAUSTFLOAT=double) + target_include_directories(test_unit_faust_dsp PRIVATE + "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-faust") + target_include_directories(test_unit_faust_dsp SYSTEM PRIVATE + ${FAUST_INCLUDE_DIR}) + endif() +endif() + +# --- P3R3: audio DSP value-asserting tests --------------------------------- +score_add_test(test_unit_avnd_audio_effects + SOURCES AvndAudioEffectsTest.cpp) +target_include_directories(test_unit_avnd_audio_effects SYSTEM PRIVATE + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/avendish/include" + "${SCORE_ROOT_SOURCE_DIR}/3rdparty/avendish/examples") + +if(TARGET score_plugin_fx) + score_add_test(test_unit_fx_audio_dsp + SOURCES FxAudioDspTest.cpp + PLUGINS score_plugin_fx) +endif() + +score_add_test(test_unit_audio_gain_pan + SOURCES AudioGainPanTest.cpp) + +score_add_test(test_unit_avnd_audio_roundtrip + SOURCES AvndAudioRoundtripTest.cpp + PLUGINS score_lib_process + APP) diff --git a/tests/unit/CurveRoundtripTest.cpp b/tests/unit/CurveRoundtripTest.cpp new file mode 100644 index 0000000000..e25149eedd --- /dev/null +++ b/tests/unit/CurveRoundtripTest.cpp @@ -0,0 +1,128 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include + +using Catch::Approx; + +QDataStream& operator<<(QDataStream& s, const Curve::PowerSegmentData& d) +{ + return s << d.gamma; +} +QDataStream& operator>>(QDataStream& s, Curve::PowerSegmentData& d) +{ + return s >> d.gamma; +} +QDataStream& operator<<(QDataStream& s, const Curve::EasingData&) +{ + return s; +} +QDataStream& operator>>(QDataStream& s, Curve::EasingData&) +{ + return s; +} + +namespace +{ +void build_reference_curve(Curve::Model& model) +{ + auto lin = new Curve::LinearSegment{Id{1}, &model}; + lin->setStart({0., 0.}); + lin->setEnd({0.4, 1.}); + + auto pow = new Curve::PowerSegment{Id{2}, &model}; + pow->setStart({0.4, 1.}); + pow->setEnd({0.7, 0.2}); + pow->gamma = 3.; + + auto ease = new Curve::Segment_quadraticIn{Id{3}, &model}; + ease->setStart({0.7, 0.2}); + ease->setEnd({1., 0.9}); + + model.addSegment(lin); + model.addSegment(pow); + model.addSegment(ease); +} + +void check_same_samples(const Curve::Model& a, const Curve::Model& b) +{ + for(int i = 0; i <= 100; i++) + { + const double x = i / 100.; + const auto va = a.valueAt(x); + const auto vb = b.valueAt(x); + REQUIRE(va.has_value() == vb.has_value()); + if(va) + CHECK(*va == Approx(*vb).margin(1e-12)); + } +} + +double gamma_of(const Curve::Model& m) +{ + for(const auto& data : m.toCurveData()) + if(data.type == Metadata::get()) + return data.specificSegmentData.value().gamma; + return -1.; +} +} + +TEST_CASE("Curve::Model DataStream round-trip preserves sampled values", "[curve][serialization]") +{ + score::test::run_in_app([](const score::GUIApplicationContext&) { + QObject parent; + Curve::Model model{Id{1}, &parent}; + build_reference_curve(model); + + // Sanity on the source model itself. + REQUIRE(model.segments().size() == 3); + CHECK(*model.valueAt(0.2) == Approx(0.5).margin(1e-12)); + CHECK(gamma_of(model) == Approx(3.).margin(1e-12)); + + const QByteArray bytes = score::marshall(model); + DataStream::Deserializer des{bytes}; + Curve::Model reloaded{des, &parent}; + + REQUIRE(reloaded.segments().size() == 3); + CHECK(gamma_of(reloaded) == Approx(3.).margin(1e-12)); + check_same_samples(model, reloaded); + + // Byte fixed-point: serializing the clone yields identical bytes. + CHECK(score::marshall(reloaded) == bytes); + }); +} + +TEST_CASE("Curve::Model JSON round-trip preserves sampled values", "[curve][serialization]") +{ + score::test::run_in_app([](const score::GUIApplicationContext&) { + QObject parent; + Curve::Model model{Id{1}, &parent}; + build_reference_curve(model); + + JSONReader reader; + reader.readFrom(model); + const rapidjson::Document jdoc = toValue(reader); + + JSONObject::Deserializer des{jdoc}; + Curve::Model reloaded{des, &parent}; + + REQUIRE(reloaded.segments().size() == 3); + CHECK(gamma_of(reloaded) == Approx(3.).margin(1e-12)); + check_same_samples(model, reloaded); + + // JSON fixed-point. + JSONReader reader2; + reader2.readFrom(reloaded); + CHECK(reader2.toByteArray() == reader.toByteArray()); + }); +} diff --git a/tests/unit/CurveSampleTest.cpp b/tests/unit/CurveSampleTest.cpp new file mode 100644 index 0000000000..1887a6ef4d --- /dev/null +++ b/tests/unit/CurveSampleTest.cpp @@ -0,0 +1,292 @@ +#include +#include +#include +#include + +#include + +#include +#include + +#include + +using Catch::Approx; + +QDataStream& operator<<(QDataStream& s, const Curve::EasingData&) +{ + return s; +} +QDataStream& operator>>(QDataStream& s, Curve::EasingData&) +{ + return s; +} + +namespace +{ +const Curve::SegmentModel& base(const Curve::SegmentModel& s) +{ + return s; +} +} + +TEST_CASE("Linear segment interpolates exactly", "[curve][segment][linear]") +{ + QObject parent; + Curve::LinearSegment seg{Id{1}, &parent}; + seg.setStart({0., 0.}); + seg.setEnd({1., 1.}); + + CHECK(base(seg).valueAt(0.) == 0.); + CHECK(base(seg).valueAt(0.25) == 0.25); + CHECK(base(seg).valueAt(0.5) == 0.5); + CHECK(base(seg).valueAt(1.) == 1.); + + // Non-trivial span and range: (0.2, 1) -> (0.8, 4) + Curve::LinearSegment seg2{Id{2}, &parent}; + seg2.setStart({0.2, 1.}); + seg2.setEnd({0.8, 4.}); + CHECK(base(seg2).valueAt(0.2) == Approx(1.).margin(1e-12)); + CHECK(base(seg2).valueAt(0.5) == Approx(2.5).margin(1e-12)); + CHECK(base(seg2).valueAt(0.8) == Approx(4.).margin(1e-12)); + + // Its ossia execution functor is plain linear interpolation of the ratio. + auto f = base(seg2).makeDoubleFunction(); + CHECK(f(0., 1., 4.) == Approx(1.).margin(1e-12)); + CHECK(f(0.3, 0., 10.) == Approx(3.).margin(1e-12)); + CHECK(f(1., 1., 4.) == Approx(4.).margin(1e-12)); +} + +TEST_CASE("Power segment with linear gamma equals linear", "[curve][segment][power]") +{ + QObject parent; + Curve::PowerSegment seg{Id{1}, &parent}; + seg.setStart({0., 2.}); + seg.setEnd({1., 6.}); + REQUIRE(seg.gamma == Curve::PowerSegmentData::linearGamma); + + CHECK(base(seg).valueAt(0.) == Approx(2.).margin(1e-12)); + CHECK(base(seg).valueAt(0.25) == Approx(3.).margin(1e-12)); + CHECK(base(seg).valueAt(0.75) == Approx(5.).margin(1e-12)); + CHECK(base(seg).valueAt(1.) == Approx(6.).margin(1e-12)); +} + +TEST_CASE("Power segment samples y = y0 + dy * x^gamma on [0,1]", "[curve][segment][power]") +{ + QObject parent; + Curve::PowerSegment seg{Id{1}, &parent}; + seg.setStart({0., 0.}); + seg.setEnd({1., 1.}); + seg.gamma = 2.; + + CHECK(base(seg).valueAt(0.) == Approx(0.).margin(1e-12)); + CHECK(base(seg).valueAt(0.25) == Approx(0.0625).margin(1e-12)); + CHECK(base(seg).valueAt(0.5) == Approx(0.25).margin(1e-12)); + CHECK(base(seg).valueAt(0.75) == Approx(0.5625).margin(1e-12)); + CHECK(base(seg).valueAt(1.) == Approx(1.).margin(1e-12)); + + seg.gamma = 0.5; // sqrt + CHECK(base(seg).valueAt(0.25) == Approx(0.5).margin(1e-12)); + CHECK(base(seg).valueAt(0.81) == Approx(0.9).margin(1e-12)); + + // Scaled y-range: (0,2) -> (1,6), gamma 2 => y = 2 + 4 x^2 + Curve::PowerSegment seg2{Id{2}, &parent}; + seg2.setStart({0., 2.}); + seg2.setEnd({1., 6.}); + seg2.gamma = 2.; + CHECK(base(seg2).valueAt(0.5) == Approx(3.).margin(1e-12)); + CHECK(base(seg2).valueAt(0.75) == Approx(4.25).margin(1e-12)); + + // Execution functor: ratio-based power easing. + auto f = base(seg2).makeDoubleFunction(); + CHECK(f(0.5, 0., 10.) == Approx(2.5).margin(1e-12)); // 0.5^2 * 10 + CHECK(f(0.25, 0., 16.) == Approx(1.).margin(1e-12)); // 0.0625 * 16 + CHECK(f(1., 2., 6.) == Approx(6.).margin(1e-12)); +} + +TEST_CASE("Power segment vertical parameter maps to gamma = 16^-p", "[curve][segment][power]") +{ + QObject parent; + Curve::PowerSegment seg{Id{1}, &parent}; + seg.setStart({0., 0.}); + seg.setEnd({1., 1.}); // ascending: gamma = 16^-p + Curve::SegmentModel& s = seg; // the parameter API is on the base interface + + s.setVerticalParameter(1.); + CHECK(seg.gamma == Approx(1. / 16.).margin(1e-12)); + REQUIRE(s.verticalParameter()); + CHECK(*s.verticalParameter() == Approx(1.).margin(1e-12)); + + s.setVerticalParameter(-0.5); + CHECK(seg.gamma == Approx(4.).margin(1e-12)); + CHECK(*s.verticalParameter() == Approx(-0.5).margin(1e-12)); + + s.setVerticalParameter(0.); + CHECK(seg.gamma == Approx(1.).margin(1e-12)); // back to linear +} + +TEST_CASE("Power segment updateData interpolation lies on the analytic curve", "[curve][segment][power]") +{ + QObject parent; + Curve::PowerSegment seg{Id{1}, &parent}; + seg.setStart({0., 0.}); + seg.setEnd({1., 1.}); + seg.gamma = 2.; + + const int numInterp = 10; + base(seg).updateData(numInterp); + const auto& data = seg.data(); + REQUIRE(data.size() == std::size_t(numInterp + 1)); + + // Endpoints exact. + CHECK(data.front().x() == Approx(0.).margin(1e-12)); + CHECK(data.front().y() == Approx(0.).margin(1e-12)); + CHECK(data.back().x() == Approx(1.).margin(1e-12)); + CHECK(data.back().y() == Approx(1.).margin(1e-12)); + + for(std::size_t i = 0; i < data.size(); i++) + { + // On [0,1] with gamma 2 every cached point must satisfy y = x^2. + CHECK(data[i].y() == Approx(std::pow(data[i].x(), 2.)).margin(1e-9)); + // Monotonic non-decreasing x. + if(i > 0) + CHECK(data[i].x() >= data[i - 1].x()); + } + + // Linear-gamma segments collapse the cache to the two endpoints. + Curve::PowerSegment lin{Id{2}, &parent}; + lin.setStart({0., 3.}); + lin.setEnd({1., 5.}); + base(lin).updateData(10); + REQUIRE(lin.data().size() == 2); + CHECK(lin.data()[0] == QPointF(0., 3.)); + CHECK(lin.data()[1] == QPointF(1., 5.)); +} + +TEST_CASE("Easing segments sample their easing function", "[curve][segment][easing]") +{ + QObject parent; + + Curve::Segment_quadraticIn quadIn{Id{1}, &parent}; + quadIn.setStart({0., 0.}); + quadIn.setEnd({1., 1.}); + CHECK(quadIn.valueAt(0.5) == Approx(0.25).margin(1e-12)); + CHECK(quadIn.valueAt(0.25) == Approx(0.0625).margin(1e-12)); + CHECK(quadIn.valueAt(1.) == Approx(1.).margin(1e-12)); + + Curve::Segment_quadraticOut quadOut{Id{2}, &parent}; + quadOut.setStart({0., 0.}); + quadOut.setEnd({1., 1.}); + // quadraticOut(t) = -(t * (t - 2)) + CHECK(quadOut.valueAt(0.5) == Approx(0.75).margin(1e-12)); + CHECK(quadOut.valueAt(0.25) == Approx(0.4375).margin(1e-12)); + + Curve::Segment_cubicInOut cubicIO{Id{3}, &parent}; + cubicIO.setStart({0., 0.}); + cubicIO.setEnd({1., 1.}); + // t < 0.5 : 4 t^3 + CHECK(cubicIO.valueAt(0.25) == Approx(0.0625).margin(1e-12)); + CHECK(cubicIO.valueAt(0.5) == Approx(0.5).margin(1e-12)); + // t >= 0.5 : 0.5 (2t-2)^3 + 1 + CHECK(cubicIO.valueAt(0.75) == Approx(0.9375).margin(1e-12)); + + // Scaled into y-range [10, 20]: y = 10 + 10 * quadIn(x) + Curve::Segment_quadraticIn scaled{Id{4}, &parent}; + scaled.setStart({0., 10.}); + scaled.setEnd({1., 20.}); + CHECK(scaled.valueAt(0.5) == Approx(12.5).margin(1e-12)); + + // updateData samples the same shape. + quadIn.updateData(4); + REQUIRE(quadIn.data().size() == 5); + for(const auto& pt : quadIn.data()) + CHECK(pt.y() == Approx(pt.x() * pt.x()).margin(1e-12)); + + // Execution functor = ratio-based easing (matches valueAt on [0,1]). + auto f = quadIn.makeDoubleFunction(); + CHECK(f(0.5, 0., 1.) == Approx(0.25).margin(1e-12)); + CHECK(f(0.5, 0., 8.) == Approx(2.).margin(1e-12)); +} + +TEST_CASE("flatCurveSegment normalizes the value into [min,max]", "[curve][segment]") +{ + const auto seg = Curve::flatCurveSegment(5., 0., 10.); + CHECK(seg.start.x() == 0.); + CHECK(seg.end.x() == 1.); + CHECK(seg.start.y() == Approx(0.5).margin(1e-12)); + CHECK(seg.end.y() == Approx(0.5).margin(1e-12)); + CHECK(seg.type == Metadata::get()); + + const auto seg2 = Curve::flatCurveSegment(-2., -4., 4.); + CHECK(seg2.start.y() == Approx(0.25).margin(1e-12)); +} + +TEST_CASE("Curve::Model samples across multiple segments", "[curve][model]") +{ + QObject parent; + Curve::Model model{Id{1}, &parent}; + + SECTION("empty curve has no value anywhere") + { + CHECK(!model.valueAt(0.)); + CHECK(!model.valueAt(0.5)); + CHECK(!model.valueAt(1.)); + } + + SECTION("single sub-range segment: out-of-domain x yields nullopt") + { + auto seg = new Curve::LinearSegment{Id{1}, &model}; + seg->setStart({0.25, 0.}); + seg->setEnd({0.75, 1.}); + model.addSegment(seg); + + CHECK(!model.valueAt(0.1)); + CHECK(!model.valueAt(0.8)); + REQUIRE(model.valueAt(0.25)); + CHECK(*model.valueAt(0.25) == Approx(0.).margin(1e-12)); + CHECK(*model.valueAt(0.5) == Approx(0.5).margin(1e-12)); + CHECK(*model.valueAt(0.75) == Approx(1.).margin(1e-12)); + } + + SECTION("triangle curve: piecewise values and continuity at the junction") + { + auto up = new Curve::LinearSegment{Id{1}, &model}; + up->setStart({0., 0.}); + up->setEnd({0.5, 1.}); + auto down = new Curve::LinearSegment{Id{2}, &model}; + down->setStart({0.5, 1.}); + down->setEnd({1., 0.}); + model.addSegment(up); + model.addSegment(down); + + CHECK(*model.valueAt(0.) == Approx(0.).margin(1e-12)); + CHECK(*model.valueAt(0.25) == Approx(0.5).margin(1e-12)); + CHECK(*model.valueAt(0.5) == Approx(1.).margin(1e-12)); + CHECK(*model.valueAt(0.75) == Approx(0.5).margin(1e-12)); + CHECK(*model.valueAt(1.) == Approx(0.).margin(1e-12)); + + // Both segments agree at the shared point. + const Curve::SegmentModel& u = *up; + const Curve::SegmentModel& d = *down; + CHECK(u.valueAt(0.5) == Approx(d.valueAt(0.5)).margin(1e-12)); + + // sortedSegments orders by start abscissa. + auto sorted = model.sortedSegments(); + REQUIRE(sorted.size() == 2); + CHECK(sorted[0] == up); + CHECK(sorted[1] == down); + } + + SECTION("power segment on a sub-range: model sampling matches the engine") + { + auto seg = new Curve::PowerSegment{Id{1}, &model}; + seg->setStart({0.5, 1.}); + seg->setEnd({1., 3.}); + seg->gamma = 2.; + model.addSegment(seg); + + REQUIRE(model.valueAt(0.75)); + CHECK(*model.valueAt(0.75) == Approx(1.5).margin(1e-12)); // model == engine + const Curve::SegmentModel& s = *seg; + CHECK(s.makeDoubleFunction()(0.5, 1., 3.) == Approx(1.5).margin(1e-12)); // ratio + } +} diff --git a/tests/unit/DataflowValueTest.cpp b/tests/unit/DataflowValueTest.cpp new file mode 100644 index 0000000000..83b994e45a --- /dev/null +++ b/tests/unit/DataflowValueTest.cpp @@ -0,0 +1,521 @@ +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace +{ +struct ScopedIgnoreSigtrap +{ + void (*prev)(int); + ScopedIgnoreSigtrap() + : prev{std::signal(SIGTRAP, SIG_IGN)} + { + } + ~ScopedIgnoreSigtrap() { std::signal(SIGTRAP, prev); } +}; + +struct TestApplication final : public score::ApplicationInterface +{ + score::ApplicationComponentsData m_compData; + score::ApplicationComponents m_components{m_compData}; + score::ApplicationSettings m_appSettings; + score::DocumentList m_documents; + std::vector> m_settings; + score::ApplicationContext m_ctx{m_appSettings, m_components, m_documents, m_settings}; + + TestApplication() + { + m_instance = this; + + auto ports = std::make_unique(); + ports->insert(std::make_unique()); + ports->insert(std::make_unique()); + ports->insert(std::make_unique()); + ports->insert(std::make_unique()); + ports->insert(std::make_unique()); + ports->insert(std::make_unique()); + ports->insert(std::make_unique()); + ports->insert(std::make_unique()); + m_compData.factories.emplace(ports->interfaceKey(), std::move(ports)); + } + + const score::ApplicationContext& context() const override { return m_ctx; } + const score::ApplicationComponents& components() const override + { + return m_components; + } +}; + +TestApplication& testApp() +{ + static TestApplication app; + return app; +} + +bool sameCableData(const Process::CableData& lhs, const Process::CableData& rhs) +{ + return lhs.type == rhs.type && lhs.source == rhs.source && lhs.sink == rhs.sink; +} + +Path makePortPath(int a, int b) +{ + ObjectPath p; + p.vec().push_back(ObjectIdentifier{"TestProcess", a}); + p.vec().push_back(ObjectIdentifier{"Port", b}); + return Path{std::move(p), Path::UnsafeDynamicCreation{}}; +} +} + +TEST_CASE("CableData equality and Cable round-trip", "[dataflow][cable]") +{ + testApp(); + + Process::CableData cd; + cd.type = Process::CableType::DelayedStrict; + cd.source = makePortPath(1, 2); + cd.sink = makePortPath(3, 4); + + SECTION("equality is field-wise") + { + Process::CableData same; + same.type = Process::CableType::DelayedStrict; + same.source = makePortPath(1, 2); + same.sink = makePortPath(3, 4); + CHECK(sameCableData(cd, same)); + + same.type = Process::CableType::ImmediateGlutton; + CHECK(!sameCableData(cd, same)); + } + + SECTION("DataStream round-trip") + { + Process::Cable src{Id{7}, cd, nullptr}; + const QByteArray arr = score::marshall(src); + Process::Cable dst{DataStream::Deserializer{arr}, nullptr}; + + CHECK(dst.id().val() == 7); + CHECK(dst.type() == Process::CableType::DelayedStrict); + CHECK(sameCableData(dst.toCableData(), cd)); + } + + SECTION("JSON round-trip") + { + Process::Cable src{Id{7}, cd, nullptr}; + JSONReader r; + r.readFrom(src); + const rapidjson::Document doc = readJson(r.toByteArray()); + Process::Cable dst{JSONObject::Deserializer{doc}, nullptr}; + + CHECK(dst.id().val() == 7); + CHECK(dst.type() == Process::CableType::DelayedStrict); + CHECK(sameCableData(dst.toCableData(), cd)); + } + + SECTION("all four cable types survive a round-trip") + { + for(auto t : + {Process::CableType::ImmediateGlutton, Process::CableType::ImmediateStrict, + Process::CableType::DelayedGlutton, Process::CableType::DelayedStrict}) + { + Process::CableData d = cd; + d.type = t; + Process::Cable src{Id{1}, d, nullptr}; + const QByteArray arr = score::marshall(src); + Process::Cable dst{DataStream::Deserializer{arr}, nullptr}; + CHECK(dst.type() == t); + } + } +} + +TEST_CASE("polymorphic port load through the dataflow factories", "[dataflow][port]") +{ + testApp(); + + SECTION("ControlInlet: value, domain, name, address survive DataStream") + { + Process::ControlInlet src{"Level", Id{4}, nullptr}; + src.setValue(0.5f); + src.setDomain(State::Domain{ossia::make_domain(0.f, 1.f)}); + src.setAddress(State::AddressAccessor{*State::Address::fromString("dev:/foo/bar")}); + + QByteArray arr; + { + DataStream::Serializer s{&arr}; + s.stream() << src; + } + DataStream::Deserializer des{arr}; + auto loaded = Process::load_inlet(des, nullptr); + REQUIRE(loaded); + + auto ctrl = dynamic_cast(loaded.get()); + REQUIRE(ctrl); + CHECK(ctrl->id().val() == 4); + CHECK(ctrl->name() == "Level"); + CHECK(ctrl->type() == Process::PortType::Message); + CHECK(ctrl->value() == ossia::value{0.5f}); + CHECK(ctrl->domain() == State::Domain{ossia::make_domain(0.f, 1.f)}); + CHECK(ctrl->address().address.toString() == "dev:/foo/bar"); + } + + SECTION("ControlInlet: JSON") + { + Process::ControlInlet src{"Level", Id{4}, nullptr}; + src.setValue(ossia::value{std::vector{1, 2, 3}}); + + JSONReader r; + r.readFrom(src); + const rapidjson::Document doc = readJson(r.toByteArray()); + JSONObject::Deserializer des{doc}; + auto loaded = Process::load_inlet(des, nullptr); + REQUIRE(loaded); + + auto ctrl = dynamic_cast(loaded.get()); + REQUIRE(ctrl); + CHECK(ctrl->value() == ossia::value{std::vector{1, 2, 3}}); + } + + SECTION("MidiOutlet keeps its concrete type") + { + Process::MidiOutlet src{"MIDI Out", Id{0}, nullptr}; + + QByteArray arr; + { + DataStream::Serializer s{&arr}; + s.stream() << src; + } + DataStream::Deserializer des{arr}; + auto loaded = Process::load_outlet(des, nullptr); + REQUIRE(loaded); + CHECK(dynamic_cast(loaded.get())); + CHECK(loaded->type() == Process::PortType::Midi); + CHECK(loaded->name() == "MIDI Out"); + } + + SECTION("AudioOutlet: gain / pan / propagate") + { + Process::AudioOutlet src{"Out", Id{2}, nullptr}; + src.setGain(0.7); + src.setPan({0.25, 0.75}); + src.setPropagate(true); + + QByteArray arr; + { + DataStream::Serializer s{&arr}; + s.stream() << src; + } + DataStream::Deserializer des{arr}; + auto loaded = Process::load_outlet(des, nullptr); + REQUIRE(loaded); + + auto audio = dynamic_cast(loaded.get()); + REQUIRE(audio); + CHECK(audio->type() == Process::PortType::Audio); + CHECK(audio->gain() == 0.7); + CHECK(audio->pan() == Process::pan_weight{0.25, 0.75}); + CHECK(audio->propagate() == true); + } +} + +TEST_CASE("ControlInlet value model", "[dataflow][port]") +{ + SECTION("setValue stores and signals the value") + { + Process::ControlInlet inl{"ctl", Id{0}, nullptr}; + ossia::value seen; + int count = 0; + QObject::connect(&inl, &Process::ControlInlet::valueChanged, + [&](const ossia::value& v) { + seen = v; + count++; + }); + + inl.setValue(3.5f); + CHECK(inl.value() == ossia::value{3.5f}); + CHECK(seen == ossia::value{3.5f}); + CHECK(count == 1); + + // Same value again: no signal + inl.setValue(3.5f); + CHECK(count == 1); + + // Impulse always re-triggers + inl.setValue(ossia::impulse{}); + inl.setValue(ossia::impulse{}); + CHECK(count == 3); + } + + SECTION("saveData / loadData flag semantics") + { + Process::ControlInlet src{"ctl", Id{0}, nullptr}; + src.setValue(1.25f); + src.setAddress(State::AddressAccessor{*State::Address::fromString("dev:/a")}); + const QByteArray data = src.saveData(); + + Process::ControlInlet defaults{"ctl", Id{1}, nullptr}; + defaults.loadData(data); + CHECK(defaults.value() == ossia::value{}); // value not restored + CHECK(defaults.address().address.toString() == "dev:/a"); + + Process::ControlInlet flagged{"ctl", Id{2}, nullptr}; + flagged.loadData(data, Process::PortLoadDataFlags::ReloadValue); + CHECK(flagged.value() == ossia::value{1.25f}); // value restored + } +} + +namespace +{ +class capture_node final : public ossia::graph_node +{ +public: + std::function fun; + + capture_node(ossia::inlets in, ossia::outlets out) + { + m_inlets = std::move(in); + m_outlets = std::move(out); + } + + std::string label() const noexcept override { return "capture_node"; } + + void run(const ossia::token_request& t, ossia::exec_state_facade e) noexcept override + { + if(fun) + fun(t, e); + } +}; + +struct value_graph_fixture +{ + std::unique_ptr g_ptr = std::make_unique(); + std::unique_ptr e_ptr + = std::make_unique(); + ossia::tc_graph& g = *g_ptr; + ossia::execution_state& e = *e_ptr; + + std::shared_ptr source; + std::shared_ptr sink; + ossia::value_outlet* src_out{}; + ossia::value_inlet* sink_in{}; + + std::vector received; + ossia::value to_send; + + explicit value_graph_fixture(ossia::connection con) + { + auto out = new ossia::value_outlet; + source = std::make_shared(ossia::inlets{}, ossia::outlets{out}); + src_out = out; + source->fun = [this](const ossia::token_request&, ossia::exec_state_facade) { + src_out->data.write_value(to_send, 0); + }; + + auto in = new ossia::value_inlet; + sink = std::make_shared(ossia::inlets{in}, ossia::outlets{}); + sink_in = in; + sink->fun = [this](const ossia::token_request&, ossia::exec_state_facade) { + for(auto& tv : sink_in->data.get_data()) + received.push_back(tv.value); + }; + + g.add_node(source); + g.add_node(sink); + g.connect(g.allocate_edge(con, src_out, sink_in, source, sink)); + } + + void tick() + { + source->request(ossia::token_request{}); + sink->request(ossia::token_request{}); + e.begin_tick(); + g.state(e); + e.commit(); + } +}; +} + +TEST_CASE("value propagation along graph edges", "[dataflow][graph]") +{ + SECTION("immediate glutton cable: value arrives in the same tick") + { + value_graph_fixture f{ossia::immediate_glutton_connection{}}; + f.to_send = 42; + f.tick(); + REQUIRE(f.received.size() == 1); + CHECK(f.received[0] == ossia::value{42}); + } + + SECTION("immediate strict cable: value arrives in the same tick") + { + value_graph_fixture f{ossia::immediate_strict_connection{}}; + f.to_send = std::string{"hello"}; + f.tick(); + REQUIRE(f.received.size() == 1); + CHECK(f.received[0] == ossia::value{std::string{"hello"}}); + } + + SECTION("values keep their type across the edge") + { + value_graph_fixture f{ossia::immediate_glutton_connection{}}; + + f.to_send = 3.25f; + f.tick(); + f.to_send = ossia::vec3f{1.f, 2.f, 3.f}; + f.tick(); + + REQUIRE(f.received.size() == 2); + CHECK(f.received[0] == ossia::value{3.25f}); + CHECK(f.received[0].get_type() == ossia::val_type::FLOAT); + CHECK(f.received[1] == ossia::value{ossia::vec3f{1.f, 2.f, 3.f}}); + CHECK(f.received[1].get_type() == ossia::val_type::VEC3F); + } + + SECTION("delayed glutton cable: value goes through the edge's delay line") + { + value_graph_fixture f{ossia::delayed_glutton_connection{}}; + f.to_send = 7; + f.tick(); + f.source->fun = {}; // stop sending + f.tick(); + + REQUIRE(f.received.size() == 1); + CHECK(f.received[0] == ossia::value{7}); + } +} + +TEST_CASE("midi messages propagate along a graph edge", "[dataflow][graph][midi]") +{ + auto g_ptr = std::make_unique(); + auto e_ptr = std::make_unique(); + ossia::tc_graph& g = *g_ptr; + ossia::execution_state& e = *e_ptr; + + auto midi = std::make_shared(4); + midi->set_channel(3); + midi->add_note({ossia::time_value{10}, ossia::time_value{50}, 61, 99}); + + auto in = new ossia::midi_inlet; + auto sink = std::make_shared(ossia::inlets{in}, ossia::outlets{}); + std::vector received; + sink->fun = [&](const ossia::token_request&, ossia::exec_state_facade) { + for(auto& m : in->data.messages) + received.push_back(m); + }; + + g.add_node(midi); + g.add_node(sink); + g.connect(g.allocate_edge( + ossia::immediate_glutton_connection{}, midi->root_outputs()[0], + sink->root_inputs()[0], midi, sink)); + + const ossia::token_request tok{ + ossia::time_value{0}, ossia::time_value{100}, ossia::time_value{1000}, + ossia::time_value{0}, 1., ossia::time_signature{4, 4}, 120.}; + midi->request(tok); + sink->request(tok); + e.begin_tick(); + g.state(e); + e.commit(); + + REQUIRE(received.size() == 1); + CHECK(cmidi2_ump_get_status_code(received[0].data) == CMIDI2_STATUS_NOTE_ON); + CHECK(cmidi2_ump_get_channel(received[0].data) == 2); // channel 3, 0-based + CHECK(cmidi2_ump_get_midi2_note_note(received[0].data) == 61); + CHECK(cmidi2_ump_get_midi2_note_velocity(received[0].data) / 0x200 == 99); +} + +TEST_CASE("fuzz: corrupt port and cable buffers are handled gracefully", + "[dataflow][fuzz]") +{ + testApp(); + + + SECTION("truncated serialized cable throws instead of crashing") + { + Process::CableData cd; + cd.type = Process::CableType::ImmediateStrict; + cd.source = makePortPath(1, 2); + cd.sink = makePortPath(3, 4); + Process::Cable src{Id{1}, cd, nullptr}; + const QByteArray arr = score::marshall(src); + + ScopedIgnoreSigtrap guard; + for(int len : {0, 2, int(arr.size() / 3), int(arr.size() / 2), int(arr.size() - 1)}) + { + try + { + Process::Cable dst{DataStream::Deserializer{arr.left(len)}, nullptr}; + } + catch(const std::exception&) + { + // "Corrupt save file." is acceptable + } + } + SUCCEED("no crash on truncated cable buffers"); + } + + SECTION("polymorphic port load from truncated buffer returns null or throws") + { + Process::ControlInlet src{"ctl", Id{0}, nullptr}; + QByteArray arr; + { + DataStream::Serializer s{&arr}; + s.stream() << src; + } + + ScopedIgnoreSigtrap guard; + for(int len : {0, 4, int(arr.size() / 2)}) + { + try + { + DataStream::Deserializer des{arr.left(len)}; + auto p = Process::load_inlet(des, nullptr); + // nullptr or a default-constructed port are both acceptable + } + catch(const std::exception&) + { + } + } + SUCCEED("no crash on truncated polymorphic port buffers"); + } +} diff --git a/tests/unit/FaustDspTest.cpp b/tests/unit/FaustDspTest.cpp new file mode 100644 index 0000000000..9dbdacc7b7 --- /dev/null +++ b/tests/unit/FaustDspTest.cpp @@ -0,0 +1,476 @@ +#include + +#include + +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using Catch::Approx; + +namespace +{ +struct Jit +{ + llvm_dsp_factory* fac{}; + llvm_dsp* dsp{}; + std::string err = std::string(4097, '\0'); + + explicit Jit(const std::string& code, int rate = 48000) + { + // https://github.com/grame-cncm/faust/issues/1117 (mirrors the plugin) + ossia::reset_default_fpu_state(); + + const char* args[] = {"-double", "-vec"}; + fac = createDSPFactoryFromString("score_test", code, 2, args, "", err, -1); + if(fac) + { + dsp = fac->createDSPInstance(); + if(dsp) + dsp->init(rate); + } + } + + Jit(const Jit&) = delete; + Jit& operator=(const Jit&) = delete; + + ~Jit() + { + delete dsp; + if(fac) + deleteDSPFactory(fac); + } + + bool ok() const { return dsp != nullptr; } + bool errored() const { return err[0] != '\0'; } + + // mono in -> mono out + std::vector run1(const std::vector& in) + { + std::vector out(in.size(), 0.0); + double* ins[1] = {const_cast(in.data())}; + double* outs[1] = {out.data()}; + dsp->compute((int)in.size(), ins, outs); + return out; + } + + // generator (0 in) -> mono out + std::vector gen1(int frames) + { + std::vector out(frames, 0.0); + double* outs[1] = {out.data()}; + dsp->compute(frames, nullptr, outs); + return out; + } +}; + +// Captures the control zones of a jitted dsp by label. +struct ZoneUI final : ::UI +{ + std::map zones; + + void openTabBox(const char*) override { } + void openHorizontalBox(const char*) override { } + void openVerticalBox(const char*) override { } + void closeBox() override { } + void declare(FAUSTFLOAT*, const char*, const char*) override { } + void addSoundfile(const char*, const char*, Soundfile**) override { } + void addButton(const char* l, FAUSTFLOAT* z) override { zones[l] = z; } + void addCheckButton(const char* l, FAUSTFLOAT* z) override { zones[l] = z; } + void addVerticalSlider( + const char* l, FAUSTFLOAT* z, FAUSTFLOAT, FAUSTFLOAT, FAUSTFLOAT, + FAUSTFLOAT) override + { + zones[l] = z; + } + void addHorizontalSlider( + const char* l, FAUSTFLOAT* z, FAUSTFLOAT i, FAUSTFLOAT mn, FAUSTFLOAT mx, + FAUSTFLOAT st) override + { + addVerticalSlider(l, z, i, mn, mx, st); + } + void addNumEntry( + const char* l, FAUSTFLOAT* z, FAUSTFLOAT i, FAUSTFLOAT mn, FAUSTFLOAT mx, + FAUSTFLOAT st) override + { + addVerticalSlider(l, z, i, mn, mx, st); + } + void addHorizontalBargraph( + const char* l, FAUSTFLOAT* z, FAUSTFLOAT, FAUSTFLOAT) override + { + zones[l] = z; + } + void addVerticalBargraph(const char* l, FAUSTFLOAT* z, FAUSTFLOAT, FAUSTFLOAT) override + { + zones[l] = z; + } +}; + +class FakeProc : public QObject +{ +public: + Process::Inlets m_inlets; + Process::Outlets m_outlets; + + Process::Inlets& inlets() { return m_inlets; } + Process::Outlets& outlets() { return m_outlets; } + void controlAdded(const Id&) { } +}; + +std::vector ramp(int n) +{ + std::vector v(n); + for(int i = 0; i < n; i++) + v[i] = 0.001 * i - 0.25; + return v; +} +} + +TEST_CASE("Faust JIT: identity and constant gain are sample-exact", "[faust][jit]") +{ + const auto in = ramp(512); + + SECTION("process = _;") + { + Jit fx{"process = _;"}; + REQUIRE(fx.ok()); + CHECK(fx.dsp->getNumInputs() == 1); + CHECK(fx.dsp->getNumOutputs() == 1); + + const auto out = fx.run1(in); + for(int i = 0; i < 512; i++) + REQUIRE(out[i] == Approx(in[i]).margin(1e-15)); + } + + SECTION("process = _ * 0.5;") + { + Jit fx{"process = _ * 0.5;"}; + REQUIRE(fx.ok()); + + const auto out = fx.run1(in); + for(int i = 0; i < 512; i++) + REQUIRE(out[i] == Approx(0.5 * in[i]).margin(1e-15)); + } +} + +TEST_CASE( + "Faust JIT: hslider default and updated values scale the signal", + "[faust][jit][controls]") +{ + Jit fx{"process = _ * hslider(\"level\", 0.5, 0, 1, 0.01);"}; + REQUIRE(fx.ok()); + + ZoneUI ui; + fx.dsp->buildUserInterface(&ui); + REQUIRE(ui.zones.count("level") == 1); + + // init() applied the declared default + auto* zone = ui.zones["level"]; + CHECK(*zone == Approx(0.5).margin(1e-12)); + + const auto in = ramp(256); + auto out = fx.run1(in); + for(int i = 0; i < 256; i++) + REQUIRE(out[i] == Approx(0.5 * in[i]).margin(1e-15)); + + // moving the control immediately changes the gain + *zone = 0.25; + out = fx.run1(in); + for(int i = 0; i < 256; i++) + REQUIRE(out[i] == Approx(0.25 * in[i]).margin(1e-15)); +} + +TEST_CASE( + "Faust JIT: recursive one-pole matches the reference recurrence", + "[faust][jit][filter]") +{ + // y[n] = x[n] + 0.5 * y[n-1] + Jit fx{"process = + ~ *(0.5);"}; + REQUIRE(fx.ok()); + + std::mt19937 rng{1234}; + std::uniform_real_distribution dist{-1.0, 1.0}; + std::vector in(512); + for(auto& s : in) + s = dist(rng); + + const auto out = fx.run1(in); + + double y = 0; + for(int i = 0; i < 512; i++) + { + y = in[i] + 0.5 * y; + REQUIRE(out[i] == Approx(y).margin(1e-12)); + } +} + +TEST_CASE("Faust JIT: multi-channel routing is independent", "[faust][jit]") +{ + Jit fx{"process = *(2), *(3);"}; + REQUIRE(fx.ok()); + REQUIRE(fx.dsp->getNumInputs() == 2); + REQUIRE(fx.dsp->getNumOutputs() == 2); + + const auto in0 = ramp(256); + auto in1 = in0; + for(auto& s : in1) + s = -s + 0.125; + + std::vector out0(256), out1(256); + double* ins[2] = {const_cast(in0.data()), in1.data()}; + double* outs[2] = {out0.data(), out1.data()}; + fx.dsp->compute(256, ins, outs); + + for(int i = 0; i < 256; i++) + { + REQUIRE(out0[i] == Approx(2.0 * in0[i]).margin(1e-15)); + REQUIRE(out1[i] == Approx(3.0 * in1[i]).margin(1e-15)); + } +} + +TEST_CASE( + "Faust::UI maps the dsp controls onto score inlets/outlets", + "[faust][plugin][controls]") +{ + Jit fx{"process = _ * hslider(\"level\", 0.5, 0, 1, 0.01) " + "* checkbox(\"on\") * button(\"bang\") : hbargraph(\"outv\", 0, 1);"}; + REQUIRE(fx.ok()); + + FakeProc proc; + Faust::UI ui{proc}; + fx.dsp->buildUserInterface(&ui); + + REQUIRE(proc.m_inlets.size() == 3); + + Process::FloatSlider* slider{}; + Process::Toggle* toggle{}; + Process::Button* button{}; + for(auto* inl : proc.m_inlets) + { + if(auto* s = dynamic_cast(inl)) + slider = s; + else if(auto* t = dynamic_cast(inl)) + toggle = t; + else if(auto* b = dynamic_cast(inl)) + button = b; + } + + REQUIRE(slider); + CHECK(slider->name() == "level"); + CHECK(slider->getMin() == Approx(0.f)); + CHECK(slider->getMax() == Approx(1.f)); + CHECK(ossia::convert(slider->value()) == Approx(0.5f)); + + REQUIRE(toggle); + CHECK(toggle->name() == "on"); + + REQUIRE(button); + CHECK(button->name() == "bang"); + + // The bargraph became a value outlet + REQUIRE(proc.m_outlets.size() == 1); + auto* bar = dynamic_cast(proc.m_outlets[0]); + REQUIRE(bar); + CHECK(bar->name() == "outv"); +} + +TEST_CASE( + "Faust::UpdateUI reuses, renames and replaces existing inlets", + "[faust][plugin][controls]") +{ + FakeProc proc; + proc.m_inlets.push_back(new Process::FloatSlider{ + 0.f, 1.f, 0.f, "audio-placeholder", getStrongId(proc.m_inlets), &proc}); + + // 1) initial build: one slider + Jit fx1{"process = _ * hslider(\"level\", 0.5, 0, 1, 0.01);"}; + REQUIRE(fx1.ok()); + { + Faust::UI ui{proc}; + fx1.dsp->buildUserInterface(&ui); + REQUIRE(proc.m_inlets.size() == 2); + } + auto* first = dynamic_cast(proc.m_inlets[1]); + REQUIRE(first); + + // 2) same control type, new name/range/init: the inlet object is reused + Jit fx2{"process = _ * hslider(\"volume\", 0.75, 0, 2, 0.01);"}; + REQUIRE(fx2.ok()); + { + Process::Inlets toRemove; + Process::Outlets toRemoveO; + Faust::UpdateUI ui{proc, toRemove, toRemoveO}; + fx2.dsp->buildUserInterface(&ui); + + CHECK(toRemove.empty()); + REQUIRE(proc.m_inlets.size() == 2); + auto* updated = dynamic_cast(proc.m_inlets[1]); + REQUIRE(updated == first); // reused in place + CHECK(updated->name() == "volume"); + CHECK(updated->getMin() == Approx(0.f)); + CHECK(updated->getMax() == Approx(2.f)); + CHECK(ossia::convert(updated->value()) == Approx(0.75f)); + } + + Jit fx3{"process = _ * button(\"mute\");"}; + REQUIRE(fx3.ok()); + { + Process::Inlets toRemove; + Process::Outlets toRemoveO; + Faust::UpdateUI ui{proc, toRemove, toRemoveO}; + fx3.dsp->buildUserInterface(&ui); + + REQUIRE(proc.m_inlets.size() == 2); + CHECK(toRemove.size() == 1); + CHECK(toRemove[0] == first); + auto* btn = dynamic_cast(proc.m_inlets[1]); + REQUIRE(btn); + CHECK(btn->name() == "mute"); + } +} + +TEST_CASE( + "Faust JIT: stdfaust.lib import (oscillator frequency, lowpass transfer " + "function)", + "[faust][jit][stdlib]") +{ + constexpr int FS = 48000; + + Jit osc{"import(\"stdfaust.lib\"); process = os.osc(440.);", FS}; + if(!osc.ok()) + { + WARN("libfaust could not resolve stdfaust.lib on this system, skipping: " + + osc.err); + return; + } + + SECTION("os.osc(440) produces a 440 Hz unit sine") + { + REQUIRE(osc.dsp->getNumInputs() == 0); + REQUIRE(osc.dsp->getNumOutputs() == 1); + + const auto out = osc.gen1(FS / 10); // 100 ms + // 44 periods -> 88 sign changes + int crossings = 0; + for(int i = 1; i < (int)out.size(); i++) + crossings += (out[i] > 0) != (out[i - 1] > 0); + CHECK(crossings >= 86); + CHECK(crossings <= 90); + + const double peak + = std::abs(*std::max_element(out.begin(), out.end(), [](double a, double b) { + return std::abs(a) < std::abs(b); + })); + CHECK(peak == Approx(1.0).margin(0.01)); + } + + SECTION("fi.lowpass(1, 1000): DC gain 1, -3 dB at cutoff, null at Nyquist") + { + Jit lp{"import(\"stdfaust.lib\"); process = fi.lowpass(1, 1000);", FS}; + REQUIRE(lp.ok()); + + // DC: settles to 1 + const auto dc = lp.run1(std::vector(4800, 1.0)); + CHECK(dc.back() == Approx(1.0).margin(1e-3)); + + // 1 kHz (the cutoff): output RMS = input RMS / sqrt(2) + Jit lp2{"import(\"stdfaust.lib\"); process = fi.lowpass(1, 1000);", FS}; + REQUIRE(lp2.ok()); + std::vector tone(4800); + for(int i = 0; i < 4800; i++) + tone[i] = std::sin(2.0 * M_PI * 1000.0 * i / FS); + const auto lp_out = lp2.run1(tone); + double acc = 0; + for(int i = 2400; i < 4800; i++) // steady state + acc += lp_out[i] * lp_out[i]; + const double out_rms = std::sqrt(acc / 2400.0); + CHECK(out_rms == Approx(0.5).margin(0.02)); // (1/sqrt2) * (1/sqrt2) + + // Nyquist: bilinear-transform lowpass has a zero at fs/2 + Jit lp3{"import(\"stdfaust.lib\"); process = fi.lowpass(1, 1000);", FS}; + REQUIRE(lp3.ok()); + std::vector nyq(4800); + for(int i = 0; i < 4800; i++) + nyq[i] = (i % 2) ? -1.0 : 1.0; + const auto ny_out = lp3.run1(nyq); + double ny_peak = 0; + for(int i = 4700; i < 4800; i++) + ny_peak = std::max(ny_peak, std::abs(ny_out[i])); + CHECK(ny_peak < 0.05); + } +} + +TEST_CASE( + "Faust JIT: malformed programs fail gracefully with an error message", + "[faust][jit][errors]") +{ + SECTION("syntax error") + { + Jit fx{"process = foo(;"}; + CHECK(fx.fac == nullptr); + CHECK(fx.dsp == nullptr); + CHECK(fx.errored()); + } + + SECTION("empty program") + { + Jit fx{""}; + CHECK(fx.dsp == nullptr); + } + + SECTION("unknown identifier") + { + Jit fx{"process = definitely_not_a_faust_function;"}; + CHECK(fx.dsp == nullptr); + CHECK(fx.errored()); + } +} + +TEST_CASE( + "Faust JIT: NaN and denormal input do not crash the jitted code", + "[faust][jit][fuzz]") +{ + Jit fx{"process = _ * 0.5;"}; + REQUIRE(fx.ok()); + + std::vector in(256, std::numeric_limits::quiet_NaN()); + auto out = fx.run1(in); + CHECK(std::isnan(out[0])); + + std::fill(in.begin(), in.end(), 1e-320); + out = fx.run1(in); + for(auto v : out) + CHECK(std::abs(v) <= 1e-320); + + std::fill(in.begin(), in.end(), std::numeric_limits::infinity()); + out = fx.run1(in); + CHECK(std::isinf(out[0])); + + SUCCEED("no crash on hostile input"); +} + +TEST_CASE( + "Faust JIT: repeated compile/destroy cycles are stable", + "[faust][jit][lifecycle]") +{ + for(int i = 0; i < 3; i++) + { + Jit fx{"process = _ * " + std::to_string(i + 1) + ";"}; + REQUIRE(fx.ok()); + const auto out = fx.run1({1.0, 2.0, 3.0}); + CHECK(out[2] == Approx(3.0 * (i + 1)).margin(1e-15)); + } +} diff --git a/tests/unit/FxAudioDspTest.cpp b/tests/unit/FxAudioDspTest.cpp new file mode 100644 index 0000000000..09e02e78c7 --- /dev/null +++ b/tests/unit/FxAudioDspTest.cpp @@ -0,0 +1,436 @@ +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +using Catch::Approx; + +namespace +{ +// Wire a MathAudioFilter node to C input / output channel buffers. +struct filter_harness +{ + Nodes::MathAudioFilter::Node node; + + void + wire(double** ins, double** outs, int channels, double rate = 48000., int frames = 16) + { + node.inputs.audio.samples = ins; + node.inputs.audio.channels = channels; + node.outputs.audio.samples = outs; + node.outputs.audio.channels = channels; + node.prepare(halp::setup{ + .input_channels = channels, + .output_channels = channels, + .frames = frames, + .rate = rate}); + } + + void run(int frames) + { + halp::tick_flicks tk{}; + tk.frames = frames; + node(tk); + } +}; +} + + +TEST_CASE("MathAudioFilter: gain expression should scale every sample", "[fx][audio][exprtk]") +{ + static constexpr int N = 8; + std::array in{1, 2, 3, 4, -1, -2, -3, -4}; + std::array out{}; + double* ins[1]{in.data()}; + double* outs[1]{out.data()}; + + filter_harness h; + h.wire(ins, outs, 1); + h.node.inputs.expr.value = "out[0] := x[0] * 0.5;"; + h.run(N); + + for(int i = 0; i < N; i++) + CHECK(out[i] == in[i] * 0.5); + // In particular: + CHECK(out[0] == 0.5); + CHECK(out[1] == 1.0); + CHECK(out[4] == -0.5); +} + +TEST_CASE("MathAudioFilter: the a/b/c params are visible in the expression", "[fx][audio][exprtk]") +{ + static constexpr int N = 4; + std::array in{1, 2, 3, 4}; + std::array out{}; + double* ins[1]{in.data()}; + double* outs[1]{out.data()}; + + filter_harness h; + h.wire(ins, outs, 1); + h.node.inputs.expr.value = "out[0] := x[0] * a + b;"; + h.node.inputs.a.value = 0.25f; + h.node.inputs.b.value = 1.f; + h.run(N); + + const auto f = [](double x) { return x * double(0.25f) + 1.; }; + for(int i = 0; i < N; i++) + CHECK(out[i] == Approx(f(in[i])).epsilon(1e-12)); +} + +TEST_CASE("MathAudioFilter: 2-tap FIR y[n] = (x[n] + x[n-1])/2 impulse response", "[fx][audio][exprtk]") +{ + static constexpr int N = 6; + std::array in{1, 0, 0, 0, 0, 0}; // unit impulse + std::array out{}; + double* ins[1]{in.data()}; + double* outs[1]{out.data()}; + + filter_harness h; + h.wire(ins, outs, 1); + h.node.inputs.expr.value = "out[0] := 0.5 * x[0] + 0.5 * px[0];"; + h.run(N); + + CHECK(out[0] == 0.5); + CHECK(out[1] == 0.5); + for(int i = 2; i < N; i++) + CHECK(out[i] == 0.0); +} + +TEST_CASE("MathAudioFilter: t and fs symbols hold sample index and sample rate", "[fx][audio][exprtk]") +{ + static constexpr int N = 5; + std::array in{}; + std::array out{}; + double* ins[1]{in.data()}; + double* outs[1]{out.data()}; + + filter_harness h; + h.wire(ins, outs, 1, 44100.); + h.node.inputs.expr.value = "out[0] := t + fs;"; + h.run(N); + + for(int i = 0; i < N; i++) + CHECK(out[i] == 44100. + i); +} + +TEST_CASE("MathAudioFilter: independent per-channel expressions", "[fx][audio][exprtk]") +{ + static constexpr int N = 4; + std::array l{1, 2, 3, 4}; + std::array r{10, 20, 30, 40}; + std::array ol{}, or_{}; + double* ins[2]{l.data(), r.data()}; + double* outs[2]{ol.data(), or_.data()}; + + filter_harness h; + h.wire(ins, outs, 2); + h.node.inputs.expr.value = "out[0] := x[0] * 2; out[1] := x[1] * 3;"; + h.run(N); + + // Channels stay independent, every sample is processed with its own input. + for(int i = 0; i < N; i++) + { + CHECK(ol[i] == l[i] * 2.); + CHECK(or_[i] == r[i] * 3.); + } +} + +TEST_CASE("MathAudioFilter: edge cases stay safe", "[fx][audio][exprtk][fuzz]") +{ + filter_harness h; + + SECTION("zero channels: early return, no crash") + { + h.wire(nullptr, nullptr, 0); + h.node.inputs.expr.value = "out[0] := x[0];"; + h.run(16); + SUCCEED("no crash with 0 channels"); + } + + SECTION("zero-length buffer: no crash, no write") + { + std::array in{1}; + std::array out{-1}; + double* ins[1]{in.data()}; + double* outs[1]{out.data()}; + h.wire(ins, outs, 1); + h.node.inputs.expr.value = "out[0] := x[0];"; + h.run(0); + CHECK(out[0] == -1.); // untouched + } + + SECTION("single sample") + { + std::array in{0.5}; + std::array out{}; + double* ins[1]{in.data()}; + double* outs[1]{out.data()}; + h.wire(ins, outs, 1); + h.node.inputs.expr.value = "out[0] := x[0] * 4;"; + h.run(1); + CHECK(out[0] == 2.0); + } + + SECTION("NaN / infinity / denormal input does not crash") + { + std::array in{ + std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), + std::numeric_limits::denorm_min(), 1.}; + std::array out{}; + double* ins[1]{in.data()}; + double* outs[1]{out.data()}; + h.wire(ins, outs, 1); + h.node.inputs.expr.value = "out[0] := x[0] * 0.5;"; + h.run(4); + CHECK(std::isnan(out[0])); + CHECK(std::isinf(out[1])); + CHECK(out[1] > 0.); + CHECK(out[2] == std::numeric_limits::denorm_min() * 0.5); + CHECK(out[3] == 0.5); + } + + SECTION("invalid expression: node refuses to run, output untouched") + { + std::array in{1, 2}; + std::array out{-7, -7}; + double* ins[1]{in.data()}; + double* outs[1]{out.data()}; + h.wire(ins, outs, 1); + h.node.inputs.expr.value = "this is not exprtk ("; + h.run(2); + CHECK(out[0] == -7.); + CHECK(out[1] == -7.); + } +} + +// --------------------------------------------------------------------------- + +namespace +{ +struct env_capture +{ + std::vector values; + int calls = 0; + + static void receive(void* self, Nodes::multichannel_output_type v) + { + auto& e = *static_cast(self); + e.calls++; + e.values.clear(); + if(auto* f = ossia_variant_alias::get_if(&v)) + e.values.push_back(*f); + else if(auto* vec = ossia_variant_alias::get_if(&v)) + e.values.assign(vec->begin(), vec->end()); + } +}; +} + +TEST_CASE("Envelope: block peak and RMS values", "[fx][audio][envelope]") +{ + Nodes::Envelope::Node node; + env_capture rms, peak; + node.outputs.rms.call.context = &rms; + node.outputs.rms.call.function = &env_capture::receive; + node.outputs.peak.call.context = &peak; + node.outputs.peak.call.function = &env_capture::receive; + + SECTION("mono: peak is the exact absolute maximum") + { + std::array in{0.25, -0.75, 0.5, 0.}; + double* ins[1]{in.data()}; + node.inputs.audio.samples = ins; + node.inputs.audio.channels = 1; + node(4); + + REQUIRE(peak.calls == 1); + REQUIRE(peak.values.size() == 1); + CHECK(peak.values[0] == 0.75f); + } + + SECTION("mono: RMS of a constant block is the textbook value") + { + static constexpr int N = 16; + std::array in; + in.fill(0.5); + double* ins[1]{in.data()}; + node.inputs.audio.samples = ins; + node.inputs.audio.channels = 1; + node(N); + + REQUIRE(rms.calls == 1); + REQUIRE(rms.values.size() == 1); + CHECK(rms.values[0] == Approx(0.5).epsilon(1e-6)); + } + + SECTION("stereo: per-channel vectors, exact per-channel peaks") + { + std::array l{1., 0., 0., 0.}; + std::array r{0., -2., 0., 0.}; + double* ins[2]{l.data(), r.data()}; + node.inputs.audio.samples = ins; + node.inputs.audio.channels = 2; + node(4); + + REQUIRE(peak.values.size() == 2); + CHECK(peak.values[0] == 1.f); + CHECK(peak.values[1] == 2.f); + + REQUIRE(rms.values.size() == 2); + // rms = sqrt(sum(x^2)/N): impulse of amplitude A over N samples -> A/sqrt(N) + CHECK(rms.values[0] == Approx(1. / 2.).epsilon(1e-6)); + CHECK(rms.values[1] == Approx(2. / 2.).epsilon(1e-6)); + } + + SECTION("silence: exactly zero rms and peak") + { + std::array in{}; + double* ins[1]{in.data()}; + node.inputs.audio.samples = ins; + node.inputs.audio.channels = 1; + node(8); + CHECK(rms.values.at(0) == 0.f); + CHECK(peak.values.at(0) == 0.f); + } + + SECTION("zero channels: no callback, no crash") + { + node.inputs.audio.samples = nullptr; + node.inputs.audio.channels = 0; + node(64); + CHECK(rms.calls == 0); + CHECK(peak.calls == 0); + } + + SECTION("zero-length block: defined zero output") + { + std::array in{1.}; + double* ins[1]{in.data()}; + node.inputs.audio.samples = ins; + node.inputs.audio.channels = 1; + node(0); + REQUIRE(rms.calls == 1); + CHECK(rms.values.at(0) == 0.f); + CHECK(peak.values.at(0) == 0.f); + } +} + +// --------------------------------------------------------------------------- + +namespace +{ +constexpr double flicks_per_second = 705600000.; + +halp::tick_flicks make_flicks_tick(int64_t start, int64_t end, int frames) +{ + halp::tick_flicks tk{}; + tk.frames = frames; + tk.start_in_flicks = start; + tk.end_in_flicks = end; + return tk; +} + +Nodes::LFO::v2::Node make_lfo(float freq, float ampl, float offset, auto waveform) +{ + Nodes::LFO::v2::Node lfo; + lfo.inputs.freq.value = freq; + lfo.inputs.ampl.value = ampl; + lfo.inputs.offset.value = offset; + lfo.inputs.jitter.value = 0.f; + lfo.inputs.phase.value = 0.f; + lfo.inputs.quant.value = 0.f; // free-running (the quantifier defaults to 1/4!) + lfo.inputs.waveform.value = waveform; + return lfo; +} +} + +TEST_CASE("LFO v2: deterministic waveform math (jitter = 0)", "[fx][lfo]") +{ + using W = Control::Widgets::Waveform; + // 0.1 s per tick at 1 Hz -> phase delta = 0.2*pi per tick + const int64_t dt = int64_t(flicks_per_second / 10); + const double ph_delta = 0.1 * 1. * 2. * std::numbers::pi; + + SECTION("sine: first tick starts at phase 0, then accumulates ph_delta") + { + auto lfo = make_lfo(1.f, 2.f, 0.5f, W::Sin); + + lfo(make_flicks_tick(0, dt, 64)); + REQUIRE(lfo.outputs.out.value.has_value()); + CHECK(*lfo.outputs.out.value == Approx(0.5).margin(1e-6)); // 2*sin(0)+0.5 + + lfo(make_flicks_tick(dt, 2 * dt, 64)); + CHECK( + *lfo.outputs.out.value + == Approx(2. * std::sin(ph_delta) + 0.5).margin(1e-5)); + + lfo(make_flicks_tick(2 * dt, 3 * dt, 64)); + CHECK( + *lfo.outputs.out.value + == Approx(2. * std::sin(2 * ph_delta) + 0.5).margin(1e-5)); + } + + SECTION("the phase control offsets the oscillator phase directly (radians)") + { + auto lfo = make_lfo(1.f, 1.f, 0.f, W::Sin); + lfo.inputs.phase.value = 0.25f; + lfo(make_flicks_tick(0, dt, 64)); + CHECK(*lfo.outputs.out.value == Approx(std::sin(0.25)).margin(1e-6)); + } + + SECTION("square: sign of the sine, scaled by ampl and offset") + { + auto lfo = make_lfo(1.f, 0.5f, 0.25f, W::Square); + // At phase 0: sin(0) = 0 -> not > 0 -> -1. + lfo(make_flicks_tick(0, dt, 64)); + CHECK(*lfo.outputs.out.value == Approx(0.5 * -1. + 0.25).margin(1e-6)); + // At phase 0.2*pi: sin > 0 -> +1. + lfo(make_flicks_tick(dt, 2 * dt, 64)); + CHECK(*lfo.outputs.out.value == Approx(0.5 * 1. + 0.25).margin(1e-6)); + } + + SECTION("saw: atan(tan(ph))/(pi/2) is linear in ph in ]-pi/2, pi/2[") + { + auto lfo = make_lfo(1.f, 1.f, 0.f, W::Saw); + lfo(make_flicks_tick(0, dt, 64)); // ph = 0 -> 0 + CHECK(*lfo.outputs.out.value == Approx(0.).margin(1e-6)); + lfo(make_flicks_tick(dt, 2 * dt, 64)); // ph = 0.2*pi -> 0.4 + CHECK(*lfo.outputs.out.value == Approx(0.4).margin(1e-5)); + } + + SECTION("triangle: asin(sin(ph))/(pi/2), linear on the rising quarter-wave") + { + auto lfo = make_lfo(1.f, 1.f, 0.f, W::Triangle); + lfo(make_flicks_tick(dt, 2 * dt, 64)); // first tick: ph = 0... but phase + CHECK(*lfo.outputs.out.value == Approx(0.).margin(1e-6)); + lfo(make_flicks_tick(2 * dt, 3 * dt, 64)); // ph = 0.2*pi < pi/2 -> 0.4 + CHECK(*lfo.outputs.out.value == Approx(0.4).margin(1e-5)); + } + + SECTION("frequency scales the phase increment") + { + auto lfo = make_lfo(2.5f, 1.f, 0.f, W::Sin); + lfo(make_flicks_tick(0, dt, 64)); + lfo(make_flicks_tick(dt, 2 * dt, 64)); + CHECK( + *lfo.outputs.out.value + == Approx(std::sin(2.5 * ph_delta)).margin(1e-5)); + } + + SECTION("zero-length tick: no phase advance") + { + auto lfo = make_lfo(1.f, 1.f, 0.f, W::Sin); + lfo(make_flicks_tick(0, 0, 0)); + CHECK(*lfo.outputs.out.value == Approx(0.).margin(1e-9)); + lfo(make_flicks_tick(0, 0, 0)); + CHECK(*lfo.outputs.out.value == Approx(0.).margin(1e-9)); + } +} diff --git a/tests/unit/MappingTest.cpp b/tests/unit/MappingTest.cpp new file mode 100644 index 0000000000..98ea86de11 --- /dev/null +++ b/tests/unit/MappingTest.cpp @@ -0,0 +1,217 @@ +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +using Catch::Approx; + +namespace +{ +// Mirror of Mapping::RecreateOnPlay::Component::on_curveChanged_impl2. +std::shared_ptr> +make_transfer_curve(const Mapping::ProcessModel& proc) +{ + const double xmin = proc.sourceMin(); + const double xmax = proc.sourceMax(); + const double ymin = proc.targetMin(); + const double ymax = proc.targetMax(); + + auto scale_x = [=](double val) -> float { return val * (xmax - xmin) + xmin; }; + auto scale_y = [=](double val) -> float { return val * (ymax - ymin) + ymin; }; + + auto segt_data = proc.curve().sortedSegments(); + REQUIRE(!segt_data.empty()); + return Engine::score_to_ossia::curve(scale_x, scale_y, segt_data, {}); +} +} + +TEST_CASE("Mapping scales source domain onto target domain", "[mapping][values]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto doc = score::test::new_document(ctx); + REQUIRE(doc); + QObject* owner = new QObject{&doc->model()}; + + Mapping::ProcessModel proc{ + TimeVal::fromMsecs(1000.), Id{1}, owner}; + + // Fresh mapping: identity curve on [0,1] -> [0,1]. + REQUIRE(proc.curve().segments().size() == 1); + CHECK(proc.sourceMin() == 0.); + CHECK(proc.sourceMax() == 1.); + CHECK(proc.targetMin() == 0.); + CHECK(proc.targetMax() == 1.); + + proc.setSourceMin(0.); + proc.setSourceMax(100.); + proc.setTargetMin(0.); + proc.setTargetMax(1.); + + auto tf = make_transfer_curve(proc); + // out(in) = in / 100 for the default linear curve. + CHECK(tf->value_at(0.f) == Approx(0.f).margin(1e-6)); + CHECK(tf->value_at(25.f) == Approx(0.25f).margin(1e-6)); + CHECK(tf->value_at(50.f) == Approx(0.5f).margin(1e-6)); + CHECK(tf->value_at(100.f) == Approx(1.f).margin(1e-6)); + + // Out-of-domain inputs clamp to the curve boundaries. + CHECK(tf->value_at(-10.f) == Approx(0.f).margin(1e-6)); + CHECK(tf->value_at(200.f) == Approx(1.f).margin(1e-6)); + + // Shifted domains: [50, 150] -> [-1, 1]: out(in) = (in-50)/100 * 2 - 1. + proc.setSourceMin(50.); + proc.setSourceMax(150.); + proc.setTargetMin(-1.); + proc.setTargetMax(1.); + auto tf2 = make_transfer_curve(proc); + CHECK(tf2->value_at(50.f) == Approx(-1.f).margin(1e-6)); + CHECK(tf2->value_at(100.f) == Approx(0.f).margin(1e-6)); + CHECK(tf2->value_at(125.f) == Approx(0.5f).margin(1e-6)); + CHECK(tf2->value_at(150.f) == Approx(1.f).margin(1e-6)); + + // Inverted target domain: [0,100] -> [1,0]: out(in) = 1 - in/100. + proc.setSourceMin(0.); + proc.setSourceMax(100.); + proc.setTargetMin(1.); + proc.setTargetMax(0.); + auto tf3 = make_transfer_curve(proc); + CHECK(tf3->value_at(25.f) == Approx(0.75f).margin(1e-6)); + CHECK(tf3->value_at(100.f) == Approx(0.f).margin(1e-6)); + }); +} + +TEST_CASE("Mapping applies non-linear curve shapes to the input", "[mapping][values]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto doc = score::test::new_document(ctx); + REQUIRE(doc); + QObject* owner = new QObject{&doc->model()}; + + Mapping::ProcessModel proc{ + TimeVal::fromMsecs(1000.), Id{1}, owner}; + proc.setSourceMin(0.); + proc.setSourceMax(100.); + proc.setTargetMin(0.); + proc.setTargetMax(1.); + + // gamma-2 power curve: out(in) = (in/100)^2. + auto& cm = proc.curve(); + cm.clear(); + auto pw = new Curve::PowerSegment{Id{1}, &cm}; + pw->setStart({0., 0.}); + pw->setEnd({1., 1.}); + pw->gamma = 2.; + cm.addSegment(pw); + + auto tf = make_transfer_curve(proc); + CHECK(tf->value_at(50.f) == Approx(0.25f).margin(1e-6)); + CHECK(tf->value_at(25.f) == Approx(0.0625f).margin(1e-6)); + CHECK(tf->value_at(100.f) == Approx(1.f).margin(1e-6)); + + cm.clear(); + auto up = new Curve::LinearSegment{Id{2}, &cm}; + up->setStart({0., 0.}); + up->setEnd({0.5, 1.}); + auto down = new Curve::LinearSegment{Id{3}, &cm}; + down->setStart({0.5, 1.}); + down->setEnd({1., 0.}); + cm.addSegment(up); + cm.addSegment(down); + + auto tri = make_transfer_curve(proc); + CHECK(tri->value_at(25.f) == Approx(0.5f).margin(1e-6)); + CHECK(tri->value_at(50.f) == Approx(1.f).margin(1e-6)); + CHECK(tri->value_at(75.f) == Approx(0.5f).margin(1e-6)); + }); +} + +TEST_CASE("Mapping serialization round-trips domains and transfer values", "[mapping][serialization]") +{ + score::test::run_in_app([](const score::GUIApplicationContext& ctx) { + auto doc = score::test::new_document(ctx); + REQUIRE(doc); + QObject* owner = new QObject{&doc->model()}; + + Mapping::ProcessModel proc{ + TimeVal::fromMsecs(1000.), Id{1}, owner}; + proc.setSourceMin(-10.); + proc.setSourceMax(10.); + proc.setTargetMin(100.); + proc.setTargetMax(200.); + + auto& cm = proc.curve(); + cm.clear(); + auto pw = new Curve::PowerSegment{Id{1}, &cm}; + pw->setStart({0., 0.}); + pw->setEnd({1., 1.}); + pw->gamma = 2.; + cm.addSegment(pw); + + auto& pl = ctx.interfaces(); + const Process::ProcessModel& as_base = proc; + + const auto check_clone = [&](Process::ProcessModel* clone) { + REQUIRE(clone); + auto mapping = dynamic_cast(clone); + REQUIRE(mapping); + + CHECK(mapping->sourceMin() == Approx(-10.).margin(1e-9)); + CHECK(mapping->sourceMax() == Approx(10.).margin(1e-9)); + CHECK(mapping->targetMin() == Approx(100.).margin(1e-9)); + CHECK(mapping->targetMax() == Approx(200.).margin(1e-9)); + + // out(in) = 100 + 100 * ((in + 10) / 20)^2 + auto tf = make_transfer_curve(*mapping); + CHECK(tf->value_at(-10.f) == Approx(100.f).margin(1e-4)); + CHECK(tf->value_at(0.f) == Approx(125.f).margin(1e-4)); + CHECK(tf->value_at(10.f) == Approx(200.f).margin(1e-4)); + }; + + // DataStream + { + const QByteArray bytes = score::marshall(as_base); + auto clone = deserialize_interface( + pl, DataStream::Deserializer{bytes}, doc->context(), owner); + check_clone(clone); + const Process::ProcessModel& clone_base = *clone; + CHECK(score::marshall(clone_base) == bytes); + delete clone; + } + + // JSON + { + JSONReader reader; + reader.readFrom(as_base); + const rapidjson::Document jdoc = toValue(reader); + JSONWriter writer{jdoc}; + auto clone = deserialize_interface(pl, writer, doc->context(), owner); + check_clone(clone); + + JSONReader reader2; + reader2.readFrom(static_cast(*clone)); + CHECK(reader2.toByteArray() == reader.toByteArray()); + delete clone; + } + }); +} diff --git a/tests/unit/MidiMessageTest.cpp b/tests/unit/MidiMessageTest.cpp new file mode 100644 index 0000000000..585a771874 --- /dev/null +++ b/tests/unit/MidiMessageTest.cpp @@ -0,0 +1,652 @@ +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include + +#include + +#include +#include + +namespace +{ +struct ScopedIgnoreSigtrap +{ + void (*prev)(int); + ScopedIgnoreSigtrap() + : prev{std::signal(SIGTRAP, SIG_IGN)} + { + } + ~ScopedIgnoreSigtrap() { std::signal(SIGTRAP, prev); } +}; + +struct TestApplication final : public score::ApplicationInterface +{ + struct GuiApp + { + int argc = 1; + char arg0[5] = "test"; + char* argv[2] = {arg0, nullptr}; + QGuiApplication app; + GuiApp() + : app{(qputenv("QT_QPA_PLATFORM", "offscreen"), argc), argv} + { + } + } m_gui; + + score::ApplicationComponentsData m_compData; + score::ApplicationComponents m_components{m_compData}; + score::ApplicationSettings m_appSettings; + score::DocumentList m_documents; + std::vector> m_settings; + score::ApplicationContext m_ctx{m_appSettings, m_components, m_documents, m_settings}; + + TestApplication() + { + m_instance = this; + + auto ports = std::make_unique(); + ports->insert(std::make_unique>()); + m_compData.factories.emplace(ports->interfaceKey(), std::move(ports)); + } + + const score::ApplicationContext& context() const override { return m_ctx; } + const score::ApplicationComponents& components() const override + { + return m_components; + } +}; + +TestApplication& testApp() +{ + static TestApplication app; + return app; +} + +// Decode a MIDI 2.0 UMP channel-voice packet produced by ossia's midi node. +struct DecodedUmp +{ + uint8_t status{}; + uint8_t channel{}; + uint8_t note{}; + uint16_t velocity16{}; +}; + +DecodedUmp decode(const libremidi::ump& u) +{ + return DecodedUmp{ + cmidi2_ump_get_status_code(u.data), cmidi2_ump_get_channel(u.data), + cmidi2_ump_get_midi2_note_note(u.data), + cmidi2_ump_get_midi2_note_velocity(u.data)}; +} +} + +TEST_CASE("libremidi channel-voice byte encoding", "[midi][message]") +{ + using libremidi::channel_events; + using libremidi::message_type; + + SECTION("note on: status nibble 0x9, channel in low nibble") + { + const auto m = channel_events::note_on(1, 60, 100); + REQUIRE(m.size() == 3); + CHECK(m[0] == 0x90); + CHECK(m[1] == 60); + CHECK(m[2] == 100); + CHECK(m.get_message_type() == message_type::NOTE_ON); + CHECK(m.get_channel() == 1); + CHECK(m.uses_channel(1)); + CHECK(!m.uses_channel(2)); + CHECK(m.is_note_on_or_off()); + } + + SECTION("channel boundaries: 1..16 map to low nibble 0..15, out-of-range clamps") + { + CHECK(channel_events::note_on(1, 0, 0)[0] == 0x90); + CHECK(channel_events::note_on(16, 0, 0)[0] == 0x9F); + CHECK(channel_events::note_on(16, 0, 0).get_channel() == 16); + // Out-of-range channels are clamped, not wrapped + CHECK(channel_events::note_on(0, 0, 0)[0] == 0x90); + CHECK(channel_events::note_on(17, 0, 0)[0] == 0x9F); + } + + SECTION("note off") + { + const auto m = channel_events::note_off(3, 64, 33); + CHECK(m[0] == 0x82); + CHECK(m[1] == 64); + CHECK(m[2] == 33); + CHECK(m.get_message_type() == message_type::NOTE_OFF); + CHECK(m.get_channel() == 3); + CHECK(m.is_note_on_or_off()); + } + + SECTION("note on with velocity 0 still has NOTE_ON status byte") + { + const auto m = channel_events::note_on(1, 60, 0); + CHECK(m[0] == 0x90); + CHECK(m[2] == 0); + CHECK(m.get_message_type() == message_type::NOTE_ON); + } + + SECTION("control change") + { + const auto m = channel_events::control_change(10, 7, 127); + REQUIRE(m.size() == 3); + CHECK(m[0] == 0xB9); + CHECK(m[1] == 7); + CHECK(m[2] == 127); + CHECK(m.get_message_type() == message_type::CONTROL_CHANGE); + CHECK(m.get_channel() == 10); + } + + SECTION("program change is a two-byte message") + { + const auto m = channel_events::program_change(2, 42); + REQUIRE(m.size() == 2); + CHECK(m[0] == 0xC1); + CHECK(m[1] == 42); + CHECK(m.get_message_type() == message_type::PROGRAM_CHANGE); + CHECK(m.get_channel() == 2); + } + + SECTION("pitch bend: 14-bit value split into 7-bit LSB / MSB") + { + const auto center = channel_events::pitch_bend(1, 8192); + REQUIRE(center.size() == 3); + CHECK(center[0] == 0xE0); + CHECK(center[1] == 0x00); + CHECK(center[2] == 0x40); + CHECK(center.get_message_type() == message_type::PITCH_BEND); + + const auto minimum = channel_events::pitch_bend(1, 0); + CHECK(minimum[1] == 0x00); + CHECK(minimum[2] == 0x00); + + const auto maximum = channel_events::pitch_bend(1, 16383); + CHECK(maximum[1] == 0x7F); + CHECK(maximum[2] == 0x7F); + + // 14-bit reassembly round-trips + const int v = 0x1234; + const auto m = channel_events::pitch_bend(4, v); + CHECK(((m[2] & 0x7F) << 7 | (m[1] & 0x7F)) == v); + CHECK(m.get_channel() == 4); + } + + SECTION("meta events are not channel messages") + { + const auto m = libremidi::meta_events::end_of_track(); + CHECK(m.is_meta_event()); + CHECK(m.get_channel() == 0); + CHECK(m.get_meta_event_type() == libremidi::meta_event_type::END_OF_TRACK); + } +} + +TEST_CASE("Midi note model", "[midi][model]") +{ + SECTION("NoteData fields and end()") + { + Midi::NoteData d{0.25, 0.5, 60, 100}; + CHECK(d.start() == 0.25); + CHECK(d.duration() == 0.5); + CHECK(d.end() == 0.75); + CHECK(d.pitch() == 60); + CHECK(d.velocity() == 100); + } + + SECTION("NoteComparator orders by start time") + { + Midi::NoteData early{0.1, 0.2, 70, 90}; + Midi::NoteData late{0.5, 0.1, 50, 90}; + Midi::NoteComparator cmp; + CHECK(cmp(early, late)); + CHECK(!cmp(late, early)); + CHECK(cmp(early, 0.4)); + } + + SECTION("Note scaling multiplies start and duration") + { + Midi::Note n{Id{1}, Midi::NoteData{0.2, 0.4, 65, 80}, nullptr}; + n.scale(0.5); + CHECK(n.start() == 0.1); + CHECK(n.duration() == 0.2); + CHECK(n.pitch() == 65); + CHECK(n.velocity() == 80); + } +} + +TEST_CASE("Midi note serialization round-trip", "[midi][serialization]") +{ + testApp(); + + Midi::Note src{Id{7}, Midi::NoteData{0.25, 0.5, 61, 101}, nullptr}; + + SECTION("DataStream") + { + const QByteArray arr = score::marshall(src); + Midi::Note dst{DataStream::Deserializer{arr}, nullptr}; + CHECK(dst.id().val() == 7); + CHECK(dst.start() == 0.25); + CHECK(dst.duration() == 0.5); + CHECK(dst.pitch() == 61); + CHECK(dst.velocity() == 101); + } + + SECTION("JSON") + { + JSONReader r; + r.readFrom(src); + const rapidjson::Document doc = readJson(r.toByteArray()); + Midi::Note dst{JSONObject::Deserializer{doc}, nullptr}; + CHECK(dst.id().val() == 7); + CHECK(dst.start() == 0.25); + CHECK(dst.duration() == 0.5); + CHECK(dst.pitch() == 61); + CHECK(dst.velocity() == 101); + } +} + +TEST_CASE("Midi::ProcessModel serialization round-trip", "[midi][serialization]") +{ + testApp(); + + Midi::ProcessModel src{ + TimeVal::fromMsecs(5000), Id{123}, nullptr}; + src.setChannel(11); + src.setRange(48, 84); + src.notes.add( + new Midi::Note{Id{0}, Midi::NoteData{0., 0.25, 36, 64}, &src}); + src.notes.add( + new Midi::Note{Id{1}, Midi::NoteData{0.5, 0.5, 84, 127}, &src}); + + auto checkLoaded = [&](Midi::ProcessModel& dst) { + CHECK(dst.channel() == 11); + CHECK(dst.range() == std::pair{48, 84}); + CHECK(dst.duration() == src.duration()); + + REQUIRE(dst.outlet); + CHECK(dst.outlet->id() == src.outlet->id()); + CHECK(dst.outlet->type() == Process::PortType::Midi); + + REQUIRE(dst.notes.size() == 2); + auto& n0 = dst.notes.at(Id{0}); + CHECK(n0.start() == 0.); + CHECK(n0.duration() == 0.25); + CHECK(n0.pitch() == 36); + CHECK(n0.velocity() == 64); + auto& n1 = dst.notes.at(Id{1}); + CHECK(n1.start() == 0.5); + CHECK(n1.duration() == 0.5); + CHECK(n1.pitch() == 84); + CHECK(n1.velocity() == 127); + }; + + SECTION("DataStream") + { + const QByteArray outer + = score::marshall(static_cast(src)); + + DataStream::Deserializer des{outer}; + QByteArray bundle; + des.stream() >> bundle; + DataStream::Deserializer sub{bundle}; + + SCORE_DEBUG_CHECK_DELIMITER2(sub); + UuidKey key; + TSerializer>::writeTo(sub, key); + SCORE_DEBUG_CHECK_DELIMITER2(sub); + CHECK(key == Metadata::get()); + + Midi::ProcessModel dst{sub, nullptr}; + checkLoaded(dst); + } + + SECTION("JSON") + { + JSONReader r; + r.readFrom(static_cast(src)); + const rapidjson::Document doc = readJson(r.toByteArray()); + JSONObject::Deserializer des{doc}; + Midi::ProcessModel dst{des, nullptr}; + checkLoaded(dst); + } +} + +TEST_CASE("ossia midi node emits note-on / note-off from note data", "[midi][executor]") +{ + ossia::nodes::midi node{8}; + node.set_channel(5); // wire channel = 4 (0-based) + + // Note A: [100, 300), pitch 60, velocity 100 + node.add_note({ossia::time_value{100}, ossia::time_value{200}, 60, 100}); + // Note B: [250, 450), pitch 72, velocity 90 + node.add_note({ossia::time_value{250}, ossia::time_value{200}, 72, 90}); + + ossia::execution_state st; // default: modelToSamplesRatio == 1 + ossia::exec_state_facade fac{&st}; + ossia::graph_node& base = node; + + auto& mp = *node.root_outputs()[0]->target(); + const ossia::time_signature sig{4, 4}; + + // Tick 1: [0, 200) -> note A starts + base.run(ossia::token_request{ossia::time_value{0}, ossia::time_value{200}, + ossia::time_value{1000}, ossia::time_value{0}, 1., sig, + 120.}, + fac); + REQUIRE(mp.messages.size() == 1); + { + const auto m = decode(mp.messages[0]); + CHECK(m.status == CMIDI2_STATUS_NOTE_ON); + CHECK(m.channel == 4); + CHECK(m.note == 60); + CHECK(m.velocity16 / 0x200 == 100); // MIDI2 16-bit velocity downscales to 100 + CHECK(mp.messages[0].timestamp == 100); // note start relative to tick start + } + mp.messages.clear(); + + // Tick 2: [200, 400) -> note A ends at 300, note B starts at 250 + base.run(ossia::token_request{ossia::time_value{200}, ossia::time_value{400}, + ossia::time_value{1000}, ossia::time_value{0}, 1., sig, + 120.}, + fac); + REQUIRE(mp.messages.size() == 2); + { + const auto off = decode(mp.messages[0]); + CHECK(off.status == CMIDI2_STATUS_NOTE_OFF); + CHECK(off.channel == 4); + CHECK(off.note == 60); + CHECK(mp.messages[0].timestamp == 100); // 300 - 200 + + const auto on = decode(mp.messages[1]); + CHECK(on.status == CMIDI2_STATUS_NOTE_ON); + CHECK(on.channel == 4); + CHECK(on.note == 72); + CHECK(on.velocity16 / 0x200 == 90); + CHECK(mp.messages[1].timestamp == 50); // 250 - 200 + } + mp.messages.clear(); + + // Stop: the still-playing note B must be flushed with a note-off + node.mustStop = true; + base.run(ossia::token_request{ossia::time_value{400}, ossia::time_value{500}, + ossia::time_value{1000}, ossia::time_value{0}, 1., sig, + 120.}, + fac); + REQUIRE(mp.messages.size() == 1); + { + const auto off = decode(mp.messages[0]); + CHECK(off.status == CMIDI2_STATUS_NOTE_OFF); + CHECK(off.channel == 4); + CHECK(off.note == 72); + } +} + +TEST_CASE("Patternist pattern parsing", "[midi][pattern]") +{ + using namespace Patternist; + + SECTION("numeric lanes with x / - steps") + { + const auto pats = parsePatterns(QByteArray("36 x-x-\n38 -x-x\n")); + REQUIRE(pats.size() == 1); + const Pattern& p = pats[0]; + CHECK(p.length == 4); + CHECK(p.division == 16); + REQUIRE(p.lanes.size() == 2); + // Lanes are sorted by descending note number + CHECK(p.lanes[0].note == 38); + CHECK( + p.lanes[0].pattern + == std::vector{Note::Rest, Note::Note, Note::Rest, Note::Note}); + CHECK(p.lanes[1].note == 36); + CHECK( + p.lanes[1].pattern + == std::vector{Note::Note, Note::Rest, Note::Note, Note::Rest}); + } + + SECTION("drum names map to General MIDI notes") + { + const auto pats = parsePatterns(QByteArray("BD x---\nSD -x--\nCH --x-\n")); + REQUIRE(pats.size() == 1); + REQUIRE(pats[0].lanes.size() == 3); + CHECK(pats[0].lanes[0].note == 42); // CH closed hihat + CHECK(pats[0].lanes[1].note == 38); // SD snare + CHECK(pats[0].lanes[2].note == 36); // BD bass drum + } + + SECTION("all step characters: 0/- rest, 1/x/X/f/F note, 2 legato") + { + const auto pats = parsePatterns(QByteArray("36 -0x1XfF2\n")); + REQUIRE(pats.size() == 1); + REQUIRE(pats[0].lanes.size() == 1); + CHECK( + pats[0].lanes[0].pattern + == std::vector{ + Note::Rest, Note::Rest, Note::Note, Note::Note, Note::Note, Note::Note, + Note::Note, Note::Legato}); + } + + SECTION("repeated note starts a new pattern") + { + const auto pats = parsePatterns(QByteArray("36 x---\n36 --x-\n")); + REQUIRE(pats.size() == 2); + REQUIRE(pats[0].lanes.size() == 1); + REQUIRE(pats[1].lanes.size() == 1); + CHECK( + pats[0].lanes[0].pattern + == std::vector{Note::Note, Note::Rest, Note::Rest, Note::Rest}); + CHECK( + pats[1].lanes[0].pattern + == std::vector{Note::Rest, Note::Rest, Note::Note, Note::Rest}); + } + + SECTION("accent lane (AC) sorts after regular lanes: note 255 as signed char") + { + const auto pats = parsePatterns(QByteArray("BD x---\nAC ---x\n")); + REQUIRE(pats.size() == 1); + REQUIRE(pats[0].lanes.size() == 2); + CHECK(pats[0].lanes[0].note == 36); + CHECK(pats[0].lanes[1].note == 255); + } + + SECTION("trailing comments after the steps are ignored") + { + const auto pats = parsePatterns(QByteArray("36 x-x- this is a kick\n")); + REQUIRE(pats.size() == 1); + REQUIRE(pats[0].lanes.size() == 1); + CHECK(pats[0].lanes[0].pattern.size() == 4); + } + + SECTION("mismatched lane lengths: lane dropped, but pattern length is updated") + { + const auto pats = parsePatterns(QByteArray("36 x---\n38 xx\n")); + REQUIRE(pats.size() == 1); + REQUIRE(pats[0].lanes.size() == 1); + CHECK(pats[0].lanes[0].note == 36); + CHECK(pats[0].lanes[0].pattern.size() == 4); + CHECK(pats[0].length == 2); // documents the quirk: length != lane size + } + + SECTION("hc means clap (39), not hi conga: second mapping is unreachable") + { + const auto pats = parsePatterns(QByteArray("HC x---\n")); + REQUIRE(pats.size() == 1); + REQUIRE(pats[0].lanes.size() == 1); + CHECK(pats[0].lanes[0].note == 39); + } + + SECTION("garbage input yields no patterns") + { + CHECK(parsePatterns(QByteArray("")).empty()); + CHECK(parsePatterns(QByteArray("hello world\nfoo bar baz\n")).empty()); + CHECK(parsePatterns(QByteArray("ZZ qqqq\n")).empty()); + } +} + +namespace +{ +// Minimal SMF format-0 file: 96 ticks per beat, one track. +std::vector make_test_smf() +{ + std::vector v; + auto push = [&](std::initializer_list l) { v.insert(v.end(), l); }; + + // Header + push({'M', 'T', 'h', 'd', 0, 0, 0, 6, 0, 0, 0, 1, 0, 96}); + + std::vector track{ + // delta 0: note on ch1, pitch 60, vel 100 + 0x00, 0x90, 0x3C, 0x64, + // delta 48: running status (no status byte): pitch 60, vel 0 == note off + 0x30, 0x3C, 0x00, + // delta 0: running status: pitch 64, vel 80 + 0x00, 0x40, 0x50, + // delta 48: explicit note off ch1, pitch 64 + 0x30, 0x80, 0x40, 0x00, + // delta 0: end of track + 0x00, 0xFF, 0x2F, 0x00}; + + push({'M', 'T', 'r', 'k', 0, 0, 0, (uint8_t)track.size()}); + v.insert(v.end(), track.begin(), track.end()); + return v; +} +} + +TEST_CASE("SMF parsing: running status and vel-0 note-off", "[midi][smf]") +{ + libremidi::reader reader; + const auto res = reader.parse(make_test_smf()); + REQUIRE(res != libremidi::reader::invalid); + REQUIRE(reader.tracks.size() == 1); + CHECK(reader.ticksPerBeat == 96); + + std::vector channel_events; + for(const auto& ev : reader.tracks[0]) + if(!ev.m.empty() && !ev.m.is_meta_event()) + channel_events.push_back(ev); + + REQUIRE(channel_events.size() == 4); + + using libremidi::message_type; + // Event 0: note on 60, vel 100 + CHECK(channel_events[0].tick == 0); + CHECK(channel_events[0].m.get_message_type() == message_type::NOTE_ON); + CHECK(channel_events[0].m.get_channel() == 1); + CHECK(channel_events[0].m.bytes[1] == 60); + CHECK(channel_events[0].m.bytes[2] == 100); + + // Event 1: running status reconstructed as a NOTE_ON with velocity 0 + CHECK(channel_events[1].tick == 48); + CHECK(channel_events[1].m.get_message_type() == message_type::NOTE_ON); + CHECK(channel_events[1].m.bytes[1] == 60); + CHECK(channel_events[1].m.bytes[2] == 0); + + // Event 2: running status, second note + CHECK(channel_events[2].tick == 0); + CHECK(channel_events[2].m.get_message_type() == message_type::NOTE_ON); + CHECK(channel_events[2].m.bytes[1] == 64); + CHECK(channel_events[2].m.bytes[2] == 0x50); + + // Event 3: explicit note off + CHECK(channel_events[3].tick == 48); + CHECK(channel_events[3].m.get_message_type() == message_type::NOTE_OFF); + CHECK(channel_events[3].m.bytes[1] == 64); +} + +TEST_CASE("fuzz: malformed inputs are handled gracefully", "[midi][fuzz]") +{ + testApp(); + + SECTION("pattern parser on binary garbage") + { + QByteArray garbage; + uint32_t seed = 0xC0FFEE; + for(int i = 0; i < 4096; i++) + { + seed = seed * 1664525u + 1013904223u; + char c = char(seed >> 24); + garbage.append(i % 37 == 0 ? '\n' : c); + } + const auto pats = Patternist::parsePatterns(garbage); + // Whatever is (not) parsed, every produced pattern must be self-consistent. + for(const auto& p : pats) + for(const auto& lane : p.lanes) + CHECK(!lane.pattern.empty()); + SUCCEED("no crash on garbage pattern input"); + } + + SECTION("SMF reader on corrupt data") + { + const std::vector> inputs{ + {}, + {'M', 'T', 'h'}, + {'M', 'T', 'h', 'd', 0, 0, 0, 6, 0, 0}, + {'M', 'T', 'h', 'd', 0, 0, 0, 6, 0, 0, 0, 1, 0, 96, 'M', 'T', 'r', 'k', 0xFF, + 0xFF, 0xFF, 0xFF, 0x90}, + {0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD, 0xBE, 0xEF}}; + + for(const auto& bytes : inputs) + { + try + { + libremidi::reader r; + (void)r.parse(bytes); + } + catch(...) + { + // exceptions are acceptable, crashes are not + } + } + SUCCEED("no crash on corrupt SMF data"); + } + + SECTION("truncated DataStream note buffers throw instead of crashing") + { + Midi::Note src{Id{3}, Midi::NoteData{0.1, 0.5, 60, 100}, nullptr}; + const QByteArray arr = score::marshall(src); + + ScopedIgnoreSigtrap guard; + for(int len = 0; len < arr.size(); len += 2) + { + const QByteArray cut = arr.left(len); + try + { + Midi::Note dst{DataStream::Deserializer{cut}, nullptr}; + } + catch(const std::exception&) + { + // "Corrupt save file." from checkDelimiter is the expected failure mode + } + + const Midi::NoteData d = score::unmarshall(cut); + CHECK(d.duration() >= 0.); + } + SUCCEED("no crash on truncated note buffers"); + } +} diff --git a/tests/unit/ProcessModelTest.cpp b/tests/unit/ProcessModelTest.cpp new file mode 100644 index 0000000000..88151c544b --- /dev/null +++ b/tests/unit/ProcessModelTest.cpp @@ -0,0 +1,661 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include + +#include + +#include +#include + +namespace +{ +static const int g_ignore_sigtrap = [] { + std::signal(SIGTRAP, SIG_IGN); + return 0; +}(); +struct TestApplication final : public score::ApplicationInterface +{ + score::ApplicationSettings appSettings; + score::ApplicationComponentsData compData; + score::ApplicationComponents comps{compData}; + score::DocumentList docList; + std::vector> settingsVec; + score::ApplicationContext ctx{appSettings, comps, docList, settingsVec}; + Process::PortFactoryList* ports{}; + + TestApplication() + { + appSettings.gui = false; // Skin::instance() -> NoGUI skin (no QFont use) + m_instance = this; + + auto pl = std::make_unique(); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + pl->insert(std::make_unique>()); + ports = pl.get(); + compData.factories.insert( + {Process::PortFactory::static_interfaceKey(), std::move(pl)}); + } + + const score::ApplicationContext& context() const override { return ctx; } + const score::ApplicationComponents& components() const override { return comps; } +}; + +static TestApplication g_app; + +Process::PortFactoryList& portFactories() +{ + return *g_app.ports; +} + +// Round-trips a port through the polymorphic DataStream save/load path. +template +T* dsPortRoundTrip(T& port, QObject* parent) +{ + const QByteArray bytes = score::marshall(static_cast(port)); + auto loaded + = deserialize_interface(portFactories(), DataStream::Deserializer{bytes}, parent); + return dynamic_cast(loaded); +} + +template +T* jsonPortRoundTrip(T& port, QObject* parent) +{ + const auto reader = score::marshall(static_cast(port)); + const QByteArray bytes = reader.toByteArray(); + REQUIRE(!bytes.isEmpty()); + const rapidjson::Document doc = readJson(bytes); + REQUIRE(!doc.HasParseError()); + JSONObject::Deserializer des{doc}; + auto loaded = deserialize_interface(portFactories(), des, parent); + return dynamic_cast(loaded); +} +} + +TEST_CASE("Port construction invariants", "[process][port]") +{ + QObject owner; + auto& inlet = *new Process::ControlInlet{"in", Id{7}, &owner}; + + CHECK(inlet.id() == Id{7}); + CHECK(inlet.type() == Process::PortType::Message); + CHECK(inlet.parent() == &owner); + CHECK(inlet.cables().empty()); + + inlet.setName("Level"); + CHECK(inlet.name() == "Level"); + + const auto addr = State::parseAddressAccessor("dev:/x@[0]"); + REQUIRE(addr.has_value()); + inlet.setAddress(*addr); + CHECK(inlet.address() == *addr); + + // setValue stores + notifies with the new value + ossia::value notified; + int notifications = 0; + QObject::connect( + &inlet, &Process::ControlInlet::valueChanged, &owner, + [&](const ossia::value& v) { + notified = v; + ++notifications; + }); + inlet.setValue(0.75f); + CHECK(inlet.value() == ossia::value{0.75f}); + CHECK(notified == ossia::value{0.75f}); + CHECK(notifications == 1); + // same value again: no re-notification + inlet.setValue(0.75f); + CHECK(notifications == 1); + + // Port type of the other concrete classes + Process::AudioInlet ai{"ai", Id{1}, &owner}; + Process::AudioOutlet ao{"ao", Id{2}, &owner}; + Process::MidiInlet mi{"mi", Id{3}, &owner}; + Process::MidiOutlet mo{"mo", Id{4}, &owner}; + Process::ValueInlet vi{"vi", Id{5}, &owner}; + Process::ValueOutlet vo{"vo", Id{6}, &owner}; + CHECK(ai.type() == Process::PortType::Audio); + CHECK(ao.type() == Process::PortType::Audio); + CHECK(mi.type() == Process::PortType::Midi); + CHECK(mo.type() == Process::PortType::Midi); + CHECK(vi.type() == Process::PortType::Message); + CHECK(vo.type() == Process::PortType::Message); +} + +TEST_CASE("ControlInlet round-trips via DataStream and JSON", "[process][port][serialization]") +{ + QObject owner; + Process::ControlInlet port{"Gain", Id{1234}, &owner}; + port.setName("Gain"); + port.setAddress(*State::parseAddressAccessor("foo:/bar@[1]")); + port.setValue(2.5f); + port.setInit(1.25f); + port.setDomain(State::Domain{ossia::make_domain(0.f, 10.f)}); + port.setExposed("gain"); + port.setDescription("some control"); + + SECTION("DataStream, saved through the base class") + { + auto ptr = dsPortRoundTrip(port, &owner); + REQUIRE(ptr); + CHECK(ptr->id() == port.id()); + CHECK(ptr->name() == port.name()); + CHECK(ptr->address() == port.address()); + CHECK(ptr->value() == port.value()); + CHECK(ptr->init() == port.init()); + CHECK(ptr->domain() == port.domain()); + CHECK(ptr->exposed() == port.exposed()); + CHECK(ptr->description() == port.description()); + } + + SECTION("DataStream, saved through the concrete class") + { + auto ptr = dsPortRoundTrip(port, &owner); + REQUIRE(ptr); + CHECK(ptr->id() == port.id()); + CHECK(ptr->value() == port.value()); + CHECK(ptr->domain() == port.domain()); + } + + SECTION("JSON, saved through the base class") + { + auto ptr = jsonPortRoundTrip(port, &owner); + REQUIRE(ptr); + CHECK(ptr->id() == port.id()); + CHECK(ptr->name() == port.name()); + CHECK(ptr->address() == port.address()); + CHECK(ptr->value() == port.value()); + CHECK(ptr->domain() == port.domain()); + } +} + +TEST_CASE("AudioOutlet persists gain, pan and propagate", "[process][port][serialization]") +{ + QObject owner; + Process::AudioOutlet port{"Out", Id{42}, &owner}; + port.setGain(0.7); + port.setPan(Process::pan_weight{0.3, 0.7}); + port.setPropagate(true); + port.setAddress(*State::parseAddressAccessor("audio:/out/main")); + + SECTION("DataStream") + { + auto ptr = dsPortRoundTrip(port, &owner); + REQUIRE(ptr); + CHECK(ptr->id() == port.id()); + CHECK(ptr->gain() == 0.7); + CHECK(ptr->pan() == Process::pan_weight{0.3, 0.7}); + CHECK(ptr->propagate() == true); + CHECK(ptr->address() == port.address()); + // The implicit gain/pan child inlets must be recreated + REQUIRE(ptr->gainInlet); + REQUIRE(ptr->panInlet); + } + + SECTION("JSON") + { + auto ptr = jsonPortRoundTrip(port, &owner); + REQUIRE(ptr); + CHECK(ptr->gain() == 0.7); + CHECK(ptr->pan() == Process::pan_weight{0.3, 0.7}); + CHECK(ptr->propagate() == true); + } +} + +TEST_CASE("ComboBox alternatives survive round-trip", "[process][port][serialization]") +{ + QObject owner; + Process::ComboBox port{ + std::vector>{{"low", 1.5f}, {"high", 4.5f}}, + 4.5f, "Quality", Id{99}, &owner}; + port.setAddress(*State::parseAddressAccessor("foo:/quality")); + + SECTION("DataStream") + { + auto ptr = dsPortRoundTrip(port, &owner); + REQUIRE(ptr); + CHECK(ptr->id() == port.id()); + CHECK(ptr->value() == port.value()); + CHECK(ptr->alternatives == port.alternatives); + } + + SECTION("JSON") + { + auto ptr = jsonPortRoundTrip(port, &owner); + REQUIRE(ptr); + CHECK(ptr->value() == port.value()); + CHECK(ptr->alternatives == port.alternatives); + } +} + +TEST_CASE("LineEdit round-trips as its concrete type", "[process][port][serialization]") +{ + QObject owner; + Process::LineEdit port{"initial text", "Script", Id{5}, &owner}; + port.setValue(std::string{"edited text"}); + + auto ds = dsPortRoundTrip(port, &owner); + REQUIRE(ds); + CHECK(ds->id() == port.id()); + CHECK(ds->value() == port.value()); + + auto js = jsonPortRoundTrip(port, &owner); + REQUIRE(js); + CHECK(js->value() == port.value()); +} + +TEST_CASE("ControlInlet saveData/loadData round-trip", "[process][port]") +{ + QObject owner; + Process::ControlInlet a{"a", Id{1}, &owner}; + a.setAddress(*State::parseAddressAccessor("dev:/ctl")); + a.setValue(3.5f); + a.setDomain(State::Domain{ossia::make_domain(0.f, 5.f)}); + + // The address always round-trips (saved by Port::saveData layer). + Process::ControlInlet b{"b", Id{2}, &owner}; + b.loadData(a.saveData()); + CHECK(b.address() == a.address()); + // loadData must not clobber identity + CHECK(b.id() == Id{2}); + + CHECK(b.value() != a.value()); // default flags: value NOT restored (by design) + + Process::ControlInlet c{"c", Id{3}, &owner}; + c.loadData(a.saveData(), Process::PortLoadDataFlags::ReloadValue); + CHECK(c.value() == a.value()); // "ReloadValue" DOES restore it + +} + +TEST_CASE("Corrupt port buffers load to nullptr without crashing", "[process][port][fuzz]") +{ + QObject owner; + Process::ControlInlet port{"c", Id{1}, &owner}; + port.setValue(0.5f); + const QByteArray full + = score::marshall(static_cast(port)); + + // Truncations: the interface loader must catch framing errors. + for(int len = 0; len < full.size(); len += 3) + { + (void)deserialize_interface( + portFactories(), DataStream::Deserializer{full.left(len)}, &owner); + } + + // Unknown factory uuid: flip bytes in the key region -> loadMissing -> null. + QByteArray bad = full; + for(int i = 4; i < std::min(20, (int)bad.size()); ++i) + bad[i] = static_cast(~bad[i]); + auto loaded + = deserialize_interface(portFactories(), DataStream::Deserializer{bad}, &owner); + CHECK(loaded == nullptr); +} + +////////////////////////////////////////////////////////////////////////////// +// Cables +////////////////////////////////////////////////////////////////////////////// + +namespace +{ +bool sameCableData(const Process::CableData& lhs, const Process::CableData& rhs) +{ + return lhs.type == rhs.type && lhs.source == rhs.source && lhs.sink == rhs.sink; +} + +Path makeOutletPath(int process, int port) +{ + return Path{ + ObjectPath{ + {"Scenario::ProcessModel", 0}, + {"TestProcess", process}, + {"Port", port}}, + Path::UnsafeDynamicCreation{}}; +} +Path makeInletPath(int process, int port) +{ + return Path{ + ObjectPath{ + {"Scenario::ProcessModel", 0}, + {"TestProcess", process}, + {"Port", port}}, + Path::UnsafeDynamicCreation{}}; +} +} + +TEST_CASE("Cable stores and round-trips its endpoints", "[process][cable][serialization]") +{ + QObject owner; + + Process::CableData data; + data.type = Process::CableType::ImmediateStrict; + data.source = makeOutletPath(1, 10); + data.sink = makeInletPath(2, 20); + + Process::Cable cable{Id{77}, data, &owner}; + + SECTION("construction reflects the data") + { + CHECK(cable.id() == Id{77}); + CHECK(cable.type() == Process::CableType::ImmediateStrict); + CHECK(cable.source() == Path{data.source.unsafePath(), Path::UnsafeDynamicCreation{}}); + CHECK(cable.sink() == Path{data.sink.unsafePath(), Path::UnsafeDynamicCreation{}}); + CHECK(sameCableData(cable.toCableData(), data)); + } + + SECTION("update replaces endpoints") + { + Process::CableData other; + other.type = Process::CableType::DelayedGlutton; + other.source = makeOutletPath(3, 30); + other.sink = makeInletPath(4, 40); + cable.update(other); + CHECK(sameCableData(cable.toCableData(), other)); + CHECK(cable.type() == Process::CableType::DelayedGlutton); + } + + SECTION("DataStream round-trip") + { + const QByteArray bytes = score::marshall(cable); + DataStream::Deserializer des{bytes}; + Process::Cable loaded{des, &owner}; + CHECK(loaded.id() == cable.id()); + CHECK(loaded.type() == cable.type()); + CHECK(sameCableData(loaded.toCableData(), cable.toCableData())); + } + + SECTION("JSON round-trip through bytes") + { + const QByteArray bytes = toJson(cable); + const rapidjson::Document doc = readJson(bytes); + REQUIRE(!doc.HasParseError()); + JSONObject::Deserializer des{doc}; + Process::Cable loaded{des, &owner}; + CHECK(loaded.id() == cable.id()); + CHECK(loaded.type() == cable.type()); + CHECK(sameCableData(loaded.toCableData(), cable.toCableData())); + } + + SECTION("CableData value round-trip") + { + const auto ds = score::unmarshall(score::marshall(data)); + CHECK(sameCableData(ds, data)); + + const auto js = fromJson(toJson(data)); + CHECK(sameCableData(js, data)); + } +} + +////////////////////////////////////////////////////////////////////////////// +// A minimal concrete process: object graph + serialization +////////////////////////////////////////////////////////////////////////////// + +namespace +{ +class TestProcess final : public Process::ProcessModel +{ + SCORE_SERIALIZE_FRIENDS +public: + using Process::ProcessModel::m_inlets; + using Process::ProcessModel::m_outlets; + + TestProcess(TimeVal duration, const Id& id, QObject* parent) + : Process::ProcessModel{duration, id, "TestProcess", parent} + { + auto in = new Process::ControlInlet{"Control", Id{0}, this}; + in->setName("Control"); + in->setValue(0.5f); + m_inlets.push_back(in); + + auto audioIn = new Process::AudioInlet{"AudioIn", Id{1}, this}; + m_inlets.push_back(audioIn); + + auto out = new Process::AudioOutlet{"AudioOut", Id{0}, this}; + out->setPropagate(true); + m_outlets.push_back(out); + } + + template + TestProcess(Impl& vis, QObject* parent) + : Process::ProcessModel{vis, parent} + { + vis.writeTo(*this); + } + + ~TestProcess() override + { + // Owned through the QObject hierarchy; nothing else to do. + } + + static UuidKey static_concreteKey() noexcept + { + return UuidKey{"5d1b9a1e-2f43-4c34-9d61-2b0e40f92a11"}; + } + UuidKey concreteKey() const noexcept override + { + return static_concreteKey(); + } + void serialize_impl(const VisitorVariant& vis) const override + { + score::serialize_dyn(vis, *this); + } + + QString prettyShortName() const noexcept override { return "Test"; } + QString category() const noexcept override { return "Test"; } + QStringList tags() const noexcept override { return {}; } + Process::ProcessFlags flags() const noexcept override + { + return Process::ProcessFlags::SupportsAll; + } +}; +} + +// Serialization of the derived part: exactly the shape real processes use. +template <> +void DataStreamReader::read(const TestProcess& proc) +{ + Process::readPorts(*this, proc.m_inlets, proc.m_outlets); + insertDelimiter(); +} +template <> +void DataStreamWriter::write(TestProcess& proc) +{ + Process::writePorts(*this, portFactories(), proc.m_inlets, proc.m_outlets, &proc); + checkDelimiter(); +} +template <> +void JSONReader::read(const TestProcess& proc) +{ + Process::readPorts(*this, proc.m_inlets, proc.m_outlets); +} +template <> +void JSONWriter::write(TestProcess& proc) +{ + Process::writePorts(*this, portFactories(), proc.m_inlets, proc.m_outlets, &proc); +} + +TEST_CASE("Concrete process: object graph invariants", "[process][model]") +{ + QObject owner; + TestProcess proc{TimeVal::fromMsecs(5000), Id{1}, &owner}; + + CHECK(proc.duration() == TimeVal::fromMsecs(5000)); + REQUIRE(proc.inlets().size() == 2); + REQUIRE(proc.outlets().size() == 1); + + // Port lookup by id resolves to the exact same object + CHECK(proc.inlet(Id{0}) == proc.inlets()[0]); + CHECK(proc.inlet(Id{1}) == proc.inlets()[1]); + CHECK(proc.outlet(Id{0}) == proc.outlets()[0]); + CHECK(proc.inlet(Id{123}) == nullptr); + + // Ports are parented to the process (ownership + path resolution) + for(auto* p : proc.inlets()) + CHECK(p->parent() == &proc); + for(auto* p : proc.outlets()) + CHECK(p->parent() == &proc); + + // Duration setters + proc.setDuration(TimeVal::fromMsecs(1000)); + CHECK(proc.duration() == TimeVal::fromMsecs(1000)); +} + +namespace +{ +void checkProcessEquality(const TestProcess& loaded, const TestProcess& proc) +{ + CHECK(loaded.id() == proc.id()); + CHECK(loaded.duration() == proc.duration()); + CHECK(loaded.position() == proc.position()); + CHECK(loaded.size() == proc.size()); + CHECK(loaded.loops() == proc.loops()); + CHECK(loaded.startOffset() == proc.startOffset()); + CHECK(loaded.metadata().getName() == proc.metadata().getName()); + + REQUIRE(loaded.inlets().size() == proc.inlets().size()); + REQUIRE(loaded.outlets().size() == proc.outlets().size()); + for(std::size_t i = 0; i < proc.inlets().size(); i++) + { + CHECK(loaded.inlets()[i]->id() == proc.inlets()[i]->id()); + CHECK(loaded.inlets()[i]->type() == proc.inlets()[i]->type()); + CHECK(loaded.inlets()[i]->parent() == &loaded); + } + auto loadedControl = dynamic_cast(loaded.inlets()[0]); + auto origControl = dynamic_cast(proc.inlets()[0]); + REQUIRE(loadedControl); + REQUIRE(origControl); + CHECK(loadedControl->value() == origControl->value()); + CHECK(loadedControl->name() == origControl->name()); + + auto loadedOut = dynamic_cast(loaded.outlets()[0]); + REQUIRE(loadedOut); + CHECK(loadedOut->propagate() == true); +} +} + +TEST_CASE("Concrete process: DataStream round-trip", "[process][model][serialization]") +{ + QObject owner; + TestProcess proc{TimeVal::fromMsecs(3000), Id{4}, &owner}; + proc.setPosition({10., 20.}); + proc.setSize({300., 150.}); + proc.setLoops(true); + proc.setStartOffset(TimeVal::fromMsecs(250)); + + // Serialize through the base class, as documents do. + const QByteArray bytes + = score::marshall(static_cast(proc)); + + DataStream::Deserializer des{bytes}; + QByteArray nested; + des.stream() >> nested; + DataStream::Deserializer sub{nested}; + + SCORE_DEBUG_CHECK_DELIMITER2(sub); + UuidKey key; + TSerializer>::writeTo(sub, key); + SCORE_DEBUG_CHECK_DELIMITER2(sub); + REQUIRE(key == TestProcess::static_concreteKey()); + + TestProcess loaded{sub, &owner}; + checkProcessEquality(loaded, proc); +} + +TEST_CASE("Concrete process: JSON round-trip", "[process][model][serialization]") +{ + QObject owner; + TestProcess proc{TimeVal::fromMsecs(3000), Id{4}, &owner}; + proc.setPosition({10., 20.}); + proc.setSize({300., 150.}); + proc.setLoops(true); + + const auto reader + = score::marshall(static_cast(proc)); + const QByteArray bytes = reader.toByteArray(); + const rapidjson::Document doc = readJson(bytes); + REQUIRE(!doc.HasParseError()); + + // JSON is keyed: the concrete constructor can read straight from the object. + JSONObject::Deserializer des{doc}; + TestProcess loaded{des, &owner}; + checkProcessEquality(loaded, proc); +} + +TEST_CASE("Ports vector round-trip via readPorts/writePorts", "[process][port][serialization]") +{ + QObject owner; + Process::Inlets ins; + Process::Outlets outs; + { + auto c = new Process::ControlInlet{"c0", Id{0}, &owner}; + c->setValue(1.5f); + ins.push_back(c); + ins.push_back(new Process::MidiInlet{"m1", Id{1}, &owner}); + auto ao = new Process::AudioOutlet{"a0", Id{0}, &owner}; + ao->setGain(0.25); + outs.push_back(ao); + outs.push_back(new Process::ControlOutlet{"c1", Id{1}, &owner}); + } + + QByteArray bytes; + { + DataStreamReader r{&bytes}; + Process::readPorts(r, ins, outs); + } + + QObject owner2; + Process::Inlets ins2; + Process::Outlets outs2; + { + DataStreamWriter w{bytes}; + Process::writePorts(w, portFactories(), ins2, outs2, &owner2); + } + + REQUIRE(ins2.size() == 2); + REQUIRE(outs2.size() == 2); + CHECK(ins2[0]->id() == ins[0]->id()); + CHECK(ins2[1]->id() == ins[1]->id()); + CHECK(outs2[0]->id() == outs[0]->id()); + CHECK(outs2[1]->id() == outs[1]->id()); + CHECK(ins2[1]->type() == Process::PortType::Midi); + + auto c2 = dynamic_cast(ins2[0]); + REQUIRE(c2); + CHECK(c2->value() == ossia::value{1.5f}); + + auto ao2 = dynamic_cast(outs2[0]); + REQUIRE(ao2); + CHECK(ao2->gain() == 0.25); +} diff --git a/tests/unit/SplineValueTest.cpp b/tests/unit/SplineValueTest.cpp new file mode 100644 index 0000000000..97a4d58db9 --- /dev/null +++ b/tests/unit/SplineValueTest.cpp @@ -0,0 +1,150 @@ +#include +#include + +#include +#include + +#include +#include +#include + +using Catch::Approx; + +namespace +{ +// Cubic Bezier / Bernstein evaluation for one coordinate. +double bezier3(double p0, double p1, double p2, double p3, double t) +{ + const double u = 1. - t; + return u * u * u * p0 + 3. * u * u * t * p1 + 3. * u * t * t * p2 + t * t * t * p3; +} +} + +TEST_CASE("2D spline with 4 control points is the cubic Bezier of its polygon", "[spline][values]") +{ + // The exact layout ossia::nodes::spline receives from Spline::ProcessModel. + ossia::spline_data data; + data.points = {{0., 0.}, {0., 1.}, {1., 1.}, {1., 0.}}; + + ts::spline<2> s; + s.set_points( + reinterpret_cast(data.points.data()), data.points.size()); + REQUIRE(bool(s)); + + for(double t : {0., 0.1, 0.25, 0.5, 0.75, 0.9, 1.}) + { + const auto [x, y] = s.evaluate(t); + CHECK(x == Approx(bezier3(0., 0., 1., 1., t)).margin(1e-9)); + CHECK(y == Approx(bezier3(0., 1., 1., 0., t)).margin(1e-9)); + } + + // Hand-computed spot values: B(0.5) = (0.5, 0.75), B(0.25) = (0.15625, 0.5625). + { + const auto [x, y] = s.evaluate(0.5); + CHECK(x == Approx(0.5).margin(1e-9)); + CHECK(y == Approx(0.75).margin(1e-9)); + } + { + const auto [x, y] = s.evaluate(0.25); + CHECK(x == Approx(0.15625).margin(1e-9)); + CHECK(y == Approx(0.5625).margin(1e-9)); + } + + // Clamped B-spline interpolates its endpoints. + { + const auto [x, y] = s.evaluate(0.); + CHECK(x == Approx(0.).margin(1e-9)); + CHECK(y == Approx(0.).margin(1e-9)); + } + { + const auto [x, y] = s.evaluate(1.); + CHECK(x == Approx(1.).margin(1e-9)); + CHECK(y == Approx(0.).margin(1e-9)); + } +} + +TEST_CASE("2D spline through collinear control points stays on the line", "[spline][values]") +{ + // 6 uniformly spaced points on y = 2x. + ossia::spline_data data; + for(int i = 0; i <= 5; i++) + { + const double x = i / 5.; + data.points.push_back({x, 2. * x}); + } + + ts::spline<2> s; + s.set_points( + reinterpret_cast(data.points.data()), data.points.size()); + REQUIRE(bool(s)); + + double last_x = -1.; + for(int i = 0; i <= 20; i++) + { + const double t = i / 20.; + const auto [x, y] = s.evaluate(t); + // The convex hull of collinear points is the segment: the curve is on it. + CHECK(y == Approx(2. * x).margin(1e-9)); + CHECK(x >= -1e-12); + CHECK(x <= 1. + 1e-12); + // For a monotonically increasing control polygon x(t) must not go back. + CHECK(x >= last_x - 1e-12); + last_x = x; + } + + const auto [x0, y0] = s.evaluate(0.); + CHECK(x0 == Approx(0.).margin(1e-9)); + CHECK(y0 == Approx(0.).margin(1e-9)); + const auto [x1, y1] = s.evaluate(1.); + CHECK(x1 == Approx(1.).margin(1e-9)); + CHECK(y1 == Approx(2.).margin(1e-9)); +} + +TEST_CASE("2D spline is symmetric for a symmetric control polygon", "[spline][values]") +{ + ossia::spline_data data; + data.points = {{0., 0.}, {0.25, 1.}, {0.5, -1.}, {0.75, 1.}, {1., 0.}}; + ts::spline<2> fwd; + fwd.set_points( + reinterpret_cast(data.points.data()), data.points.size()); + + ossia::spline_data rev; + rev.points.assign(data.points.rbegin(), data.points.rend()); + ts::spline<2> bwd; + bwd.set_points( + reinterpret_cast(rev.points.data()), rev.points.size()); + + // Reversing the control polygon reverses the parametrization exactly. + for(double t : {0., 0.2, 0.4, 0.6, 0.8, 1.}) + { + const auto [fx, fy] = fwd.evaluate(t); + const auto [bx, by] = bwd.evaluate(1. - t); + CHECK(fx == Approx(bx).margin(1e-9)); + CHECK(fy == Approx(by).margin(1e-9)); + } +} + +TEST_CASE("3D spline with 4 control points matches the 3D cubic Bezier", "[spline][spline3d][values]") +{ + // Mirrors ossia::nodes::spline3d (used by score-plugin-spline3d). + const std::array, 4> pts{ + {{0., 0., 0.}, {1., 2., 3.}, {2., -1., 1.}, {3., 0., -2.}}}; + + ts::spline<3> s; + s.set_points(reinterpret_cast(pts.data()), pts.size()); + REQUIRE(bool(s)); + + for(double t : {0., 0.25, 0.5, 0.75, 1.}) + { + const auto [x, y, z] = s.evaluate(t); + CHECK(x == Approx(bezier3(0., 1., 2., 3., t)).margin(1e-9)); + CHECK(y == Approx(bezier3(0., 2., -1., 0., t)).margin(1e-9)); + CHECK(z == Approx(bezier3(0., 3., 1., -2., t)).margin(1e-9)); + } + + // Spot value at t=0.5: x = 1.5, y = 0.375, z = 1.25. + const auto [x, y, z] = s.evaluate(0.5); + CHECK(x == Approx(1.5).margin(1e-9)); + CHECK(y == Approx(0.375).margin(1e-9)); + CHECK(z == Approx(1.25).margin(1e-9)); +} diff --git a/tests/unit/StateSerializationTest.cpp b/tests/unit/StateSerializationTest.cpp new file mode 100644 index 0000000000..7db1ef4f15 --- /dev/null +++ b/tests/unit/StateSerializationTest.cpp @@ -0,0 +1,381 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include + +#include + +#include +#include + +namespace +{ +// Must outlive every test: serializers dereference AppComponents(). +static const score::testing::MockApplication g_mock_app; + +static const int g_ignore_sigtrap = [] { + std::signal(SIGTRAP, SIG_IGN); + return 0; +}(); + +template +T dsRoundTrip(const T& t) +{ + const QByteArray bytes = score::marshall(t); + REQUIRE(!bytes.isEmpty()); + return score::unmarshall(bytes); +} + +template +T jsonRoundTrip(const T& t) +{ + const QByteArray bytes = toJson(t); + REQUIRE(!bytes.isEmpty()); + return fromJson(bytes); +} + +template +bool gracefulUnmarshall(const QByteArray& bytes) +{ + try + { + (void)score::unmarshall(bytes); + return true; + } + catch(const std::exception&) + { + return true; + } + catch(...) + { + return true; + } +} +} + +TEST_CASE("AddressAccessor parses indices and units", "[state][address]") +{ + SECTION("plain accessor index") + { + const auto acc = State::parseAddressAccessor("foo:/bar@[1]"); + REQUIRE(acc.has_value()); + CHECK(acc->address.device == "foo"); + REQUIRE(acc->address.path.size() == 1); + CHECK(acc->address.path[0] == "bar"); + + const ossia::destination_qualifiers& q = acc->qualifiers.get(); + REQUIRE(q.accessors.size() == 1); + CHECK(q.accessors[0] == 1); + CHECK(!q.unit); + } + + SECTION("unit qualifier") + { + const auto acc = State::parseAddressAccessor("dev:/light@[color.rgb]"); + REQUIRE(acc.has_value()); + const ossia::destination_qualifiers& q = acc->qualifiers.get(); + REQUIRE(!!q.unit); + CHECK(q.unit == ossia::parse_pretty_unit("color.rgb")); + } + + SECTION("unit + component accessor") + { + const auto acc = State::parseAddressAccessor("dev:/light@[color.rgb.r]"); + REQUIRE(acc.has_value()); + const ossia::destination_qualifiers& q = acc->qualifiers.get(); + CHECK(!!q.unit); + // Selecting the .r component turns into an accessor index on the unit + CHECK(!q.accessors.empty()); + } + + SECTION("string round-trip") + { + for(const char* str : + {"foo:/bar@[1]", "dev:/light@[color.rgb]", "dev:/light@[color.rgb.r]", + "a:/b/c/d@[2]"}) + { + const auto acc = State::parseAddressAccessor(str); + REQUIRE(acc.has_value()); + const auto reparsed = State::parseAddressAccessor(acc->toString()); + REQUIRE(reparsed.has_value()); + CHECK(*reparsed == *acc); + } + } +} + +TEST_CASE("Address DataStream round-trip", "[state][address][serialization]") +{ + SECTION("regular address") + { + const auto addr = State::Address::fromString("aDevice:/some/node"); + REQUIRE(addr.has_value()); + const auto back = dsRoundTrip(*addr); + CHECK(back == *addr); + CHECK(back.device == "aDevice"); + CHECK(back.path == QStringList{QStringLiteral("some"), QStringLiteral("node")}); + } + + SECTION("root (device only) address") + { + State::Address root{"dev", {}}; + const auto back = dsRoundTrip(root); + CHECK(back == root); + CHECK(back.path.isEmpty()); + } + + SECTION("unicode fragments") + { + State::Address addr{QStringLiteral("装置"), {QStringLiteral("était")}}; + const auto back = dsRoundTrip(addr); + CHECK(back == addr); + } +} + +TEST_CASE("Address JSON round-trip", "[state][address][serialization]") +{ + const auto addr = State::Address::fromString("aDevice:/some/node"); + REQUIRE(addr.has_value()); + const auto back = jsonRoundTrip(*addr); + CHECK(back == *addr); +} + +TEST_CASE("AddressAccessor DataStream + JSON round-trip", "[state][address][serialization]") +{ + for(const char* str : + {"foo:/bar@[1]", "dev:/light@[color.rgb]", "dev:/light@[color.rgb.r]"}) + { + const auto acc = State::parseAddressAccessor(str); + REQUIRE(acc.has_value()); + + const auto ds = dsRoundTrip(*acc); + CHECK(ds == *acc); + + const auto js = jsonRoundTrip(*acc); + CHECK(js == *acc); + } +} + +static std::vector testValues() +{ + return { + ossia::value{ossia::impulse{}}, + ossia::value{int32_t{-42}}, + ossia::value{2.5f}, + ossia::value{true}, + ossia::value{false}, + ossia::value{std::string{"hello world"}}, + ossia::value{std::string{"héllo wörld ⇒ 日本"}}, + ossia::value{ossia::vec2f{0.5f, -1.5f}}, + ossia::value{ossia::vec3f{0.25f, 0.5f, 0.75f}}, + ossia::value{ossia::vec4f{1.f, 2.f, 3.f, 4.f}}, + ossia::value{std::vector{ + int32_t{1}, 2.5f, std::string{"x"}, + std::vector{int32_t{5}, int32_t{6}}}}, + ossia::value{ossia::value_map_type{{"a", int32_t{1}}, {"b", 0.5f}}}, + }; +} + +TEST_CASE("Message DataStream round-trip over all value types", "[state][message][serialization]") +{ + const auto acc = State::parseAddressAccessor("dev:/foo/bar@[1]"); + REQUIRE(acc.has_value()); + + for(const auto& v : testValues()) + { + State::Message msg{*acc, v}; + const auto back = dsRoundTrip(msg); + CHECK(back == msg); + CHECK(back.value == v); + CHECK(back.address == *acc); + } +} + +TEST_CASE("Message JSON round-trip over all value types", "[state][message][serialization]") +{ + const auto acc = State::parseAddressAccessor("dev:/foo/bar@[1]"); + REQUIRE(acc.has_value()); + + for(const auto& v : testValues()) + { + State::Message msg{*acc, v}; + const auto back = jsonRoundTrip(msg); + CHECK(back == msg); + CHECK(back.value == v); + } +} + +TEST_CASE("MessageList DataStream round-trip", "[state][message][serialization]") +{ + State::MessageList list; + const auto a1 = State::parseAddressAccessor("dev:/a"); + const auto a2 = State::parseAddressAccessor("other:/b/c@[2]"); + REQUIRE(a1.has_value()); + REQUIRE(a2.has_value()); + list.push_back(State::Message{*a1, ossia::value{int32_t{7}}}); + list.push_back(State::Message{*a2, ossia::value{std::string{"str"}}}); + + const auto back = dsRoundTrip(list); + REQUIRE(back.size() == 2); + CHECK(back[0] == list[0]); + CHECK(back[1] == list[1]); +} + +TEST_CASE("Domain DataStream round-trip", "[state][domain][serialization]") +{ + SECTION("float min/max domain") + { + State::Domain d{ossia::make_domain(0.f, 127.f)}; + const auto back = dsRoundTrip(d); + CHECK(back == d); + CHECK(back.get().convert_min() == 0.f); + CHECK(back.get().convert_max() == 127.f); + } + + SECTION("int min/max domain") + { + State::Domain d{ossia::make_domain(int32_t{-5}, int32_t{5})}; + const auto back = dsRoundTrip(d); + CHECK(back == d); + CHECK(back.get().convert_min() == -5); + CHECK(back.get().convert_max() == 5); + } + + SECTION("empty domain") + { + State::Domain d; + const auto back = dsRoundTrip(d); + CHECK(back == d); + } +} + +TEST_CASE("Value to string and back", "[state][value][conversion]") +{ + SECTION("int") + { + const ossia::value v{int32_t{123}}; + const QString s = State::convert::toPrettyString(v); + CHECK(s == "123"); + const auto parsed = State::parseValue(s.toStdString()); + REQUIRE(parsed.has_value()); + CHECK(*parsed == v); + } + + SECTION("float") + { + const ossia::value v{2.5f}; + const auto parsed = State::parseValue(State::convert::toPrettyString(v).toStdString()); + REQUIRE(parsed.has_value()); + CHECK(*parsed == v); + } + + SECTION("quoted string") + { + const ossia::value v{std::string{"foo bar"}}; + const QString s = State::convert::toPrettyString(v); + CHECK(s == "\"foo bar\""); + const auto parsed = State::parseValue(s.toStdString()); + REQUIRE(parsed.has_value()); + CHECK(*parsed == v); + } + + SECTION("list") + { + const ossia::value v{std::vector{int32_t{1}, int32_t{2}, int32_t{3}}}; + const QString s = State::convert::toPrettyString(v); + const auto parsed = State::parseValue(s.toStdString()); + REQUIRE(parsed.has_value()); + CHECK(*parsed == v); + } + + SECTION("bool") + { + CHECK(State::convert::toPrettyString(ossia::value{true}) == "true"); + CHECK(State::convert::toPrettyString(ossia::value{false}) == "false"); + const auto parsed = State::parseValue("true"); + REQUIRE(parsed.has_value()); + CHECK(*parsed == ossia::value{true}); + } + + SECTION("garbage does not parse") + { + CHECK(!State::parseValue("@#!garbage^&").has_value()); + } + + SECTION("typed conversions") + { + CHECK(State::convert::value(ossia::value{2.7f}) == 2); + CHECK(State::convert::value(ossia::value{int32_t{3}}) == 3.f); + CHECK(State::convert::value(ossia::value{std::string{"x"}}) == "x"); + } +} + +TEST_CASE("Expression parse and string round-trip", "[state][expression]") +{ + const auto expr = State::parseExpression(QStringLiteral("{ %dev:/foo% == 5 }")); + REQUIRE(expr.has_value()); + + const QString s1 = expr->toString(); + const auto reparsed = State::parseExpression(s1); + REQUIRE(reparsed.has_value()); + CHECK(reparsed->toString() == s1); + + CHECK(!State::parseExpression(QStringLiteral("((((")).has_value()); +} + +TEST_CASE("Truncated DataStream buffers are handled gracefully", "[state][serialization][fuzz]") +{ + const auto acc = State::parseAddressAccessor("dev:/foo/bar@[1]"); + REQUIRE(acc.has_value()); + State::Message msg{ + *acc, ossia::value{std::vector{ + int32_t{1}, std::string{"payload"}, ossia::vec3f{1, 2, 3}}}}; + + const QByteArray full = score::marshall(msg); + REQUIRE(full.size() > 8); + + for(int len = 0; len < full.size(); ++len) + { + CHECK(gracefulUnmarshall(full.left(len))); + } + + // The full buffer still round-trips. + CHECK(score::unmarshall(full) == msg); + + // Same exercise for AddressAccessor. + const QByteArray accBytes = score::marshall(*acc); + for(int len = 0; len < accBytes.size(); ++len) + { + CHECK(gracefulUnmarshall(accBytes.left(len))); + } +} + +TEST_CASE("Random garbage buffers do not crash Address deserialization", "[state][serialization][fuzz]") +{ + std::mt19937 rng{20260717}; + std::uniform_int_distribution byteDist{0, 255}; + std::uniform_int_distribution sizeDist{1, 64}; + + for(int iter = 0; iter < 500; ++iter) + { + QByteArray buf; + const int n = sizeDist(rng); + buf.reserve(n); + for(int i = 0; i < n; ++i) + buf.append(static_cast(byteDist(rng))); + + CHECK(gracefulUnmarshall(buf)); + } +}