diff --git a/nisps/.gitignore b/nisps/.gitignore new file mode 100644 index 0000000..9bd80f2 --- /dev/null +++ b/nisps/.gitignore @@ -0,0 +1,2 @@ +build/ +build-*/ diff --git a/nisps/core/concepts.hpp b/nisps/core/concepts.hpp new file mode 100644 index 0000000..e42a74d --- /dev/null +++ b/nisps/core/concepts.hpp @@ -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 +#include +#include +#include +#include + +#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 in stream 2. +// --------------------------------------------------------------------------- +template +concept MLEngine = requires(T e, + std::span in, + std::span out, + std::size_t idx, + float fv, + std::uint64_t seed) { + { e.set_input(idx, fv) } -> std::same_as; + { e.process() } -> std::same_as; + { e.outputs() } -> std::same_as>; + { e.add_example(in, in) } -> std::same_as; + { e.train() } -> std::same_as; // returns final loss + { e.move_weights(fv, fv) } -> std::same_as; // (speed, spread) + { e.draw_weights(fv) } -> std::same_as; // (spread) + { e.reset() } -> std::same_as; + { e.seed(seed) } -> std::same_as; +}; + +// --------------------------------------------------------------------------- +// AudioEngine — per-sample stereo processor. Sample-rate is negotiated at +// setup; engines should NOT assume 48 kHz. +// --------------------------------------------------------------------------- +template +concept AudioEngine = requires(T e, + stereosample_t s, + std::span params, + float sr) { + { T::param_count() } -> std::convertible_to; // constexpr + { T::engine_id() } -> std::same_as; // constexpr + { e.setup(sr) } -> std::same_as; + { e.set_params(params) } -> std::same_as; // non-RT + { e.process(s) } -> std::same_as; // RT, per-sample + { e.driver_config() } -> std::convertible_to; +}; + +// --------------------------------------------------------------------------- +// 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 +concept Mode = requires(T m, std::size_t idx, float v, stereosample_t s, float sr) { + { T::mode_id() } -> std::same_as; // constexpr + { T::input_channel_count() } -> std::convertible_to; // constexpr + { T::param_schema() } -> std::same_as; // constexpr ref + { m.setup(sr) } -> std::same_as; + { m.set_input(idx, v) } -> std::same_as; + { m.tick_control() } -> std::same_as; // non-RT + { m.process(s) } -> std::same_as; // 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 diff --git a/nisps/core/fixed_buffer.hpp b/nisps/core/fixed_buffer.hpp new file mode 100644 index 0000000..e6a0b7e --- /dev/null +++ b/nisps/core/fixed_buffer.hpp @@ -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 +#include +#include +#include + +namespace nisps { + +template +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) { + if (n_ >= N) return false; + buf_[n_++] = v; + return true; + } + constexpr bool push_back(T&& v) noexcept(std::is_nothrow_move_assignable_v) { + 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 buf_{}; + size_type n_ = 0u; +}; + +} // namespace nisps diff --git a/nisps/core/math.hpp b/nisps/core/math.hpp new file mode 100644 index 0000000..cc6c4a3 --- /dev/null +++ b/nisps/core/math.hpp @@ -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 + +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 diff --git a/nisps/core/perf.hpp b/nisps/core/perf.hpp new file mode 100644 index 0000000..72c4211 --- /dev/null +++ b/nisps/core/perf.hpp @@ -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 diff --git a/nisps/core/ring_buffer.hpp b/nisps/core/ring_buffer.hpp new file mode 100644 index 0000000..7853d37 --- /dev/null +++ b/nisps/core/ring_buffer.hpp @@ -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 +#include +#include + +namespace nisps { + +template +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, + "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 head_; + std::atomic tail_; + T buf_[N]{}; +}; + +} // namespace nisps diff --git a/nisps/core/rng.hpp b/nisps/core/rng.hpp new file mode 100644 index 0000000..b06473f --- /dev/null +++ b/nisps/core/rng.hpp @@ -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 + +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(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 diff --git a/nisps/core/types.hpp b/nisps/core/types.hpp new file mode 100644 index 0000000..5dacc3b --- /dev/null +++ b/nisps/core/types.hpp @@ -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 +#include + +#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