feat(pipeline)!: P4 core — input/output chains + curve catalog in nisps/

- nisps/pipeline/input_chain.hpp: faithful f32 port of the manifold input
  pipeline (invert→deadzone→circular clamp→momentum-modulated zoom→centred
  power→EMA→momentum update). Caller-supplied dt, internal accumulated
  clock (no wall clock — deterministic; matches the P1 fixtures' clock
  contract). Fixed-capacity velocity ring; serialisable state.
- nisps/pipeline/output_chain.hpp: curve→EMA→slew→freeze(+per-output mask)
  chain, capacity-templated (browser cap 4096; firmware would use NOut).
- nisps/core/math.hpp: + centered_power(x, exponent) (both chains use it).
- bindings: nisps_pipeline_create/destroy, nisps_input_set_config(15-float
  wire layout)/process/reset, nisps_output_set_config/set_freeze_mask/
  process/reset, pipeline state save/load, nisps_curve_apply(+batch)
  (ids 0-6 = Curve enum, 7 = centred power).
- parity v5 Stage 7: rational (transcendental-free) traces through both
  chains (2 configs each) + full curve catalog — 1273 floats PASS, the
  pipeline floats bit-identical native↔WASM.
- ctest test_pipeline.cpp: deadzone remap, circular clamp, zoom+freeze,
  sticky anchor, frame-rate-independent EMA, momentum zoom-out/recovery,
  state round-trip, slew, freeze gate/mask, reseed-on-length-change,
  centred-power endpoints.

Part of one-core-engine-refactor P4; the manifold TS switch follows.
This commit is contained in:
monkey-w1n5t0n 2026-07-18 11:52:53 +02:00
parent 1f4513802f
commit 1672fe3474
12 changed files with 1039 additions and 4 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -62,6 +62,7 @@ if(NOT EMSCRIPTEN)
${NISPS_TEST_DIR}/test_mlp_feedback.cpp ${NISPS_TEST_DIR}/test_mlp_feedback.cpp
${NISPS_TEST_DIR}/test_mlp_geo_dislike.cpp ${NISPS_TEST_DIR}/test_mlp_geo_dislike.cpp
${NISPS_TEST_DIR}/test_mlp_serialize.cpp ${NISPS_TEST_DIR}/test_mlp_serialize.cpp
${NISPS_TEST_DIR}/test_pipeline.cpp
) )
target_link_libraries(nisps_core_tests PRIVATE nisps_core) target_link_libraries(nisps_core_tests PRIVATE nisps_core)

View file

@ -105,4 +105,21 @@ inline float apply_curve(Curve c, float x) noexcept {
return x; // unreachable, silences -Wreturn-type return x; // unreachable, silences -Wreturn-type
} }
// Centred power curve — pivots around 0.5 instead of 0 (from the legacy
// input/output pipelines; both nisps/pipeline chains use it):
// exponent < 1 → push toward the extremes
// exponent = 1 → identity
// exponent > 1 → pull toward the centre
// Parameterised, so it lives beside the Curve enum rather than inside it
// (schema param curves don't carry a parameter).
inline float centered_power(float x, float exponent) noexcept {
if (exponent == 1.f) return clamp01(x);
const float offset = x - 0.5f;
const float sign = (offset < 0.f) ? -1.f : 1.f;
// Range [-0.5, 0.5] → [-1, 1] for the power op, then halve back.
const float shaped =
sign * std::pow(std::fabs(offset) * 2.f, exponent) * 0.5f;
return clamp01(shaped + 0.5f);
}
} // namespace nisps } // namespace nisps

View file

@ -0,0 +1,273 @@
// nisps/pipeline/input_chain.hpp — the 2-axis input-processing chain
// (one-core-engine-refactor P4). Faithful C++ port of the retired
// manifold/src/engine/input-pipeline.ts (itself a bit-for-bit port of the
// legacy js/ui/input-pipeline.js), which is the behaviour contract pinned by
// manifold/tests/fixtures/input-pipeline-golden.json.
//
// Stages (in order), each axis in [0,1]:
// 0. Invert (per-axis flip)
// 1. Deadzone (suppress jitter near centre, remap live zone to [0,1])
// 2. Circular clamp (constrain to unit disk centred at 0.5,0.5)
// 3. Zoom (narrow window around anchor, modulated by momentum)
// 4. Centred power curve (per-axis exponent)
// 5. EMA smoothing (frame-rate-independent)
// 6. Momentum-as-zoom update (consumed next frame)
//
// TIME MODEL: the caller passes dt in SECONDS per call; the chain accumulates
// its own clock for the momentum velocity window (the TS original read
// performance.now() — the fixtures pin the equivalent clock contract). No
// wall clock in core: fully deterministic.
//
// PERF CONTRACT: no heap, no virtual dispatch, `.f` literals, fixed-capacity
// velocity ring. Control-rate (per pointer event / per control tick), not the
// audio ISR.
#pragma once
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#include "../core/math.hpp"
#include "../core/perf.hpp"
namespace nisps::pipeline {
inline constexpr float kZoomMin = 0.01f;
inline constexpr float kZoomMax = 1.0f;
inline constexpr float kFreezeThreshold = kZoomMin;
inline constexpr float kReferenceDt = 1.f / 60.f;
inline constexpr float kVelocityWindowDefaultS = 0.150f;
// Sentinel for "null" per-axis overrides (valid zooms are [0.01, 1], valid
// curves [0.2, 5] — zero is outside both ranges).
inline constexpr float kUnsetOverride = 0.f;
enum class AnchorMode : std::uint8_t { Auto = 0, Sticky = 1, Center = 2 };
enum class MomentumMode : std::uint8_t { Off = 0, Gentle = 1, Strong = 2 };
struct InputChainConfig {
float zoom = 1.0f; // [0.01, 1]
float zoom_x = kUnsetOverride; // 0 ⇒ use zoom
float zoom_y = kUnsetOverride;
float anchor_x = 0.5f;
float anchor_y = 0.5f;
AnchorMode anchor_mode = AnchorMode::Center;
float deadzone = 0.f; // [0, 0.4]
float input_curve = 1.0f; // [0.2, 5]
float curve_x = kUnsetOverride; // 0 ⇒ use input_curve
float curve_y = kUnsetOverride;
float smoothing = 0.f; // [0, 0.95]
MomentumMode momentum_mode = MomentumMode::Off;
float velocity_window_s = kVelocityWindowDefaultS;
bool invert_x = false;
bool invert_y = false;
};
struct InputChainResult {
float x;
float y;
bool frozen;
};
class InputChain {
public:
// Velocity-history capacity. The TS original kept an unbounded window-
// trimmed list; at real pointer rates (≤240 Hz) a 150 ms window holds
// ≤36 entries. When full, the oldest entry is dropped (it would be the
// first trimmed anyway).
static constexpr std::size_t kHistoryCap = 64u;
InputChain() noexcept = default;
void set_config(const InputChainConfig& c) noexcept { cfg_ = c; }
const InputChainConfig& config() const noexcept { return cfg_; }
void reset() noexcept {
smoothed_x_ = 0.5f;
smoothed_y_ = 0.5f;
momentum_multiplier_ = 1.f;
frozen_ = false;
now_s_ = 0.f;
hist_count_ = 0u;
hist_head_ = 0u;
}
// Serialisable per-instance state (persistence): [smoothed_x, smoothed_y,
// momentum_multiplier]. The velocity history is transient by design.
static constexpr std::size_t state_size() noexcept { return 3u; }
void save_state(std::span<float> out) const noexcept {
if (out.size() < state_size()) return;
out[0] = smoothed_x_;
out[1] = smoothed_y_;
out[2] = momentum_multiplier_;
}
void load_state(std::span<const float> in) noexcept {
if (in.size() < state_size()) return;
smoothed_x_ = in[0];
smoothed_y_ = in[1];
momentum_multiplier_ = in[2];
hist_count_ = 0u;
hist_head_ = 0u;
}
// Process one raw 2D sample. `dt_s` = seconds since the previous call
// (clamped at 0; 0 falls back to the 1/60 reference inside smoothing).
InputChainResult process(float raw_x, float raw_y, float dt_s) noexcept {
const float safe_dt = (dt_s > 0.f) ? dt_s : 0.f;
now_s_ += safe_dt;
const float base_zoom_x = (cfg_.zoom_x != kUnsetOverride) ? cfg_.zoom_x : cfg_.zoom;
const float base_zoom_y = (cfg_.zoom_y != kUnsetOverride) ? cfg_.zoom_y : cfg_.zoom;
const bool frozen_x = base_zoom_x <= kFreezeThreshold;
const bool frozen_y = base_zoom_y <= kFreezeThreshold;
if (frozen_x && frozen_y) {
frozen_ = true;
return {smoothed_x_, smoothed_y_, true};
}
// 0. Invert
float x = cfg_.invert_x ? (1.f - raw_x) : raw_x;
float y = cfg_.invert_y ? (1.f - raw_y) : raw_y;
// 1. Deadzone
x = apply_deadzone_(x, cfg_.deadzone);
y = apply_deadzone_(y, cfg_.deadzone);
// 2. Circular clamp to the unit disk centred at (0.5, 0.5)
{
const float cx = x - 0.5f;
const float cy = y - 0.5f;
const float dist = std::sqrt(cx * cx + cy * cy);
if (dist > 0.5f && dist > 1e-12f) {
const float scale = 0.5f / dist;
x = 0.5f + cx * scale;
y = 0.5f + cy * scale;
}
}
// 3. Zoom around the anchor (with momentum modulation)
const float anchor_x = (cfg_.anchor_mode == AnchorMode::Center) ? 0.5f : cfg_.anchor_x;
const float anchor_y = (cfg_.anchor_mode == AnchorMode::Center) ? 0.5f : cfg_.anchor_y;
const float eff_zoom_x = frozen_x
? kFreezeThreshold
: nisps::clamp(base_zoom_x * momentum_multiplier_, kZoomMin, kZoomMax);
const float eff_zoom_y = frozen_y
? kFreezeThreshold
: nisps::clamp(base_zoom_y * momentum_multiplier_, kZoomMin, kZoomMax);
x = frozen_x ? smoothed_x_ : apply_zoom_(x, anchor_x, eff_zoom_x);
y = frozen_y ? smoothed_y_ : apply_zoom_(y, anchor_y, eff_zoom_y);
// 4. Centred power curve
const float curve_x = (cfg_.curve_x != kUnsetOverride) ? cfg_.curve_x : cfg_.input_curve;
const float curve_y = (cfg_.curve_y != kUnsetOverride) ? cfg_.curve_y : cfg_.input_curve;
if (!frozen_x) x = nisps::centered_power(x, curve_x);
if (!frozen_y) y = nisps::centered_power(y, curve_y);
// 5. EMA smoothing
if (!frozen_x) smoothed_x_ = ema_smooth_(smoothed_x_, x, cfg_.smoothing, safe_dt);
if (!frozen_y) smoothed_y_ = ema_smooth_(smoothed_y_, y, cfg_.smoothing, safe_dt);
// 6. Update momentum-zoom for the next frame (uses the RAW sample,
// pre-pipeline, like the TS original).
update_momentum_(raw_x, raw_y, safe_dt);
frozen_ = false;
return {smoothed_x_, smoothed_y_, false};
}
bool frozen() const noexcept { return frozen_; }
float momentum_multiplier() const noexcept { return momentum_multiplier_; }
private:
static float apply_deadzone_(float input, float deadzone) noexcept {
if (deadzone <= 0.f) return input;
const float offset = input - 0.5f;
const float abs_off = std::fabs(offset);
const float half_dz = deadzone * 0.5f;
if (abs_off <= half_dz) return 0.5f;
const float sign = (offset < 0.f) ? -1.f : 1.f;
const float remapped = ((abs_off - half_dz) / (0.5f - half_dz)) * 0.5f;
return 0.5f + sign * remapped;
}
static float apply_zoom_(float input, float anchor, float zoom_level) noexcept {
return nisps::clamp(anchor + (input - 0.5f) * zoom_level, 0.f, 1.f);
}
static float ema_smooth_(float prev, float raw, float smoothing, float dt) noexcept {
if (smoothing <= 0.f) return raw;
const float effective_dt = (dt > 0.f) ? dt : kReferenceDt;
const float alpha = 1.f - smoothing;
const float alpha_eff = 1.f - std::pow(1.f - alpha, effective_dt / kReferenceDt);
return prev + alpha_eff * (raw - prev);
}
void update_momentum_(float raw_x, float raw_y, float dt) noexcept {
float factor, min_mul, max_mul;
switch (cfg_.momentum_mode) {
case MomentumMode::Gentle: factor = 0.6f; min_mul = 0.3f; max_mul = 1.0f; break;
case MomentumMode::Strong: factor = 1.5f; min_mul = 0.15f; max_mul = 1.0f; break;
case MomentumMode::Off:
default:
momentum_multiplier_ = 1.f;
hist_count_ = 0u;
hist_head_ = 0u;
return;
}
// Trim entries older than the window, then append (bounded ring).
const float window = cfg_.velocity_window_s;
while (hist_count_ > 0u) {
const HistEntry& oldest = hist_[hist_head_];
if (now_s_ - oldest.t <= window) break;
hist_head_ = (hist_head_ + 1u) % kHistoryCap;
--hist_count_;
}
if (hist_count_ == kHistoryCap) {
hist_head_ = (hist_head_ + 1u) % kHistoryCap;
--hist_count_;
}
hist_[(hist_head_ + hist_count_) % kHistoryCap] = {raw_x, raw_y, now_s_};
++hist_count_;
if (hist_count_ < 2u) {
momentum_multiplier_ = 1.f;
return;
}
const HistEntry& a = hist_[hist_head_];
const HistEntry& b = hist_[(hist_head_ + hist_count_ - 1u) % kHistoryCap];
const float dt_hist = b.t - a.t;
if (dt_hist <= 0.f) return; // keep the previous multiplier
const float dx = b.x - a.x;
const float dy = b.y - a.y;
const float dist = std::sqrt(dx * dx + dy * dy);
const float speed = dist / dt_hist; // [0,1]-space units per second
const float norm_speed = nisps::clamp(speed * factor, 0.f, 1.f);
// Higher speed → smaller multiplier (zoom out faster movements).
const float target = max_mul - (max_mul - min_mul) * norm_speed;
// Smooth toward the target so the zoom doesn't jitter.
const float smooth_coeff = nisps::clamp(dt * 6.f, 0.f, 1.f);
momentum_multiplier_ += smooth_coeff * (target - momentum_multiplier_);
}
struct HistEntry {
float x;
float y;
float t;
};
InputChainConfig cfg_{};
float smoothed_x_ = 0.5f;
float smoothed_y_ = 0.5f;
float momentum_multiplier_ = 1.f;
bool frozen_ = false;
float now_s_ = 0.f;
HistEntry hist_[kHistoryCap]{};
std::size_t hist_head_ = 0u;
std::size_t hist_count_ = 0u;
};
} // namespace nisps::pipeline

View file

@ -0,0 +1,162 @@
// nisps/pipeline/output_chain.hpp — the per-output processing chain
// (one-core-engine-refactor P4). Faithful C++ port of the retired
// manifold/src/engine/output-pipeline.ts, the behaviour contract pinned by
// manifold/tests/fixtures/output-pipeline-golden.json.
//
// Stages (in order) for each output:
// 1. Global power curve (raw^exponent, exponent in [0.2, 5.0])
// 2. Per-output EMA smoothing (frame-rate-independent)
// 3. Slew-rate limiting (max change per second per output)
// 4. Freeze gate (global) and per-output freeze mask
//
// PERF CONTRACT: no heap — capacity is a template parameter (browser
// bindings instantiate a large cap; firmware would pick its mode's NOut).
// Control-rate. `.f` literals, no virtual dispatch.
#pragma once
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#include "../core/math.hpp"
#include "../core/perf.hpp"
namespace nisps::pipeline {
inline constexpr float kOutputReferenceDt = 1.f / 60.f;
// Slew sentinel: any value <= 0 means "unlimited" (the TS Infinity default).
inline constexpr float kSlewUnlimited = 0.f;
struct OutputChainConfig {
float global_curve = 1.0f; // [0.2, 5]; 1 = linear
float smoothing = 0.f; // [0, 0.95]
float slew_rate = kSlewUnlimited; // change/sec; <= 0 ⇒ unlimited
bool freeze_output = false; // global freeze gate
};
template <std::size_t NMax>
class OutputChain {
public:
static constexpr std::size_t kMaxOutputs = NMax;
OutputChain() noexcept = default;
void set_config(const OutputChainConfig& c) noexcept { cfg_ = c; }
const OutputChainConfig& config() const noexcept { return cfg_; }
// Per-output freeze mask (1 = frozen). Empty span clears the mask.
void set_freeze_mask(std::span<const std::uint8_t> mask) noexcept {
mask_count_ = (mask.size() < NMax) ? mask.size() : NMax;
for (std::size_t i = 0; i < mask_count_; ++i) freeze_mask_[i] = mask[i];
}
void clear_freeze_mask() noexcept { mask_count_ = 0u; }
void reset() noexcept {
seeded_count_ = 0u;
}
// Serialisable state: [count, prev..., smoothed...].
std::size_t state_size() const noexcept { return 1u + 2u * seeded_count_; }
void save_state(std::span<float> out) const noexcept {
if (out.size() < state_size()) return;
out[0] = static_cast<float>(seeded_count_);
for (std::size_t i = 0; i < seeded_count_; ++i) {
out[1u + i] = prev_[i];
out[1u + seeded_count_ + i] = smoothed_[i];
}
}
void load_state(std::span<const float> in) noexcept {
if (in.empty()) return;
std::size_t n = static_cast<std::size_t>(in[0]);
if (n > NMax) n = NMax;
if (in.size() < 1u + 2u * n) return;
seeded_count_ = n;
for (std::size_t i = 0; i < n; ++i) {
prev_[i] = in[1u + i];
smoothed_[i] = in[1u + n + i];
}
}
// Process `raw` (n ≤ NMax) into `out` (may alias `raw`). `dt_s` = seconds
// since the previous call.
void process(std::span<const float> raw, std::span<float> out, float dt_s) noexcept {
std::size_t n = raw.size();
if (n > NMax) n = NMax;
if (out.size() < n) return;
const float dt = (dt_s > 0.f) ? dt_s : 0.f;
// (Re)seed prev/smoothed from raw on first call or length change —
// matches the TS null/length-mismatch reseed.
if (seeded_count_ != n) {
for (std::size_t i = 0; i < n; ++i) {
const float r = nisps::clamp01(raw[i]);
prev_[i] = r;
smoothed_[i] = r;
}
seeded_count_ = n;
}
if (cfg_.freeze_output) {
// Output frozen: hold prior values.
for (std::size_t i = 0; i < n; ++i) out[i] = prev_[i];
return;
}
const float exp = cfg_.global_curve;
const bool slew_on = cfg_.slew_rate > 0.f;
const float max_delta = slew_on ? cfg_.slew_rate * dt : 0.f;
for (std::size_t i = 0; i < n; ++i) {
const float r = nisps::clamp01(raw[i]);
const float curved = (exp == 1.0f) ? r : std::pow(r, exp);
// Per-output freeze
if (i < mask_count_ && freeze_mask_[i] != 0u) {
out[i] = prev_[i];
continue;
}
// Stage 2: EMA smoothing
float value = ema_smooth_(smoothed_[i], curved, cfg_.smoothing, dt);
smoothed_[i] = value;
// Stage 3: slew-rate limit
if (slew_on) {
const float delta = value - prev_[i];
if (std::fabs(delta) > max_delta) {
const float sign = (delta < 0.f) ? -1.f : 1.f;
value = prev_[i] + sign * max_delta;
}
}
out[i] = nisps::clamp01(value);
}
// Update prev for the next call (frozen dims hold their prev).
for (std::size_t i = 0; i < n; ++i) {
if (i < mask_count_ && freeze_mask_[i] != 0u) continue;
prev_[i] = out[i];
}
}
private:
static float ema_smooth_(float prev, float raw, float smoothing, float dt) noexcept {
if (smoothing <= 0.f) return raw;
const float effective_dt = (dt > 0.f) ? dt : kOutputReferenceDt;
const float alpha = 1.f - smoothing;
const float alpha_eff =
1.f - std::pow(1.f - alpha, effective_dt / kOutputReferenceDt);
return prev + alpha_eff * (raw - prev);
}
OutputChainConfig cfg_{};
std::uint8_t freeze_mask_[NMax]{};
std::size_t mask_count_ = 0u;
float prev_[NMax]{};
float smoothed_[NMax]{};
std::size_t seeded_count_ = 0u;
};
} // namespace nisps::pipeline

