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:
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.
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
273
nisps/pipeline/input_chain.hpp
Normal file
273
nisps/pipeline/input_chain.hpp
Normal 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
|
||||||
162
nisps/pipeline/output_chain.hpp
Normal file
162
nisps/pipeline/output_chain.hpp
Normal 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
|
||||||
|
|
@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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])) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
238
tests/cpp/test_pipeline.cpp
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue