feat: nisps/core foundation — perf, types, concepts, buffers, rng, math
Greenfield C++20 core for the unified firmware+WASM rewrite (architecture.md
streams, meml-dn7). Header-only, platform-agnostic, no heap, no virtual
dispatch.
Components:
- perf.hpp memory section + inlining macros, RP2040/RP2350-aware,
inert on host/Emscripten
- types.hpp stereosample_t (mirrors firmware AudioDriver API),
sample_t/param_t aliases, DriverConfig negotiation struct
- concepts.hpp MLEngine, AudioEngine, Mode (architecture §4.1-4.3)
- fixed_buffer.hpp std::array-backed cursor; replaces std::vector in hot paths
- ring_buffer.hpp SPSC lock-free FIFO, power-of-two capacity, atomic
head/tail; replaces pico/util/queue in core
- rng.hpp xoshiro256+ with splitmix64 seeding, uniform/signed/
gaussian-via-3-uniforms (matches legacy MoveWeights shape)
- math.hpp clamp01, fast_sigmoid (tanh-Padé, ~1.2% max err on [-6,6]),
exact_sigmoid, fast_exp, named Curve catalog (linear/exp/
log/square/sqrt/sigmoid/cubic) — TypeScript twin lives in
playground/src/output/curves.ts (stream 5)
Performance discipline (Chris's rules):
- No heap, no std::vector, no malloc/new in core
- All float literals carry .f suffix
- Memory section attrs syntactically present, inert on non-firmware builds
This commit is contained in:
parent
d3373a4e44
commit
4f60fc8405
8 changed files with 537 additions and 0 deletions
2
nisps/.gitignore
vendored
Normal file
2
nisps/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
build/
|
||||||
|
build-*/
|
||||||
85
nisps/core/concepts.hpp
Normal file
85
nisps/core/concepts.hpp
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// nisps/core/concepts.hpp — C++20 concepts that pin down the shape of the
|
||||||
|
// three central types in the rewrite: ML engines, audio engines and modes.
|
||||||
|
//
|
||||||
|
// These are CONCEPTS, not interfaces — there is no virtual dispatch in the
|
||||||
|
// hot paths. Concrete types are templated/satisfied at compile time on
|
||||||
|
// firmware; the WASM bindings layer flattens to a C API for the browser.
|
||||||
|
//
|
||||||
|
// See architecture.md §4.1, §4.2, §4.3.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <concepts>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "types.hpp"
|
||||||
|
|
||||||
|
namespace nisps {
|
||||||
|
|
||||||
|
// Forward declaration. Concrete `ParamSchema` lives with the codegen output
|
||||||
|
// (stream 5). Modes only need to expose a constexpr reference to it.
|
||||||
|
struct ParamSchema;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MLEngine — interactive ML inference + training + RL state perturbation.
|
||||||
|
// Implementations: nisps::MLP<NIn, NHidden..., NOut> in stream 2.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
template <typename T>
|
||||||
|
concept MLEngine = requires(T e,
|
||||||
|
std::span<const float> in,
|
||||||
|
std::span<float> out,
|
||||||
|
std::size_t idx,
|
||||||
|
float fv,
|
||||||
|
std::uint64_t seed) {
|
||||||
|
{ e.set_input(idx, fv) } -> std::same_as<void>;
|
||||||
|
{ e.process() } -> std::same_as<void>;
|
||||||
|
{ e.outputs() } -> std::same_as<std::span<const float>>;
|
||||||
|
{ e.add_example(in, in) } -> std::same_as<void>;
|
||||||
|
{ e.train() } -> std::same_as<float>; // returns final loss
|
||||||
|
{ e.move_weights(fv, fv) } -> std::same_as<void>; // (speed, spread)
|
||||||
|
{ e.draw_weights(fv) } -> std::same_as<void>; // (spread)
|
||||||
|
{ e.reset() } -> std::same_as<void>;
|
||||||
|
{ e.seed(seed) } -> std::same_as<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AudioEngine — per-sample stereo processor. Sample-rate is negotiated at
|
||||||
|
// setup; engines should NOT assume 48 kHz.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
template <typename T>
|
||||||
|
concept AudioEngine = requires(T e,
|
||||||
|
stereosample_t s,
|
||||||
|
std::span<const float> params,
|
||||||
|
float sr) {
|
||||||
|
{ T::param_count() } -> std::convertible_to<std::size_t>; // constexpr
|
||||||
|
{ T::engine_id() } -> std::same_as<std::string_view>; // constexpr
|
||||||
|
{ e.setup(sr) } -> std::same_as<void>;
|
||||||
|
{ e.set_params(params) } -> std::same_as<void>; // non-RT
|
||||||
|
{ e.process(s) } -> std::same_as<stereosample_t>; // RT, per-sample
|
||||||
|
{ e.driver_config() } -> std::convertible_to<DriverConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mode — binds an ML config + audio engine + voice space + I/O channel
|
||||||
|
// mapping. Hardware bindings live OUTSIDE this concept (firmware/glue,
|
||||||
|
// playground/src/modes); modes are platform-agnostic.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
template <typename T>
|
||||||
|
concept Mode = requires(T m, std::size_t idx, float v, stereosample_t s, float sr) {
|
||||||
|
{ T::mode_id() } -> std::same_as<std::string_view>; // constexpr
|
||||||
|
{ T::input_channel_count() } -> std::convertible_to<std::size_t>; // constexpr
|
||||||
|
{ T::param_schema() } -> std::same_as<const ParamSchema&>; // constexpr ref
|
||||||
|
{ m.setup(sr) } -> std::same_as<void>;
|
||||||
|
{ m.set_input(idx, v) } -> std::same_as<void>;
|
||||||
|
{ m.tick_control() } -> std::same_as<void>; // non-RT
|
||||||
|
{ m.process(s) } -> std::same_as<stereosample_t>; // RT
|
||||||
|
// engine() / ml() return references; we only assert they are callable.
|
||||||
|
// (`auto&` in concept signatures is awkward; require non-void.)
|
||||||
|
{ m.engine() };
|
||||||
|
{ m.ml() };
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
77
nisps/core/fixed_buffer.hpp
Normal file
77
nisps/core/fixed_buffer.hpp
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// nisps/core/fixed_buffer.hpp — heap-free dynamic-length array with
|
||||||
|
// compile-time capacity. Replaces std::vector in hot paths.
|
||||||
|
//
|
||||||
|
// API mirrors a tiny subset of std::vector: push_back / clear / size / data /
|
||||||
|
// operator[] / iterators / front / back. NO reserve, NO resize-with-default,
|
||||||
|
// NO insert. If you need those, you're probably reaching for the wrong tool.
|
||||||
|
//
|
||||||
|
// Bounds checking: push_back returns bool (false ⇒ full, no-op). operator[]
|
||||||
|
// is unchecked — match std::vector's behavior.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <type_traits>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace nisps {
|
||||||
|
|
||||||
|
template <typename T, std::size_t N>
|
||||||
|
class FixedBuffer {
|
||||||
|
public:
|
||||||
|
using value_type = T;
|
||||||
|
using size_type = std::size_t;
|
||||||
|
using iterator = T*;
|
||||||
|
using const_iterator = const T*;
|
||||||
|
|
||||||
|
constexpr FixedBuffer() noexcept = default;
|
||||||
|
|
||||||
|
// Trivially copyable when T is. Move/copy semantics fine — backing store
|
||||||
|
// is std::array, which has correct value-semantics.
|
||||||
|
|
||||||
|
constexpr size_type size() const noexcept { return n_; }
|
||||||
|
static constexpr size_type capacity() noexcept { return N; }
|
||||||
|
constexpr bool empty() const noexcept { return n_ == 0u; }
|
||||||
|
constexpr bool full() const noexcept { return n_ == N; }
|
||||||
|
|
||||||
|
constexpr T* data() noexcept { return buf_.data(); }
|
||||||
|
constexpr const T* data() const noexcept { return buf_.data(); }
|
||||||
|
|
||||||
|
constexpr T& operator[](size_type i) noexcept { return buf_[i]; }
|
||||||
|
constexpr const T& operator[](size_type i) const noexcept { return buf_[i]; }
|
||||||
|
|
||||||
|
constexpr T& front() noexcept { return buf_[0]; }
|
||||||
|
constexpr const T& front() const noexcept { return buf_[0]; }
|
||||||
|
constexpr T& back() noexcept { return buf_[n_ - 1u]; }
|
||||||
|
constexpr const T& back() const noexcept { return buf_[n_ - 1u]; }
|
||||||
|
|
||||||
|
constexpr iterator begin() noexcept { return buf_.data(); }
|
||||||
|
constexpr const_iterator begin() const noexcept { return buf_.data(); }
|
||||||
|
constexpr iterator end() noexcept { return buf_.data() + n_; }
|
||||||
|
constexpr const_iterator end() const noexcept { return buf_.data() + n_; }
|
||||||
|
|
||||||
|
constexpr void clear() noexcept { n_ = 0u; }
|
||||||
|
|
||||||
|
// Returns true on success. Does NOT throw or assert when full — callers
|
||||||
|
// are expected to size the buffer correctly.
|
||||||
|
constexpr bool push_back(const T& v) noexcept(std::is_nothrow_copy_assignable_v<T>) {
|
||||||
|
if (n_ >= N) return false;
|
||||||
|
buf_[n_++] = v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
constexpr bool push_back(T&& v) noexcept(std::is_nothrow_move_assignable_v<T>) {
|
||||||
|
if (n_ >= N) return false;
|
||||||
|
buf_[n_++] = std::move(v);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pop_back — trivial decrement; does not destroy (T must be cleanup-free).
|
||||||
|
constexpr void pop_back() noexcept { if (n_ > 0u) --n_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::array<T, N> buf_{};
|
||||||
|
size_type n_ = 0u;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
108
nisps/core/math.hpp
Normal file
108
nisps/core/math.hpp
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// nisps/core/math.hpp — small math helpers used by the audio path, the ML
|
||||||
|
// activations and the parameter curve mapping.
|
||||||
|
//
|
||||||
|
// CONTRACT WITH TYPESCRIPT (stream 5)
|
||||||
|
// The `Curve` enum below is mirrored in `playground/src/output/curves.ts`. The
|
||||||
|
// numeric meaning of each variant must stay identical so a parameter routed
|
||||||
|
// through firmware and a parameter routed through the browser produce the
|
||||||
|
// same value. JSON schemas reference curves by string name, so the source of
|
||||||
|
// truth for the *names* lives in the schema validator, but the *math* lives
|
||||||
|
// here. Golden-vector tests in `tests/cpp/parity_check.cpp` (stream 11)
|
||||||
|
// will pin this down.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace nisps {
|
||||||
|
|
||||||
|
inline float clamp01(float x) noexcept {
|
||||||
|
if (x < 0.f) return 0.f;
|
||||||
|
if (x > 1.f) return 1.f;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline float clamp(float x, float lo, float hi) noexcept {
|
||||||
|
if (x < lo) return lo;
|
||||||
|
if (x > hi) return hi;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fast_sigmoid: 3rd-order rational approximation via the Padé-approximant of
|
||||||
|
// tanh. We use the identity sigmoid(x) = 0.5 + 0.5 * tanh(x/2), and approx
|
||||||
|
// tanh(u) ≈ u * (27 + u²) / (27 + 9u²)
|
||||||
|
// which is the (3,2)-Padé expansion of tanh around 0.
|
||||||
|
//
|
||||||
|
// Max error vs true logistic 1/(1+e^-x) over x ∈ [-6, 6]: ≈ 0.012 (~1.2%).
|
||||||
|
// Outside that range the approximation drifts, so we clamp to [0, 1].
|
||||||
|
//
|
||||||
|
// For the ML output activation we need monotonic, smooth, bounded — NOT
|
||||||
|
// log-likelihood-grade accuracy. The MLP can opt into either fast_sigmoid
|
||||||
|
// or exp-based exact_sigmoid; both are provided.
|
||||||
|
inline float fast_sigmoid(float x) noexcept {
|
||||||
|
const float u = x * 0.5f;
|
||||||
|
const float u2 = u * u;
|
||||||
|
const float t = u * (27.f + u2) / (27.f + 9.f * u2);
|
||||||
|
const float y = 0.5f + 0.5f * t;
|
||||||
|
if (y < 0.f) return 0.f;
|
||||||
|
if (y > 1.f) return 1.f;
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True sigmoid via std::exp. Exact, slower; provided so callers that need
|
||||||
|
// gradient-correct activations have a path that avoids the ~1.8% bias.
|
||||||
|
inline float exact_sigmoid(float x) noexcept {
|
||||||
|
// Guard against extreme inputs to avoid expf overflow / underflow noise.
|
||||||
|
if (x > 40.f) return 1.f;
|
||||||
|
if (x < -40.f) return 0.f;
|
||||||
|
return 1.f / (1.f + std::exp(-x));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate exp via the limit (1 + x/n)^n with n=256. Good to ~0.5% over
|
||||||
|
// x∈[-4, 4]; falls apart outside that range. Only use where exp is on the
|
||||||
|
// hot path AND inputs are bounded — otherwise reach for std::exp.
|
||||||
|
inline float fast_exp(float x) noexcept {
|
||||||
|
float r = 1.f + x * (1.f / 256.f);
|
||||||
|
r *= r; r *= r; r *= r; r *= r;
|
||||||
|
r *= r; r *= r; r *= r; r *= r; // 8 squarings ⇒ ^256
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Curve catalog. Each curve maps [0,1] → [0,1], monotone increasing, with
|
||||||
|
// the endpoints fixed at 0 and 1. The caller is responsible for clamping the
|
||||||
|
// input — apply_curve assumes x is already in range.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum class Curve : int {
|
||||||
|
linear = 0,
|
||||||
|
exp = 1,
|
||||||
|
log = 2,
|
||||||
|
square = 3,
|
||||||
|
sqrt = 4,
|
||||||
|
sigmoid = 5,
|
||||||
|
cubic = 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
inline float apply_curve(Curve c, float x) noexcept {
|
||||||
|
switch (c) {
|
||||||
|
case Curve::linear: return x;
|
||||||
|
case Curve::exp: // (e^x - 1) / (e - 1) — concave-up, slow start
|
||||||
|
return (std::exp(x) - 1.f) * (1.f / 1.71828182845904523536f);
|
||||||
|
case Curve::log: // log(1 + (e-1) x) — concave-down, fast start
|
||||||
|
return std::log(1.f + 1.71828182845904523536f * x);
|
||||||
|
case Curve::square: return x * x;
|
||||||
|
case Curve::sqrt: return std::sqrt(x);
|
||||||
|
case Curve::sigmoid: {
|
||||||
|
// S-curve through (0,0) and (1,1). Stretch logistic and rescale.
|
||||||
|
const float k = 6.f; // slope at midpoint
|
||||||
|
const float s = exact_sigmoid(k * (x - 0.5f));
|
||||||
|
const float s0 = exact_sigmoid(-k * 0.5f);
|
||||||
|
const float s1 = exact_sigmoid( k * 0.5f);
|
||||||
|
return (s - s0) / (s1 - s0);
|
||||||
|
}
|
||||||
|
case Curve::cubic: return x * x * x;
|
||||||
|
}
|
||||||
|
return x; // unreachable, silences -Wreturn-type
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
28
nisps/core/perf.hpp
Normal file
28
nisps/core/perf.hpp
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// nisps/core/perf.hpp — RP2040/RP2350 memory section + inlining attributes.
|
||||||
|
//
|
||||||
|
// On firmware builds the macros expand to GCC/Pico-specific section attributes
|
||||||
|
// so hot code/data lives in SRAM instead of XIP flash. On every other build
|
||||||
|
// (host tests, Emscripten/WASM) they are inert — the discipline of marking
|
||||||
|
// audio-critical declarations is preserved syntactically without affecting
|
||||||
|
// codegen.
|
||||||
|
//
|
||||||
|
// See architecture.md §3.4.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#if defined(ARDUINO_ARCH_RP2040) || defined(ARDUINO_ARCH_RP2350)
|
||||||
|
// Pico SDK provides __not_in_flash and __not_in_flash_func.
|
||||||
|
// __not_in_flash takes a section name string; __not_in_flash_func wraps the
|
||||||
|
// declaration directly.
|
||||||
|
#define NISPS_AUDIO_MEM __not_in_flash("audio")
|
||||||
|
#define NISPS_AUDIO_FUNC __not_in_flash_func
|
||||||
|
#define NISPS_APP_SRAM __not_in_flash("app")
|
||||||
|
#define NISPS_FORCE_INLINE __attribute__((always_inline)) inline
|
||||||
|
#define NISPS_HOT __attribute__((hot))
|
||||||
|
#else
|
||||||
|
#define NISPS_AUDIO_MEM
|
||||||
|
#define NISPS_AUDIO_FUNC(decl) decl
|
||||||
|
#define NISPS_APP_SRAM
|
||||||
|
#define NISPS_FORCE_INLINE inline
|
||||||
|
#define NISPS_HOT
|
||||||
|
#endif
|
||||||
80
nisps/core/ring_buffer.hpp
Normal file
80
nisps/core/ring_buffer.hpp
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// nisps/core/ring_buffer.hpp — single-producer single-consumer lock-free FIFO.
|
||||||
|
//
|
||||||
|
// Replaces pico/util/queue across the platform-agnostic core. On firmware,
|
||||||
|
// the inter-core hand-off can wrap this OR use queue_t directly — that
|
||||||
|
// decision lives in stream 6 (firmware glue). Within `nisps/`, this is the
|
||||||
|
// canonical channel.
|
||||||
|
//
|
||||||
|
// Design notes
|
||||||
|
// - Capacity N must be a power of two. We mask the head/tail indices instead
|
||||||
|
// of taking modulus; this lets the indices wrap naturally at size_t and we
|
||||||
|
// compare them with subtraction (i.e. `head - tail == N` ⇒ full).
|
||||||
|
// - T must be trivially copyable. We do not run T's destructor on pop —
|
||||||
|
// callers want POD-shaped messages here, not RAII handles.
|
||||||
|
// - Memory orders follow Vyukov's classic SPSC pattern:
|
||||||
|
// producer: relaxed load(tail), [write slot], release store(head)
|
||||||
|
// consumer: relaxed load(head), acquire load(head), [read slot], release store(tail)
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
namespace nisps {
|
||||||
|
|
||||||
|
template <typename T, std::size_t N>
|
||||||
|
class RingBuffer {
|
||||||
|
static_assert(N > 0u, "RingBuffer capacity must be > 0");
|
||||||
|
static_assert((N & (N - 1u)) == 0u, "RingBuffer capacity must be power of two");
|
||||||
|
static_assert(std::is_trivially_copyable_v<T>,
|
||||||
|
"RingBuffer element type must be trivially copyable");
|
||||||
|
|
||||||
|
public:
|
||||||
|
static constexpr std::size_t capacity() noexcept { return N; }
|
||||||
|
|
||||||
|
RingBuffer() noexcept : head_(0u), tail_(0u) {}
|
||||||
|
|
||||||
|
// No copy / no move — atomics aren't trivially movable and there's no
|
||||||
|
// good story for "transfer half-full ring under contention".
|
||||||
|
RingBuffer(const RingBuffer&) = delete;
|
||||||
|
RingBuffer& operator=(const RingBuffer&) = delete;
|
||||||
|
|
||||||
|
bool try_push(const T& v) noexcept {
|
||||||
|
const auto head = head_.load(std::memory_order_relaxed);
|
||||||
|
const auto tail = tail_.load(std::memory_order_acquire);
|
||||||
|
if (head - tail >= N) return false; // full
|
||||||
|
buf_[head & kMask] = v;
|
||||||
|
head_.store(head + 1u, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool try_pop(T& out) noexcept {
|
||||||
|
const auto tail = tail_.load(std::memory_order_relaxed);
|
||||||
|
const auto head = head_.load(std::memory_order_acquire);
|
||||||
|
if (head == tail) return false; // empty
|
||||||
|
out = buf_[tail & kMask];
|
||||||
|
tail_.store(tail + 1u, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate; relies on head/tail being read in arbitrary order. Use
|
||||||
|
// for diagnostics, not for synchronization.
|
||||||
|
std::size_t size_approx() const noexcept {
|
||||||
|
const auto h = head_.load(std::memory_order_relaxed);
|
||||||
|
const auto t = tail_.load(std::memory_order_relaxed);
|
||||||
|
return h - t;
|
||||||
|
}
|
||||||
|
bool empty_approx() const noexcept { return size_approx() == 0u; }
|
||||||
|
bool full_approx() const noexcept { return size_approx() >= N; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr std::size_t kMask = N - 1u;
|
||||||
|
|
||||||
|
// Head/tail use std::size_t and rely on natural unsigned wrap.
|
||||||
|
std::atomic<std::size_t> head_;
|
||||||
|
std::atomic<std::size_t> tail_;
|
||||||
|
T buf_[N]{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
90
nisps/core/rng.hpp
Normal file
90
nisps/core/rng.hpp
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// nisps/core/rng.hpp — deterministic PRNG used everywhere we need
|
||||||
|
// reproducibility (weight init, RL noise, dataset shuffle, golden vectors).
|
||||||
|
//
|
||||||
|
// xoshiro256+ — Blackman & Vigna (2018). 64-bit state, 4× u64 state words,
|
||||||
|
// passes BigCrush, fast (a handful of ALU ops), no branches in the hot path.
|
||||||
|
// We use the `+` variant rather than `**` because we only consume the high
|
||||||
|
// bits for floats; the low-bit linearity that pesters `+` for integer use is
|
||||||
|
// irrelevant once you mask off the float mantissa.
|
||||||
|
//
|
||||||
|
// Seeding: we splitmix64 the user-provided u64 seed to fan it out across the
|
||||||
|
// four state words, so seed=0 / seed=1 / seed=N all produce uncorrelated
|
||||||
|
// streams. (Pure xoshiro fails badly for all-zero state.)
|
||||||
|
//
|
||||||
|
// Gaussian: sum-of-three-uniforms. Cheaper than Box-Muller (no log/sin) and
|
||||||
|
// the existing JS engine uses the same shape (`gen_randn = sum of 3
|
||||||
|
// uniforms scaled by speed`), so by matching it we keep firmware ↔ browser
|
||||||
|
// noise statistically equivalent without a parity headache. Box-Muller would
|
||||||
|
// be more accurate, but we want compatibility with the legacy MoveWeights
|
||||||
|
// shape — see recon/01-ml-stack.md §3.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace nisps {
|
||||||
|
|
||||||
|
class Rng {
|
||||||
|
public:
|
||||||
|
explicit Rng(std::uint64_t seed) noexcept { this->seed(seed); }
|
||||||
|
|
||||||
|
void seed(std::uint64_t s) noexcept {
|
||||||
|
// splitmix64: avalanche the seed into four uncorrelated state words.
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
s += 0x9E3779B97F4A7C15ull;
|
||||||
|
std::uint64_t z = s;
|
||||||
|
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
|
||||||
|
z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
|
||||||
|
z = z ^ (z >> 31);
|
||||||
|
state_[i] = z;
|
||||||
|
}
|
||||||
|
// Guarantee non-zero state. Astronomically unlikely to be all-zero
|
||||||
|
// post-splitmix anyway, but defense in depth.
|
||||||
|
if ((state_[0] | state_[1] | state_[2] | state_[3]) == 0ull) {
|
||||||
|
state_[0] = 1ull;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::uint64_t next_u64() noexcept {
|
||||||
|
const std::uint64_t result = state_[0] + state_[3];
|
||||||
|
const std::uint64_t t = state_[1] << 17;
|
||||||
|
state_[2] ^= state_[0];
|
||||||
|
state_[3] ^= state_[1];
|
||||||
|
state_[1] ^= state_[2];
|
||||||
|
state_[0] ^= state_[3];
|
||||||
|
state_[2] ^= t;
|
||||||
|
state_[3] = rotl_(state_[3], 45);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniform float in [0, 1). Standard "top 24 bits → mantissa" trick.
|
||||||
|
inline float next_float_uniform() noexcept {
|
||||||
|
// 1.f / 2^24 = 5.9604644775390625e-08
|
||||||
|
constexpr float kInv = 1.f / 16777216.f;
|
||||||
|
return static_cast<float>(next_u64() >> 40) * kInv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniform float in [-1, 1).
|
||||||
|
inline float next_float_signed() noexcept {
|
||||||
|
return next_float_uniform() * 2.f - 1.f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approx Gaussian via sum of three [-1, 1) uniforms.
|
||||||
|
// Variance of one such uniform = 1/3, so summing three gives variance 1
|
||||||
|
// and stddev 1. We then scale by the requested stddev.
|
||||||
|
inline float next_float_gaussian(float stddev = 1.f) noexcept {
|
||||||
|
const float a = next_float_signed();
|
||||||
|
const float b = next_float_signed();
|
||||||
|
const float c = next_float_signed();
|
||||||
|
return (a + b + c) * stddev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static inline std::uint64_t rotl_(std::uint64_t x, int k) noexcept {
|
||||||
|
return (x << k) | (x >> (64 - k));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t state_[4]{};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
67
nisps/core/types.hpp
Normal file
67
nisps/core/types.hpp
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// nisps/core/types.hpp — Foundational scalar/sample types shared by every
|
||||||
|
// engine, mode and ML component.
|
||||||
|
//
|
||||||
|
// `stereosample_t` mirrors the layout/API used in firmware
|
||||||
|
// (`src/memllib/audio/AudioDriver.hpp`) so engine code can move between
|
||||||
|
// firmware and WASM unchanged. The `__force_inline` decoration from the
|
||||||
|
// firmware version is replaced with NISPS_FORCE_INLINE so the same source
|
||||||
|
// works on host builds.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "perf.hpp"
|
||||||
|
|
||||||
|
namespace nisps {
|
||||||
|
|
||||||
|
using sample_t = float;
|
||||||
|
using param_t = float;
|
||||||
|
|
||||||
|
struct stereosample_t {
|
||||||
|
float L;
|
||||||
|
float R;
|
||||||
|
|
||||||
|
NISPS_FORCE_INLINE stereosample_t operator+(const stereosample_t& o) const {
|
||||||
|
return {L + o.L, R + o.R};
|
||||||
|
}
|
||||||
|
NISPS_FORCE_INLINE stereosample_t& operator+=(const stereosample_t& o) {
|
||||||
|
L += o.L; R += o.R; return *this;
|
||||||
|
}
|
||||||
|
NISPS_FORCE_INLINE stereosample_t operator-() const { return {-L, -R}; }
|
||||||
|
NISPS_FORCE_INLINE stereosample_t operator-(const stereosample_t& o) const {
|
||||||
|
return {L - o.L, R - o.R};
|
||||||
|
}
|
||||||
|
NISPS_FORCE_INLINE stereosample_t& operator-=(const stereosample_t& o) {
|
||||||
|
L -= o.L; R -= o.R; return *this;
|
||||||
|
}
|
||||||
|
NISPS_FORCE_INLINE stereosample_t operator*(float s) const {
|
||||||
|
return {L * s, R * s};
|
||||||
|
}
|
||||||
|
NISPS_FORCE_INLINE stereosample_t& operator*=(float s) {
|
||||||
|
L *= s; R *= s; return *this;
|
||||||
|
}
|
||||||
|
NISPS_FORCE_INLINE float operator[](std::size_t i) const {
|
||||||
|
return i == 0u ? L : R;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audio driver / engine setup contract. Engines advertise the gain staging and
|
||||||
|
// sample-rate-hint they want; the concrete driver (firmware glue or WASM
|
||||||
|
// AudioWorklet) honours what it can. Negotiation, not imperative.
|
||||||
|
//
|
||||||
|
// `mic_input` true ⇒ codec is configured for mic-level input
|
||||||
|
// `mic_gain_db` dB of pre-amp gain when `mic_input` is true
|
||||||
|
// `line_level` 1..15 ish — codec line-input gain step
|
||||||
|
// `output_volume` 0..1 — analog out master
|
||||||
|
// `sample_rate` preferred rate (Hz); 0 means "don't care"
|
||||||
|
struct DriverConfig {
|
||||||
|
bool mic_input = false;
|
||||||
|
std::uint8_t mic_gain_db = 0;
|
||||||
|
std::uint8_t line_level = 0;
|
||||||
|
float output_volume = 1.f;
|
||||||
|
float sample_rate = 0.f;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace nisps
|
||||||
Loading…
Reference in a new issue