View file

@ -62,6 +62,7 @@
#include "../engines/xiasri.hpp" #include "../engines/xiasri.hpp"
// ML. // ML.
#include "../core/math.hpp"
#include "../core/types.hpp" #include "../core/types.hpp"
#include "../ml/dynamic_storage.hpp" #include "../ml/dynamic_storage.hpp"
#include "../ml/feedback.hpp" #include "../ml/feedback.hpp"
@ -71,6 +72,10 @@
#include "../ml/stats.hpp" #include "../ml/stats.hpp"
#include "../ml/warm_start.hpp" #include "../ml/warm_start.hpp"
// Pipelines (one-core-engine P4).
#include "../pipeline/input_chain.hpp"
#include "../pipeline/output_chain.hpp"
namespace { namespace {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -179,6 +184,17 @@ struct MLHandle {
std::size_t n_out() const noexcept { return mlp.n_out(); } std::size_t n_out() const noexcept { return mlp.n_out(); }
}; };
// ---------------------------------------------------------------------------
// Pipeline side (one-core-engine P4): the input/output processing chains,
// state C++-side per handle. The output chain is capacity-templated; the
// browser instantiates the kMaxDim cap.
// ---------------------------------------------------------------------------
struct PipelineHandle {
nisps::pipeline::InputChain input;
nisps::pipeline::OutputChain<kMaxDim> output;
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Engine side // Engine side
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -946,6 +962,164 @@ void nisps_ml_describe(void* ml, int* out_dims) {
out_dims[5] = static_cast<int>(BrowserMLP::kNumLayers); out_dims[5] = static_cast<int>(BrowserMLP::kNumLayers);
} }
// ---------------------------------------------------------------------------
// Pipelines (one-core-engine P4). Input chain: the 2-axis pad pipeline.
// Output chain: curve → EMA → slew → freeze over the routed vector. State
// lives C++-side per handle; the TS wrappers are thin.
//
// INPUT CONFIG WIRE LAYOUT (nisps_input_set_config, 15 floats — keep in
// lockstep with manifold/src/engine wrappers):
// [0] zoom [1] zoomX (0=null) [2] zoomY (0=null)
// [3] anchorX [4] anchorY [5] anchorMode (0 auto/1 sticky/2 centre)
// [6] deadzone [7] inputCurve [8] curveX (0=null)
// [9] curveY (0=null) [10] smoothing [11] momentumMode (0 off/1 gentle/2 strong)
// [12] velocityWindow (SECONDS) [13] invertX (!=0) [14] invertY (!=0)
// ---------------------------------------------------------------------------
EMSCRIPTEN_KEEPALIVE
void* nisps_pipeline_create(void) {
return static_cast<void*>(new PipelineHandle());
}
EMSCRIPTEN_KEEPALIVE
void nisps_pipeline_destroy(void* p) {
if (!p) return;
delete static_cast<PipelineHandle*>(p);
}
EMSCRIPTEN_KEEPALIVE
void nisps_input_set_config(void* p, const float* cfg, int n) {
if (!p || !cfg || n < 15) return;
auto* h = static_cast<PipelineHandle*>(p);
nisps::pipeline::InputChainConfig c;
c.zoom = cfg[0];
c.zoom_x = cfg[1];
c.zoom_y = cfg[2];
c.anchor_x = cfg[3];
c.anchor_y = cfg[4];
c.anchor_mode = static_cast<nisps::pipeline::AnchorMode>(
static_cast<int>(cfg[5]) == 1 ? 1 : (static_cast<int>(cfg[5]) == 2 ? 2 : 0));
c.deadzone = cfg[6];
c.input_curve = cfg[7];
c.curve_x = cfg[8];
c.curve_y = cfg[9];
c.smoothing = cfg[10];
c.momentum_mode = static_cast<nisps::pipeline::MomentumMode>(
static_cast<int>(cfg[11]) == 1 ? 1 : (static_cast<int>(cfg[11]) == 2 ? 2 : 0));
c.velocity_window_s = cfg[12];
c.invert_x = cfg[13] != 0.f;
c.invert_y = cfg[14] != 0.f;
h->input.set_config(c);
}
// Returns 1 when the chain is frozen (both axes at the freeze threshold).
EMSCRIPTEN_KEEPALIVE
int nisps_input_process(void* p, float x, float y, float dt_s, float* out_xy) {
if (!p || !out_xy) return 0;
auto* h = static_cast<PipelineHandle*>(p);
const auto r = h->input.process(x, y, dt_s);
out_xy[0] = r.x;
out_xy[1] = r.y;
return r.frozen ? 1 : 0;
}
EMSCRIPTEN_KEEPALIVE
void nisps_input_reset(void* p) {
if (!p) return;
static_cast<PipelineHandle*>(p)->input.reset();
}
EMSCRIPTEN_KEEPALIVE
void nisps_output_set_config(void* p, float global_curve, float smoothing,
float slew_rate, int freeze) {
if (!p) return;
auto* h = static_cast<PipelineHandle*>(p);
nisps::pipeline::OutputChainConfig c;
c.global_curve = global_curve;
c.smoothing = smoothing;
c.slew_rate = slew_rate; // <= 0 ⇒ unlimited (the TS Infinity default)
c.freeze_output = freeze != 0;
h->output.set_config(c);
}
EMSCRIPTEN_KEEPALIVE
void nisps_output_set_freeze_mask(void* p, const uint8_t* mask, int n) {
if (!p) return;
auto* h = static_cast<PipelineHandle*>(p);
if (!mask || n <= 0) {
h->output.clear_freeze_mask();
return;
}
h->output.set_freeze_mask(
std::span<const std::uint8_t>(mask, static_cast<std::size_t>(n)));
}
// In-place: processes the first n floats of `inout`.
EMSCRIPTEN_KEEPALIVE
void nisps_output_process(void* p, float* inout, int n, float dt_s) {
if (!p || !inout || n <= 0) return;
auto* h = static_cast<PipelineHandle*>(p);
const std::size_t count = static_cast<std::size_t>(n);
h->output.process(std::span<const float>(inout, count),
std::span<float>(inout, count), dt_s);
}
EMSCRIPTEN_KEEPALIVE
void nisps_output_reset(void* p) {
if (!p) return;
static_cast<PipelineHandle*>(p)->output.reset();
}
// Persistence: [input state (3)] + [output state (1 + 2*count)].
EMSCRIPTEN_KEEPALIVE
int nisps_pipeline_state_size(void* p) {
if (!p) return 0;
auto* h = static_cast<PipelineHandle*>(p);
return static_cast<int>(h->input.state_size() + h->output.state_size());
}
EMSCRIPTEN_KEEPALIVE
void nisps_pipeline_save_state(void* p, float* out) {
if (!p || !out) return;
auto* h = static_cast<PipelineHandle*>(p);
const std::size_t in_n = h->input.state_size();
h->input.save_state(std::span<float>(out, in_n));
h->output.save_state(std::span<float>(out + in_n, h->output.state_size()));
}
EMSCRIPTEN_KEEPALIVE
void nisps_pipeline_load_state(void* p, const float* in, int n) {
if (!p || !in || n <= 0) return;
auto* h = static_cast<PipelineHandle*>(p);
const std::size_t in_n = h->input.state_size();
const std::size_t total = static_cast<std::size_t>(n);
if (total < in_n) return;
h->input.load_state(std::span<const float>(in, in_n));
h->output.load_state(std::span<const float>(in + in_n, total - in_n));
}
// ---------------------------------------------------------------------------
// Curve catalog (one-core-engine P4): nisps/core/math.hpp is the single
// source of truth; the browser samples it instead of mirroring the maths.
// ids 0..6 = nisps::Curve (param ignored); id 7 = centred power (param =
// exponent). UI curve previews render by sampling the batch call.
// ---------------------------------------------------------------------------
EMSCRIPTEN_KEEPALIVE
float nisps_curve_apply(int id, float x, float param) {
if (id == 7) return nisps::centered_power(x, param);
if (id < 0 || id > 6) return x;
return nisps::apply_curve(static_cast<nisps::Curve>(id), nisps::clamp01(x));
}
EMSCRIPTEN_KEEPALIVE
void nisps_curve_apply_batch(int id, const float* xs, float* out, int n, float param) {
if (!xs || !out || n <= 0) return;
for (int i = 0; i < n; ++i) {
out[i] = nisps_curve_apply(id, xs[i], param);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Engine lifecycle // Engine lifecycle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -53,6 +53,12 @@ EXPORTED_FUNCS='[
"_nisps_ml_jolt_press","_nisps_ml_jolt_step","_nisps_ml_jolt_release", "_nisps_ml_jolt_press","_nisps_ml_jolt_step","_nisps_ml_jolt_release",
"_nisps_ml_jolt_active","_nisps_ml_jolt_lr_scale","_nisps_ml_jolt_tick_lr_ramp", "_nisps_ml_jolt_active","_nisps_ml_jolt_lr_scale","_nisps_ml_jolt_tick_lr_ramp",
"_nisps_ml_explore_intensity","_nisps_ml_explore_get_intensity","_nisps_ml_explore_apply", "_nisps_ml_explore_intensity","_nisps_ml_explore_get_intensity","_nisps_ml_explore_apply",
"_nisps_pipeline_create","_nisps_pipeline_destroy",
"_nisps_input_set_config","_nisps_input_process","_nisps_input_reset",
"_nisps_output_set_config","_nisps_output_set_freeze_mask",
"_nisps_output_process","_nisps_output_reset",
"_nisps_pipeline_state_size","_nisps_pipeline_save_state","_nisps_pipeline_load_state",
"_nisps_curve_apply","_nisps_curve_apply_batch",
"_nisps_ml_get_layer_stats","_nisps_ml_describe", "_nisps_ml_get_layer_stats","_nisps_ml_describe",
"_nisps_engine_create","_nisps_engine_destroy", "_nisps_engine_create","_nisps_engine_destroy",
"_nisps_engine_set_params","_nisps_engine_process_block" "_nisps_engine_set_params","_nisps_engine_process_block"

View file

@ -55,10 +55,13 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "../../nisps/core/math.hpp"
#include "../../nisps/engines/channel_strip.hpp" #include "../../nisps/engines/channel_strip.hpp"
#include "../../nisps/engines/paf_synth.hpp" #include "../../nisps/engines/paf_synth.hpp"
#include "../../nisps/ml/feedback.hpp" #include "../../nisps/ml/feedback.hpp"
#include "../../nisps/ml/mlp.hpp" #include "../../nisps/ml/mlp.hpp"
#include "../../nisps/pipeline/input_chain.hpp"
#include "../../nisps/pipeline/output_chain.hpp"
namespace { namespace {
@ -83,7 +86,7 @@ constexpr std::array<std::size_t, 12u> kProbeIdx = {
}; };
constexpr std::uint32_t kMagic = 0x5450524Eu; // 'NPRT' constexpr std::uint32_t kMagic = 0x5450524Eu; // 'NPRT'
constexpr std::uint32_t kVersion = 4u; // v4 adds stage 6 (geometric dislike) constexpr std::uint32_t kVersion = 5u; // v5 adds stage 7 (pipelines + curves)
// Must match the salt in nisps/wasm/bindings.cpp MLHandle so the controller's // Must match the salt in nisps/wasm/bindings.cpp MLHandle so the controller's
// static-output RNG stream is identical native ↔ WASM. // static-output RNG stream is identical native ↔ WASM.
@ -324,6 +327,85 @@ int main(int argc, char** argv) {
} }
} }
// ---- Stage 7: pipelines + curves (one-core-engine P4) ----
// Deterministic rational traces (no transcendentals crossing the JS/C++
// boundary) through the input chain (2 configs), the output chain
// (2 configs), and the curve catalog. parity_wasm.mjs drives the same
// sequences through the C ABI.
{
// -- input chain: default config, then a loaded config --
auto run_input = [&payload](const nisps::pipeline::InputChainConfig& cfg) {
nisps::pipeline::InputChain ch;
ch.set_config(cfg);
const float dt = 1.f / 120.f;
for (int i = 0; i < 120; ++i) {
const float x = static_cast<float>((i * 37) % 97) / 96.f;
const float y = static_cast<float>((i * 53 + 11) % 89) / 88.f;
const auto r = ch.process(x, y, dt);
if (i % 10 == 9) {
payload.push_back(r.x);
payload.push_back(r.y);
}
}
};
run_input(nisps::pipeline::InputChainConfig{});
{
nisps::pipeline::InputChainConfig c;
c.zoom = 0.7f;
c.deadzone = 0.1f;
c.input_curve = 1.8f;
c.smoothing = 0.6f;
c.momentum_mode = nisps::pipeline::MomentumMode::Strong;
c.invert_x = true;
run_input(c);
}
// -- output chain: default, then loaded config with a freeze mask --
auto run_output = [&payload](const nisps::pipeline::OutputChainConfig& cfg,
bool with_mask) {
nisps::pipeline::OutputChain<16u> ch;
ch.set_config(cfg);
if (with_mask) {
std::uint8_t mask[16u];
for (std::size_t j = 0; j < 16u; ++j) {
mask[j] = (j % 2u == 0u) ? 1u : 0u;
}
ch.set_freeze_mask(mask);
}
const float dt = 1.f / 60.f;
float vec[16u];
float out[16u];
for (int i = 0; i < 60; ++i) {
for (std::size_t j = 0; j < 16u; ++j) {
vec[j] = static_cast<float>((i * 13 + static_cast<int>(j) * 29) % 101) / 100.f;
}
ch.process(std::span<const float>(vec), std::span<float>(out), dt);
if (i % 15 == 14) {
for (std::size_t j = 0; j < 16u; ++j) payload.push_back(out[j]);
}
}
};
run_output(nisps::pipeline::OutputChainConfig{}, false);
{
nisps::pipeline::OutputChainConfig c;
c.global_curve = 2.2f;
c.smoothing = 0.5f;
c.slew_rate = 2.0f;
run_output(c, true);
}
// -- curve catalog: ids 0..6 (enum) + centred power (param 1.7) --
for (int id = 0; id <= 7; ++id) {
for (int i = 0; i <= 16; ++i) {
const float x = static_cast<float>(i) / 16.f;
const float v = (id == 7)
? nisps::centered_power(x, 1.7f)
: nisps::apply_curve(static_cast<nisps::Curve>(id), x);
payload.push_back(v);
}
}
}
// ---- Sanity: every value finite ---- // ---- Sanity: every value finite ----
for (std::size_t i = 0; i < payload.size(); ++i) { for (std::size_t i = 0; i < payload.size(); ++i) {
if (!std::isfinite(payload[i])) { if (!std::isfinite(payload[i])) {

View file

@ -16,7 +16,7 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
const MAGIC = 0x5450524e; const MAGIC = 0x5450524e;
const VERSION = 4; // v4 adds stage 6 (geometric dislike) const VERSION = 5; // v5 adds stage 7 (pipelines + curves)
const DEFAULT_TOL = 1e-5; const DEFAULT_TOL = 1e-5;
// Layout for context in error messages — must match parity_check.cpp / parity_wasm.mjs. // Layout for context in error messages — must match parity_check.cpp / parity_wasm.mjs.

View file

@ -38,7 +38,7 @@ const __dirname = dirname(__filename);
const repoRoot = resolve(__dirname, '..', '..'); const repoRoot = resolve(__dirname, '..', '..');
const MAGIC = 0x5450524e; // 'NPRT' const MAGIC = 0x5450524e; // 'NPRT'
const VERSION = 4; // v4 adds stage 6 (geometric dislike) const VERSION = 5; // v5 adds stage 7 (pipelines + curves)
const SEED = 42 >>> 0; const SEED = 42 >>> 0;
const INPUT_X = 0.25; const INPUT_X = 0.25;
@ -116,6 +116,15 @@ function bind(Module) {
feedbackNegativeCount: cwrap('nisps_ml_feedback_negative_count', 'number', ['number']), feedbackNegativeCount: cwrap('nisps_ml_feedback_negative_count', 'number', ['number']),
describe: cwrap('nisps_ml_describe', null, ['number','number']), describe: cwrap('nisps_ml_describe', null, ['number','number']),
pipelineCreate: cwrap('nisps_pipeline_create', 'number', []),
pipelineDestroy: cwrap('nisps_pipeline_destroy', null, ['number']),
inputSetConfig: cwrap('nisps_input_set_config', null, ['number','number','number']),
inputProcess: cwrap('nisps_input_process', 'number', ['number','number','number','number','number']),
outputSetConfig: cwrap('nisps_output_set_config', null, ['number','number','number','number','number']),
outputSetFreezeMask: cwrap('nisps_output_set_freeze_mask', null, ['number','number','number']),
outputProcess: cwrap('nisps_output_process', null, ['number','number','number','number']),
curveApply: cwrap('nisps_curve_apply', 'number', ['number','number','number']),
engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']), engineCreate: cwrap('nisps_engine_create', 'number', ['string','number']),
engineDestroy: cwrap('nisps_engine_destroy', null, ['number']), engineDestroy: cwrap('nisps_engine_destroy', null, ['number']),
engineSetParams: cwrap('nisps_engine_set_params', null, ['number','number','number']), engineSetParams: cwrap('nisps_engine_set_params', null, ['number','number','number']),
@ -379,6 +388,78 @@ async function main() {
api.destroy(ml); api.destroy(ml);
// --- Stage 7: pipelines + curves (one-core-engine P4) ---
// Mirrors parity_check.cpp stage 7: rational traces (bit-exact across the
// JS/C++ boundary) through the input chain, output chain, and curve
// catalog via the C ABI.
const pipelineFloats = [];
{
const outXY = api.malloc(8);
const cfgBuf = api.malloc(15 * 4);
// Input-config wire layout (see bindings.cpp): [zoom, zoomX, zoomY,
// anchorX, anchorY, anchorMode, deadzone, inputCurve, curveX, curveY,
// smoothing, momentumMode, velocityWindowS, invertX, invertY]
const runInput = (cfg) => {
const p = api.pipelineCreate();
new Float32Array(api.HEAPF32.buffer, cfgBuf, 15).set(cfg);
api.inputSetConfig(p, cfgBuf, 15);
const dt = Math.fround(1 / 120);
for (let i = 0; i < 120; i++) {
const x = Math.fround(((i * 37) % 97) / 96);
const y = Math.fround(((i * 53 + 11) % 89) / 88);
api.inputProcess(p, x, y, dt, outXY);
if (i % 10 === 9) {
const v = new Float32Array(api.HEAPF32.buffer, outXY, 2);
pipelineFloats.push(v[0], v[1]);
}
}
api.pipelineDestroy(p);
};
const defIn = [1, 0, 0, 0.5, 0.5, 2, 0, 1, 0, 0, 0, 0, 0.15, 0, 0];
runInput(defIn);
runInput([0.7, 0, 0, 0.5, 0.5, 2, 0.1, 1.8, 0, 0, 0.6, 2, 0.15, 1, 0]);
const N16 = 16;
const vecBuf = api.malloc(N16 * 4);
const maskBuf = api.malloc(N16);
const runOutput = (curve, smoothing, slew, withMask) => {
const p = api.pipelineCreate();
api.outputSetConfig(p, curve, smoothing, slew, 0);
if (withMask) {
const m = new Uint8Array(api.HEAPF32.buffer, maskBuf, N16);
for (let j = 0; j < N16; j++) m[j] = j % 2 === 0 ? 1 : 0;
api.outputSetFreezeMask(p, maskBuf, N16);
}
const dt = Math.fround(1 / 60);
for (let i = 0; i < 60; i++) {
const vec = new Float32Array(api.HEAPF32.buffer, vecBuf, N16);
for (let j = 0; j < N16; j++) {
vec[j] = Math.fround(((i * 13 + j * 29) % 101) / 100);
}
api.outputProcess(p, vecBuf, N16, dt);
if (i % 15 === 14) {
const out = new Float32Array(api.HEAPF32.buffer, vecBuf, N16);
for (let j = 0; j < N16; j++) pipelineFloats.push(out[j]);
}
}
api.pipelineDestroy(p);
};
runOutput(1, 0, 0, false); // defaults (slew 0 = unlimited)
runOutput(2.2, 0.5, 2.0, true);
for (let id = 0; id <= 7; id++) {
for (let i = 0; i <= 16; i++) {
pipelineFloats.push(api.curveApply(id, Math.fround(i / 16), 1.7));
}
}
api.free(outXY);
api.free(cfgBuf);
api.free(vecBuf);
api.free(maskBuf);
}
// --- Build payload, write blob --- // --- Build payload, write blob ---
const payload = []; const payload = [];
for (const v of outsStage1) payload.push(v); for (const v of outsStage1) payload.push(v);
@ -388,6 +469,7 @@ async function main() {
payload.push(pafL, pafR); payload.push(pafL, pafR);
payload.push(csL, csR); payload.push(csL, csR);
for (const v of feedbackFloats) payload.push(v); for (const v of feedbackFloats) payload.push(v);
for (const v of pipelineFloats) payload.push(v);
// Sanity: all finite. // Sanity: all finite.
for (let i = 0; i < payload.length; ++i) { for (let i = 0; i < payload.length; ++i) {

238
tests/cpp/test_pipeline.cpp Normal file
View file

@ -0,0 +1,238 @@
// tests/cpp/test_pipeline.cpp — nisps/pipeline input+output chains
// (one-core-engine P4). Unit semantics; the fixture regression against the
// pre-migration TS goldens runs browser-side (manifold pipeline-golden test
// flipped onto the WASM build), and native↔WASM agreement is parity stage 7.
#include <array>
#include <cmath>
#include <cstdint>
#include <span>
#include "../../nisps/core/math.hpp"
#include "../../nisps/pipeline/input_chain.hpp"
#include "../../nisps/pipeline/output_chain.hpp"
#include "test_helpers.hpp"
namespace {
using nisps::pipeline::AnchorMode;
using nisps::pipeline::InputChain;
using nisps::pipeline::InputChainConfig;
using nisps::pipeline::MomentumMode;
using nisps::pipeline::OutputChain;
using nisps::pipeline::OutputChainConfig;
constexpr float kDt = 1.f / 120.f;
} // namespace
// -- input chain ----------------------------------------------------------------
NISPS_TEST(input_chain_identity_default) {
InputChain ch;
const auto r = ch.process(0.3f, 0.8f, kDt);
NISPS_EXPECT(!r.frozen);
NISPS_EXPECT_NEAR(r.x, 0.3f, 1e-6);
NISPS_EXPECT_NEAR(r.y, 0.8f, 1e-6);
}
NISPS_TEST(input_chain_deadzone_remap) {
InputChain ch;
InputChainConfig c;
c.deadzone = 0.2f; // half-dz 0.1 around 0.5
ch.set_config(c);
// Inside the deadzone → pinned to centre.
NISPS_EXPECT_NEAR(ch.process(0.55f, 0.5f, kDt).x, 0.5f, 1e-6);
// Live-zone endpoints preserved.
NISPS_EXPECT_NEAR(ch.process(1.f, 0.5f, kDt).x, 1.f, 1e-6);
NISPS_EXPECT_NEAR(ch.process(0.f, 0.5f, kDt).x, 0.f, 1e-6);
// Just outside the deadzone remaps continuously from centre.
const float just = ch.process(0.6f + 1e-3f, 0.5f, kDt).x;
NISPS_EXPECT(just > 0.5f && just < 0.52f);
}
NISPS_TEST(input_chain_circular_clamp) {
InputChain ch;
// A corner (1,1) is outside the unit disk around (0.5,0.5) → clamped to
// the rim, direction preserved.
const auto r = ch.process(1.f, 1.f, kDt);
const float cx = r.x - 0.5f;
const float cy = r.y - 0.5f;
NISPS_EXPECT_NEAR(std::sqrt(cx * cx + cy * cy), 0.5f, 1e-5);
NISPS_EXPECT_NEAR(cx, cy, 1e-6);
}
NISPS_TEST(input_chain_zoom_and_freeze) {
InputChain ch;
InputChainConfig c;
c.zoom = 0.5f; // half window around the centre anchor
ch.set_config(c);
NISPS_EXPECT_NEAR(ch.process(1.f, 0.5f, kDt).x, 0.75f, 1e-6);
NISPS_EXPECT_NEAR(ch.process(0.f, 0.5f, kDt).x, 0.25f, 1e-6);
// Zoom at the freeze threshold: holds the last smoothed value.
ch.process(0.75f, 0.25f, kDt); // establish state (0.625, 0.375)
c.zoom = 0.01f;
ch.set_config(c);
const auto frozen = ch.process(0.f, 1.f, kDt);
NISPS_EXPECT(frozen.frozen);
NISPS_EXPECT_NEAR(frozen.x, 0.625f, 1e-5);
NISPS_EXPECT_NEAR(frozen.y, 0.375f, 1e-5);
}
NISPS_TEST(input_chain_sticky_anchor) {
InputChain ch;
InputChainConfig c;
c.zoom = 0.2f;
c.anchor_mode = AnchorMode::Sticky;
c.anchor_x = 0.8f;
c.anchor_y = 0.2f;
ch.set_config(c);
const auto r = ch.process(0.5f, 0.5f, kDt); // centred stick → exactly the anchor
NISPS_EXPECT_NEAR(r.x, 0.8f, 1e-6);
NISPS_EXPECT_NEAR(r.y, 0.2f, 1e-6);
}
NISPS_TEST(input_chain_ema_frame_rate_independent) {
// Same wall-time travel at different tick rates converges to ~the same
// place (the alpha_eff dt-compensation).
auto run = [](float dt, int steps) {
InputChain ch;
InputChainConfig c;
c.smoothing = 0.8f;
ch.set_config(c);
float x = 0.f;
for (int i = 0; i < steps; ++i) x = ch.process(1.f, 0.5f, dt).x;
return x;
};
const float at60 = run(1.f / 60.f, 60); // 1 s
const float at240 = run(1.f / 240.f, 240); // 1 s
NISPS_EXPECT_NEAR(at60, at240, 5e-3);
NISPS_EXPECT(at60 > 0.9f);
}
NISPS_TEST(input_chain_momentum_zooms_out_on_speed) {
InputChain ch;
InputChainConfig c;
c.momentum_mode = MomentumMode::Strong;
ch.set_config(c);
// Sweep fast across the full range: multiplier should drop below 1.
float x = 0.f;
for (int i = 0; i <= 24; ++i) {
x = static_cast<float>(i) / 24.f;
ch.process(x, 0.5f, kDt);
}
NISPS_EXPECT(ch.momentum_multiplier() < 0.9f);
// Dwell: multiplier recovers toward 1.
for (int i = 0; i < 240; ++i) ch.process(1.f, 0.5f, kDt);
NISPS_EXPECT(ch.momentum_multiplier() > 0.95f);
}
NISPS_TEST(input_chain_state_round_trip) {
InputChain a;
InputChainConfig c;
c.smoothing = 0.5f;
a.set_config(c);
for (int i = 0; i < 10; ++i) a.process(0.9f, 0.1f, kDt);
std::array<float, InputChain::state_size()> blob{};
a.save_state(blob);
InputChain b;
b.set_config(c);
b.load_state(blob);
const auto ra = a.process(0.9f, 0.1f, kDt);
const auto rb = b.process(0.9f, 0.1f, kDt);
NISPS_EXPECT(ra.x == rb.x);
NISPS_EXPECT(ra.y == rb.y);
}
// -- output chain ---------------------------------------------------------------
NISPS_TEST(output_chain_identity_default) {
OutputChain<8u> ch;
const float raw[4] = {0.1f, 0.5f, 0.9f, 1.2f};
float out[4];
ch.process(std::span<const float>(raw), std::span<float>(out), kDt);
NISPS_EXPECT_NEAR(out[0], 0.1f, 1e-6);
NISPS_EXPECT_NEAR(out[3], 1.0f, 1e-6); // clamped
}
NISPS_TEST(output_chain_global_curve) {
OutputChain<8u> ch;
OutputChainConfig c;
c.global_curve = 2.0f;
ch.set_config(c);
const float raw[2] = {0.5f, 0.9f};
float out[2];
ch.process(std::span<const float>(raw), std::span<float>(out), kDt);
NISPS_EXPECT_NEAR(out[0], 0.25f, 1e-6);
NISPS_EXPECT_NEAR(out[1], 0.81f, 1e-5);
}
NISPS_TEST(output_chain_slew_limits_change) {
OutputChain<4u> ch;
OutputChainConfig c;
c.slew_rate = 1.0f; // one full unit per second
ch.set_config(c);
const float step0[1] = {0.f};
const float step1[1] = {1.f};
float out[1];
ch.process(std::span<const float>(step0), std::span<float>(out), kDt); // seed at 0
ch.process(std::span<const float>(step1), std::span<float>(out), kDt);
NISPS_EXPECT_NEAR(out[0], kDt, 1e-6); // limited to slew*dt
ch.process(std::span<const float>(step1), std::span<float>(out), kDt);
NISPS_EXPECT_NEAR(out[0], 2.f * kDt, 1e-6);
}
NISPS_TEST(output_chain_freeze_gate_and_mask) {
OutputChain<4u> ch;
const float a[2] = {0.2f, 0.8f};
float out[2];
ch.process(std::span<const float>(a), std::span<float>(out), kDt);
// Global freeze holds prior values.
OutputChainConfig c;
c.freeze_output = true;
ch.set_config(c);
const float b[2] = {0.9f, 0.1f};
ch.process(std::span<const float>(b), std::span<float>(out), kDt);
NISPS_EXPECT_NEAR(out[0], 0.2f, 1e-6);
NISPS_EXPECT_NEAR(out[1], 0.8f, 1e-6);
// Per-output mask freezes only masked dims.
c.freeze_output = false;
ch.set_config(c);
const std::uint8_t mask[2] = {1u, 0u};
ch.set_freeze_mask(mask);
ch.process(std::span<const float>(b), std::span<float>(out), kDt);
NISPS_EXPECT_NEAR(out[0], 0.2f, 1e-6); // frozen
NISPS_EXPECT_NEAR(out[1], 0.1f, 1e-6); // live
}
NISPS_TEST(output_chain_reseed_on_length_change) {
OutputChain<8u> ch;
const float a4[4] = {0.1f, 0.2f, 0.3f, 0.4f};
float out4[4];
ch.process(std::span<const float>(a4), std::span<float>(out4), kDt);
// Shorter vector reseeds rather than reusing stale state.
OutputChainConfig c;
c.slew_rate = 0.001f; // would clamp hard if prev were stale
ch.set_config(c);
const float a2[2] = {0.9f, 0.9f};
float out2[2];
ch.process(std::span<const float>(a2), std::span<float>(out2), kDt);
NISPS_EXPECT_NEAR(out2[0], 0.9f, 1e-6); // seeded fresh from raw
}
// -- curve catalog --------------------------------------------------------------
NISPS_TEST(centered_power_pivots_at_half) {
NISPS_EXPECT_NEAR(nisps::centered_power(0.5f, 3.f), 0.5f, 1e-7);
NISPS_EXPECT_NEAR(nisps::centered_power(0.f, 1.f), 0.f, 1e-7);
NISPS_EXPECT_NEAR(nisps::centered_power(1.f, 2.f), 1.f, 1e-6);
// exponent > 1 pulls toward the centre.
NISPS_EXPECT(nisps::centered_power(0.75f, 2.f) < 0.75f);
// exponent < 1 pushes toward the extremes.
NISPS_EXPECT(nisps::centered_power(0.75f, 0.5f) > 0.75f);
}