// UGen tests: phasor, lfo, slew, one-pole, env-follow, sah, noise, toggle, count // // Tests exercise state-slot allocation, cross-tick accumulation, keyword // parsing, and alias resolution. #define CATCH_CONFIG_MAIN #include "../catch.hpp" #include "src/signal_engine/signal_engine.h" #include #include #include #include #include using namespace sig; #ifndef M_PI #define M_PI 3.14159265358979323846 #endif namespace { struct GoldenHarness { SignalEngine engine; double cell_values[MAX_CELLS] = {}; double hw_inputs[32] = {}; double outputs[MAX_OUTPUTS] = {}; double workspace[MAX_TOTAL_NODES] = {}; double prev_t = 0.0; double last_dt = 0.0; bool has_ticked = false; bool state_committed_this_step = false; explicit GoldenHarness(double bpm = 120.0, int beats_per_bar = 4) { engine.init_defaults(bpm, beats_per_bar); } EvalResult eval_result(const std::string& code) { return eval_cold(code.c_str(), static_cast(code.size()), engine); } void eval_ok(const std::string& code) { EvalResult r = eval_result(code); INFO("code: " << code); if (r.kind == EvalResult::Error && r.diagnostic_count > 0) { INFO("diagnostic: " << (r.diagnostics[0].message ? r.diagnostics[0].message : "")); INFO("suggestion: " << (r.diagnostics[0].suggestion ? r.diagnostics[0].suggestion : "")); } REQUIRE(r.kind != EvalResult::Error); engine.pool.rebuild_execution_order(); } void assign_ok(const char* output, const char* expr) { eval_ok(std::string("(") + output + " " + expr + ")"); } uint16_t output_index(const char* output_name) { SymbolID sym = internSymbol(output_name); uint16_t idx = GraphBuilder::resolve_output_index(sym); REQUIRE(idx != NODE_NONE); return idx; } double sample(const char* output_name, double t) { std::memset(outputs, 0, sizeof(outputs)); std::memset(workspace, 0, sizeof(workspace)); engine.cells.snapshot_values(cell_values, MAX_CELLS); last_dt = t - prev_t; if (engine.pool.state_slot_count > 0 && has_ticked && t != prev_t && !state_committed_this_step) { ExecutionContext state_ctx; state_ctx.t = t; state_ctx.dt = last_dt; state_ctx.cell_values = cell_values; state_ctx.hw_inputs = hw_inputs; state_ctx.data_pool = engine.cells.data_pool; state_ctx.data_offsets = engine.cells.data_offsets; state_ctx.data_lengths = engine.cells.data_lengths; state_ctx.prev_outputs = engine.pool.prev_output_values; state_ctx.output_values = outputs; state_ctx.workspace = workspace; execute_all_outputs(engine.pool, state_ctx); commit_state(engine.pool, workspace); state_committed_this_step = true; std::memset(workspace, 0, sizeof(workspace)); std::memset(outputs, 0, sizeof(outputs)); } ExecutionContext ctx; ctx.t = t; ctx.dt = last_dt; ctx.cell_values = cell_values; ctx.hw_inputs = hw_inputs; ctx.data_pool = engine.cells.data_pool; ctx.data_offsets = engine.cells.data_offsets; ctx.data_lengths = engine.cells.data_lengths; ctx.prev_outputs = engine.pool.prev_output_values; ctx.output_values = outputs; ctx.workspace = workspace; execute_all_outputs(engine.pool, ctx); return outputs[output_index(output_name)]; } double tick(const char* output_name, double t) { if (t != prev_t) state_committed_this_step = false; double value = sample(output_name, t); commit_outputs(engine.pool, outputs); has_ticked = true; prev_t = t; return value; } }; } // anonymous namespace // ── phasor ───────────────────────────────────────────────────────────────── TEST_CASE("UGen: phasor produces phase ramp [0,1)", "[ugens][phasor]") { GoldenHarness h; h.assign_ok("a1", "(phasor 1.0)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); REQUIRE(h.tick("a1", 0.1) == Approx(0.1)); REQUIRE(h.tick("a1", 0.2) == Approx(0.2)); } TEST_CASE("UGen: phasor wraps at 1.0", "[ugens][phasor]") { GoldenHarness h; h.assign_ok("a1", "(phasor 1.0)"); // Tick through one full cycle (10 × dt=0.1 = 1.0s) for (int i = 0; i < 10; i++) { h.tick("a1", i * 0.1); } // After 1.0s: phase = frac(1.0) = 0.0 REQUIRE(h.tick("a1", 1.0) == Approx(0.0).margin(1e-10)); } TEST_CASE("UGen: phasor with :phase init", "[ugens][phasor]") { GoldenHarness h; h.assign_ok("a1", "(phasor 1.0 :phase 0.5)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.5)); } TEST_CASE("UGen: phasor at 2Hz", "[ugens][phasor]") { GoldenHarness h; h.assign_ok("a1", "(phasor 2.0)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // dt=0.1, state = frac(0 + 2.0*0.1) = 0.2 REQUIRE(h.tick("a1", 0.1) == Approx(0.2)); } // ── lfo ───────────────────────────────────────────────────────────────────── TEST_CASE("UGen: lfo default wave is sine", "[ugens][lfo]") { GoldenHarness h; h.assign_ok("a1", "(lfo 1.0)"); // usin(0) = (sin(0)+1)/2 = 0.5 REQUIRE(h.tick("a1", 0.0) == Approx(0.5)); // After dt=0.25: phase = 0.25, usin(0.25) = (sin(2π*0.25)+1)/2 = 1.0 REQUIRE(h.tick("a1", 0.25) == Approx(1.0)); } TEST_CASE("UGen: lfo :wave :saw produces phasor output", "[ugens][lfo]") { GoldenHarness h; h.assign_ok("a1", "(lfo 1.0 :wave :saw)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); REQUIRE(h.tick("a1", 0.1) == Approx(0.1)); REQUIRE(h.tick("a1", 0.2) == Approx(0.2)); } TEST_CASE("UGen: lfo :wave :tri produces triangle", "[ugens][lfo]") { GoldenHarness h; h.assign_ok("a1", "(lfo 1.0 :wave :tri)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // tri(0) = 0 // After dt=0.25: phase=0.25, tri(0.25) = 1 - |2*0.25 - 1| = 0.5 REQUIRE(h.tick("a1", 0.25) == Approx(0.5)); } TEST_CASE("UGen: lfo :wave :sqr produces square", "[ugens][lfo]") { GoldenHarness h; h.assign_ok("a1", "(lfo 1.0 :wave :sqr)"); // phase=0 → frac(0)=0 < 0.5 → 1 REQUIRE(h.tick("a1", 0.0) == Approx(1.0)); // phase=0.1 → < 0.5 → 1 REQUIRE(h.tick("a1", 0.1) == Approx(1.0)); } TEST_CASE("UGen: lfo :sqr with :pw", "[ugens][lfo]") { GoldenHarness h; h.assign_ok("a1", "(lfo 1.0 :wave :sqr :pw 0.25)"); // phase=0 < 0.25 → high REQUIRE(h.tick("a1", 0.0) == Approx(1.0)); // phase=0.1 < 0.25 → still high REQUIRE(h.tick("a1", 0.1) == Approx(1.0)); // phase=0.3 > 0.25 → low h.tick("a1", 0.2); REQUIRE(h.tick("a1", 0.3) == Approx(0.0)); } // ── namespaced lfo waveforms ──────────────────────────────────────────────── TEST_CASE("UGen: lfo/sin is a unipolar sine lfo", "[ugens][lfo][namespace]") { GoldenHarness h; h.assign_ok("a1", "(lfo/sin 1.0)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.5)); // usin(0) = 0.5 } TEST_CASE("UGen: lfo/saw is a saw lfo", "[ugens][lfo][namespace]") { GoldenHarness h; h.assign_ok("a1", "(lfo/saw 1.0)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); REQUIRE(h.tick("a1", 0.1) == Approx(0.1)); } TEST_CASE("UGen: lfo/tri is a triangle lfo", "[ugens][lfo][namespace]") { GoldenHarness h; h.assign_ok("a1", "(lfo/tri 1.0)"); REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // tri(0) = 0 } TEST_CASE("UGen: lfo/sqr is a square lfo", "[ugens][lfo][namespace]") { GoldenHarness h; h.assign_ok("a1", "(lfo/sqr 1.0)"); REQUIRE(h.tick("a1", 0.0) == Approx(1.0)); // phase < 0.5 → 1 } // ── slew ──────────────────────────────────────────────────────────────────── TEST_CASE("UGen: slew limits rate of change", "[ugens][slew]") { GoldenHarness h; // Use ain1 (bare symbol, not function call) as input h.assign_ok("a1", "(slew ain1 10.0)"); h.hw_inputs[8] = 0.0; // ain1 = INP_AI1 (index 8) REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // Jump target to 1.0, dt=0.1, max_step = 10*0.1 = 1.0 h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.1) == Approx(1.0)); // delta=1.0 ≤ step=1.0 } TEST_CASE("UGen: slew clamps when target changes too fast", "[ugens][slew]") { GoldenHarness h; h.assign_ok("a1", "(slew ain1 1.0)"); h.hw_inputs[8] = 0.0; REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // Target jumps to 10, max_step = 1.0*0.1 = 0.1 h.hw_inputs[8] = 10.0; double val = h.tick("a1", 0.1); REQUIRE(val == Approx(0.1)); val = h.tick("a1", 0.2); REQUIRE(val == Approx(0.2)); } // ── one-pole ──────────────────────────────────────────────────────────────── TEST_CASE("UGen: one-pole low-pass filter", "[ugens][one-pole]") { GoldenHarness h; h.assign_ok("a1", "(one-pole ain1 10.0)"); h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // initial state = 0 // dt=0.01, alpha = min(1, 2*pi*10*0.01) ≈ 0.628 double val = h.tick("a1", 0.01); double alpha = std::min(1.0, 2.0 * M_PI * 10.0 * 0.01); REQUIRE(val == Approx(alpha).margin(0.01)); } // ── env-follow ────────────────────────────────────────────────────────────── TEST_CASE("UGen: env-follow tracks absolute input", "[ugens][env-follow]") { GoldenHarness h; h.assign_ok("a1", "(env-follow ain1 100.0 10.0)"); h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // dt=0.01, attack=100, a_coeff = min(1, 100*0.01) = 1.0 // diff = 1.0 - 0 = 1.0, new = 0 + 1.0*1.0 = 1.0 double val = h.tick("a1", 0.01); REQUIRE(val == Approx(1.0)); } TEST_CASE("UGen: envelope-follower is alias", "[ugens][env-follow][alias]") { GoldenHarness h; h.assign_ok("a1", "(envelope-follower ain1 100.0 10.0)"); h.hw_inputs[8] = 0.5; REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); } // ── sah (sample-and-hold) ─────────────────────────────────────────────────── TEST_CASE("UGen: sah samples on rising edge", "[ugens][sah]") { GoldenHarness h; h.assign_ok("a1", "(sah ain1 ain2)"); h.hw_inputs[8] = 0.42; // input h.hw_inputs[9] = 0.0; // trigger low REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // initial = 0, no trigger // Trigger rises: ain2 goes from 0 → 1 (> 0.5) h.hw_inputs[9] = 1.0; double val = h.tick("a1", 0.1); REQUIRE(val == Approx(0.42)); // sampled the input // Trigger stays high, input changes — should hold h.hw_inputs[8] = 0.99; val = h.tick("a1", 0.2); REQUIRE(val == Approx(0.42)); // still holding // Trigger goes low, then high again with new value h.hw_inputs[9] = 0.0; h.tick("a1", 0.3); h.hw_inputs[8] = 0.77; h.hw_inputs[9] = 1.0; val = h.tick("a1", 0.4); REQUIRE(val == Approx(0.77)); // sampled new value } TEST_CASE("UGen: latch is alias for sah", "[ugens][sah][alias]") { GoldenHarness h; h.assign_ok("a1", "(latch ain1 ain2)"); h.hw_inputs[8] = 0.5; h.hw_inputs[9] = 0.0; h.tick("a1", 0.0); h.hw_inputs[9] = 1.0; REQUIRE(h.tick("a1", 0.1) == Approx(0.5)); } // ── noise ─────────────────────────────────────────────────────────────────── TEST_CASE("UGen: noise produces values in [-1,1]", "[ugens][noise]") { GoldenHarness h; h.assign_ok("a1", "(noise)"); bool found_positive = false; bool found_negative = false; for (int i = 0; i < 20; i++) { double val = h.tick("a1", i * 0.01); REQUIRE(val >= -1.0); REQUIRE(val <= 1.0); if (val > 0.1) found_positive = true; if (val < -0.1) found_negative = true; } REQUIRE(found_positive); REQUIRE(found_negative); } TEST_CASE("UGen: noise is deterministic", "[ugens][noise]") { GoldenHarness h1, h2; h1.assign_ok("a1", "(noise)"); h2.assign_ok("a1", "(noise)"); for (int i = 0; i < 10; i++) { double v1 = h1.tick("a1", i * 0.01); double v2 = h2.tick("a1", i * 0.01); REQUIRE(v1 == Approx(v2)); } } // ── toggle ────────────────────────────────────────────────────────────────── TEST_CASE("UGen: toggle flips on rising edge", "[ugens][toggle]") { GoldenHarness h; h.assign_ok("a1", "(toggle ain1)"); h.hw_inputs[8] = 0.0; REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // initial = 0 // Trigger rises → toggle: 0 → 1 h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.1) == Approx(1.0)); // Trigger stays high → no change REQUIRE(h.tick("a1", 0.2) == Approx(1.0)); // Trigger goes low → no change h.hw_inputs[8] = 0.0; REQUIRE(h.tick("a1", 0.3) == Approx(1.0)); // Trigger rises again → toggle: 1 → 0 h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.4) == Approx(0.0)); // And again → 0 → 1 h.hw_inputs[8] = 0.0; h.tick("a1", 0.5); h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.6) == Approx(1.0)); } // ── count ─────────────────────────────────────────────────────────────────── TEST_CASE("UGen: count increments on trigger", "[ugens][count]") { GoldenHarness h; h.assign_ok("a1", "(count ain1)"); h.hw_inputs[8] = 0.0; REQUIRE(h.tick("a1", 0.0) == Approx(0.0)); // Rising edge → count = 1 h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.1) == Approx(1.0)); // Still high → no increment REQUIRE(h.tick("a1", 0.2) == Approx(1.0)); // Low → no increment h.hw_inputs[8] = 0.0; REQUIRE(h.tick("a1", 0.3) == Approx(1.0)); // Rising again → count = 2 h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.4) == Approx(2.0)); } TEST_CASE("UGen: count with :reset", "[ugens][count]") { GoldenHarness h; h.assign_ok("a1", "(count ain1 :reset ain2)"); h.hw_inputs[8] = 0.0; // trigger h.hw_inputs[9] = 0.0; // reset h.tick("a1", 0.0); // Count up: 2 triggers h.hw_inputs[8] = 1.0; h.tick("a1", 0.1); h.hw_inputs[8] = 0.0; h.tick("a1", 0.2); h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.3) == Approx(2.0)); // Reset with rising edge on ain2 h.hw_inputs[9] = 1.0; REQUIRE(h.tick("a1", 0.4) == Approx(0.0)); // count reset to 0 // Count again after reset: trigger goes low then high h.hw_inputs[8] = 0.0; h.hw_inputs[9] = 0.0; h.tick("a1", 0.5); // trigger low, reset low h.hw_inputs[8] = 1.0; REQUIRE(h.tick("a1", 0.6) == Approx(1.0)); } // ── Classification ────────────────────────────────────────────────────────── TEST_CASE("Classification: UGens are stateful", "[ugens][classification]") { GoldenHarness h; h.assign_ok("a1", "(phasor 1.0)"); REQUIRE(h.engine.pool.output_class[h.output_index("a1")] == OutputClass::Stateful); GoldenHarness h2; h2.assign_ok("a1", "(lfo 1.0)"); REQUIRE(h2.engine.pool.output_class[h2.output_index("a1")] == OutputClass::Stateful); GoldenHarness h3; h3.assign_ok("a1", "(noise)"); REQUIRE(h3.engine.pool.output_class[h3.output_index("a1")] == OutputClass::Stateful); }