diff --git a/CMakeLists.txt b/CMakeLists.txt index 3025dde..cdd05ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -446,6 +446,22 @@ add_library(card_drag STATIC src/card_drag.cpp) target_include_directories(card_drag PUBLIC src) target_link_libraries(card_drag PUBLIC drag_out bank_grid) +# --------------------------------------------------------------------------- +# 2v) Pure sampler_core library — NO VST3, NO REAPER, NO SWELL. The HEART of the +# Phase S MIDI-playback instrument (S3 / D3): polyphonic voice allocation with +# bounded stealing, an ADSR amplitude envelope, a key/velocity keymap with +# (note, velocity) -> zone resolution, and repitch/interpolation from a root note +# with loop-point-aware sustain. The mirror of bank_model / peaks / bank_book, +# tested hard outside any host. Lives under src/vst/ (it is instrument code) but +# links NEITHER SDK — the plain-data boundary is enforced structurally: the test +# target below links only sampler_core (+ its peaks dep for the AudioSample alias, +# the one house precedent wav_trim also relies on). The VST3 shell (src/vst/ +# reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified. +# --------------------------------------------------------------------------- +add_library(sampler_core STATIC src/vst/sampler_core.cpp) +target_include_directories(sampler_core PUBLIC src src/vst) +target_link_libraries(sampler_core PUBLIC peaks) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -584,6 +600,13 @@ add_executable(card_drag_tests tests/test_card_drag.cpp) target_link_libraries(card_drag_tests PRIVATE card_drag) add_test(NAME card_drag_tests COMMAND card_drag_tests) +# sampler_core: the S3 heart. Links ONLY sampler_core (+ its peaks dep) — NEITHER the +# VST3 SDK nor the REAPER SDK — which is the structural proof of the plain-data +# boundary (a VST3/REAPER type in the core would fail to compile/link here). +add_executable(sampler_core_tests tests/test_sampler_core.cpp) +target_link_libraries(sampler_core_tests PRIVATE sampler_core) +add_test(NAME sampler_core_tests COMMAND sampler_core_tests) + # --------------------------------------------------------------------------- # 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE. # editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp new file mode 100644 index 0000000..2325a3e --- /dev/null +++ b/src/vst/sampler_core.cpp @@ -0,0 +1,309 @@ +// sampler_core — pure sampler engine implementation. See sampler_core.h for the +// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape, +// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes. + +#include "sampler_core.h" + +#include + +namespace reasampler { + +// --------------------------------------------------------------------------- +// pitchRatio +// --------------------------------------------------------------------------- + +double pitchRatio(int note, int rootNote) { + // Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0. + return std::pow(2.0, static_cast(note - rootNote) / 12.0); +} + +// --------------------------------------------------------------------------- +// Keymap +// --------------------------------------------------------------------------- + +ZoneResolution Keymap::resolve(int note, int velocity) const { + (void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1. + for (std::size_t i = 0; i < zones.size(); ++i) { + const KeyZone& z = zones[i]; + if (note >= z.lowNote && note <= z.highNote) { + return ZoneResolution{true, i}; + } + } + return ZoneResolution{false, 0}; +} + +Keymap Keymap::singleSampleChromatic(SampleData sample) { + const int root = sample.rootNote; + Keymap km; + km.samples.push_back(std::move(sample)); + KeyZone zone; + zone.lowNote = 0; + zone.highNote = 127; + zone.rootNote = root; + zone.sampleIndex = 0; + km.zones.push_back(zone); + return km; +} + +// --------------------------------------------------------------------------- +// AdsrEnvelope +// --------------------------------------------------------------------------- + +void AdsrEnvelope::noteOn() { + stage_ = Stage::Attack; + level_ = 0.0; + framesInStage_ = 0; +} + +void AdsrEnvelope::noteOff() { + if (stage_ == Stage::Idle || stage_ == Stage::Finished || + stage_ == Stage::Release) { + return; // already released / not sounding. + } + // Release from the CURRENT level — release-before-sustain releases from the + // partial attack/decay level, not from sustainLevel. + releaseFrom_ = level_; + stage_ = Stage::Release; + framesInStage_ = 0; +} + +double AdsrEnvelope::tick() { + switch (stage_) { + case Stage::Idle: + case Stage::Finished: + level_ = 0.0; + return 0.0; + + case Stage::Attack: { + if (params_.attackFrames <= 0) { + level_ = 1.0; + } else { + level_ = static_cast(framesInStage_) / + static_cast(params_.attackFrames); + if (level_ > 1.0) level_ = 1.0; + } + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.attackFrames) { + stage_ = Stage::Decay; + framesInStage_ = 0; + level_ = 1.0; + } + return out; + } + + case Stage::Decay: { + if (params_.decayFrames <= 0) { + level_ = params_.sustainLevel; + } else { + const double t = static_cast(framesInStage_) / + static_cast(params_.decayFrames); + level_ = 1.0 + (params_.sustainLevel - 1.0) * t; + } + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.decayFrames) { + stage_ = Stage::Sustain; + framesInStage_ = 0; + level_ = params_.sustainLevel; + } + return out; + } + + case Stage::Sustain: + level_ = params_.sustainLevel; + return level_; + + case Stage::Release: { + if (params_.releaseFrames <= 0) { + level_ = 0.0; + stage_ = Stage::Finished; + return 0.0; + } + const double t = static_cast(framesInStage_) / + static_cast(params_.releaseFrames); + level_ = releaseFrom_ * (1.0 - t); + if (level_ < 0.0) level_ = 0.0; + const double out = level_; + ++framesInStage_; + if (framesInStage_ >= params_.releaseFrames) { + stage_ = Stage::Finished; + level_ = 0.0; + } + return out; + } + } + return 0.0; // unreachable; silences a warning. +} + +// --------------------------------------------------------------------------- +// Voice +// --------------------------------------------------------------------------- + +void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, + const AdsrParams& adsr) { + active_ = true; + releasing_ = false; + note_ = note; + // MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively. + int v = velocity; + if (v < 0) v = 0; + if (v > 127) v = 127; + velocityGain_ = static_cast(v) / 127.0; + ratio_ = pitchRatio(note, rootNote); + readPos_ = 0.0; + sample_ = &sample; + env_.configure(adsr); + env_.noteOn(); +} + +void Voice::release() { + if (!active_) return; + releasing_ = true; + env_.noteOff(); +} + +AudioSample Voice::renderFrame() { + if (!active_ || sample_ == nullptr) return 0.0f; + + const std::vector& pcm = sample_->frames; + const std::int64_t frameCount = static_cast(pcm.size()); + + // Loop-aware sustain: if a valid, non-zero-length loop exists and the read head + // has advanced past the loop end, wrap it back into [start, end). A zero-length + // loop (start == end) is treated as "no loop" — the note is allowed to run off + // the sample end and go silent, rather than spinning on a zero span. + const SampleLoop& loop = sample_->loop; + const bool loopUsable = loop.hasLoop && loop.end > loop.start && + loop.start >= 0 && loop.end <= frameCount; + if (loopUsable) { + const std::int64_t loopStart = loop.start; + const std::int64_t loopEnd = loop.end; + const double loopLen = static_cast(loopEnd - loopStart); + while (readPos_ >= static_cast(loopEnd)) { + readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase. + } + } + + // Ran off the end with no usable loop -> voice is done. + if (readPos_ >= static_cast(frameCount)) { + active_ = false; + return 0.0f; + } + + // Linear interpolation between the two bracketing frames. For the loop case, the + // second point wraps to loopStart so the seam is continuous. + const std::int64_t i0 = static_cast(readPos_); + const double frac = readPos_ - static_cast(i0); + std::int64_t i1 = i0 + 1; + if (loopUsable && i1 >= loop.end) { + i1 = loop.start; // seamless wrap for the interpolation partner. + } + const double s0 = (i0 >= 0 && i0 < frameCount) ? static_cast(pcm[i0]) : 0.0; + const double s1 = (i1 >= 0 && i1 < frameCount) ? static_cast(pcm[i1]) : 0.0; + const double interp = s0 + (s1 - s0) * frac; + + const double amp = env_.tick(); + const double out = interp * amp * velocityGain_; + + readPos_ += ratio_; + + if (env_.finished()) { + active_ = false; + } + return static_cast(out); +} + +// --------------------------------------------------------------------------- +// VoiceEngine +// --------------------------------------------------------------------------- + +VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, + const AdsrParams& adsr) + : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), adsr_(adsr) { + // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on; + // clamp to a single voice so the engine is always usable (documented degenerate). +} + +std::size_t VoiceEngine::allocateVoice() { + // 1. A free (idle) voice, lowest index for determinism. + for (std::size_t i = 0; i < voices_.size(); ++i) { + if (!voices_[i].active()) return i; + } + // 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail), + // else the oldest voice overall. "Oldest" = smallest startOrder. + std::size_t bestReleasing = kNoVoice; + std::uint64_t bestReleasingOrder = 0; + std::size_t bestOverall = kNoVoice; + std::uint64_t bestOverallOrder = 0; + for (std::size_t i = 0; i < voices_.size(); ++i) { + const std::uint64_t order = voices_[i].startOrder(); + if (voices_[i].releasing()) { + if (bestReleasing == kNoVoice || order < bestReleasingOrder) { + bestReleasing = i; + bestReleasingOrder = order; + } + } + if (bestOverall == kNoVoice || order < bestOverallOrder) { + bestOverall = i; + bestOverallOrder = order; + } + } + return bestReleasing != kNoVoice ? bestReleasing : bestOverall; +} + +std::size_t VoiceEngine::noteOn(int note, int velocity) { + const ZoneResolution res = keymap_.resolve(note, velocity); + if (!res.matched) return kNoVoice; // out-of-zone: defined no-play. + + const KeyZone& zone = keymap_.zones[res.zoneIndex]; + if (zone.sampleIndex >= keymap_.samples.size()) { + return kNoVoice; // zone points at a missing sample — refuse rather than UB. + } + const SampleData& sample = keymap_.samples[zone.sampleIndex]; + + const std::size_t v = allocateVoice(); + voices_[v].start(note, velocity, sample, zone.rootNote, adsr_); + voices_[v].setStartOrder(nextStartOrder_++); + return v; +} + +void VoiceEngine::noteOff(int note) { + // Release the NEWEST active, non-releasing voice on this note (largest startOrder), + // so a re-triggered note releases its newest instance first and older tails ring. + std::size_t target = kNoVoice; + std::uint64_t bestOrder = 0; + for (std::size_t i = 0; i < voices_.size(); ++i) { + if (voices_[i].active() && !voices_[i].releasing() && + voices_[i].note() == note) { + const std::uint64_t order = voices_[i].startOrder(); + if (target == kNoVoice || order > bestOrder) { + target = i; + bestOrder = order; + } + } + } + if (target != kNoVoice) voices_[target].release(); +} + +void VoiceEngine::render(std::vector& out, std::size_t frameCount) { + const std::size_t base = out.size(); + out.resize(base + frameCount, 0.0f); + for (Voice& voice : voices_) { + if (!voice.active()) continue; + for (std::size_t f = 0; f < frameCount; ++f) { + if (!voice.active()) break; + out[base + f] += voice.renderFrame(); + } + } +} + +std::size_t VoiceEngine::activeVoiceCount() const { + std::size_t n = 0; + for (const Voice& v : voices_) { + if (v.active()) ++n; + } + return n; +} + +} // namespace reasampler diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h new file mode 100644 index 0000000..a51bccc --- /dev/null +++ b/src/vst/sampler_core.h @@ -0,0 +1,262 @@ +#pragma once +// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately +// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW +// and outside any plugin host. It owns the pure sampler engine: polyphonic voice +// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap +// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note +// with loop-point-aware sustain. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL, +// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell +// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from +// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced +// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i). +// +// It shares the `AudioSample` float alias from peaks — the one house precedent for a +// pure module leaning on peaks for the audio-domain type (wav_trim does the same). The +// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the +// core does no file I/O — it is handed decoded sample frames and produces audio frames. + +#include +#include +#include + +#include "peaks.h" // AudioSample (float) + +namespace reasampler { + +// --------------------------------------------------------------------------- +// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that +// govern playback. The shell decodes the on-disk WAV and fills this; the core +// never touches a file. +// --------------------------------------------------------------------------- + +// A loop over [start, end) frames, half-open. A zero-length loop (start == end) +// is the "no sustain loop" marker — a held note past the sample end goes silent +// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false. +struct SampleLoop { + bool hasLoop = false; + std::int64_t start = 0; // first looped frame (inclusive) + std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end +}; + +// One decoded audio sample the engine can voice. `frames` is DEINTERLEAVED-agnostic: +// the core plays a single mono stream per sample (Tier 0-1 scope), so `frames` is one +// channel's PCM at `sampleRate`. `rootNote` is the MIDI note the file was recorded at +// (S2 intrinsic) — the pitch that plays back at unity ratio. +struct SampleData { + std::vector frames; // mono PCM, one value per frame + int sampleRate = 44100; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch) + int rootNote = 60; // MIDI note recorded at (plays at unity here) + SampleLoop loop; // sustain loop, if any +}; + +// --------------------------------------------------------------------------- +// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves +// to at most one zone; a zone names which SampleData to play and the root note to +// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the +// sample's own root. Tier-1: several zones, each a key range with its own root. +// +// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built: +// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone +// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and +// resolve() gains the velocity dimension it already receives but currently ignores +// for selection. The (note, velocity) signature and the "resolve to a zone, then a +// sample within it" shape are already in place — Tier 2 fills in the second step +// without changing callers or the voice engine. See the report note. +// --------------------------------------------------------------------------- + +// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with +// the root note to repitch from (defaults to the sample's own root, overridable in +// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a +// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention). +struct KeyZone { + int lowNote = 0; + int highNote = 127; + int rootNote = 60; // repitch reference for this zone + std::size_t sampleIndex = 0; // index into Keymap::samples +}; + +// Result of resolving a (note, velocity). `matched == false` means the note falls in +// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0. +struct ZoneResolution { + bool matched = false; + std::size_t zoneIndex = 0; // valid only when matched +}; + +// The keymap: the decoded samples plus the zones that map keys onto them. Owns +// resolution. Pure: no host types. Zones are tested first-match in order, so an +// earlier zone wins an overlap (deterministic, documented). +struct Keymap { + std::vector samples; + std::vector zones; + + // Resolves (note, velocity) to a zone. First zone (in order) whose [low,high] + // contains `note` wins. velocity is accepted now (Tier-2 seam) but does not + // affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains + // the note. + ZoneResolution resolve(int note, int velocity) const; + + // Convenience: build the Tier-0 degenerate keymap — one sample mapped + // chromatically across the whole keyboard from its own root note. + static Keymap singleSampleChromatic(SampleData sample); +}; + +// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`: +// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0, +// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed. +double pitchRatio(int note, int rootNote); + +// --------------------------------------------------------------------------- +// ADSR amplitude envelope. Sample-based (times in frames), linear segments. A gate: +// noteOn() enters Attack; noteOff() enters Release from wherever it is. The classic +// four-stage shape, asserted against a known signal in the tests (mirror of peaks). +// +// Segment math (all linear ramps): +// Attack: 0 -> 1 over attackFrames +// Decay: 1 -> sustainLevel over decayFrames +// Sustain: hold sustainLevel until noteOff +// Release: currentLevel -> 0 over releaseFrames +// A zero-length attack jumps straight to 1 on the first frame; zero decay jumps to +// sustain; a noteOff during attack/decay (release-before-sustain) releases from the +// current partial level, not from sustainLevel. +// --------------------------------------------------------------------------- + +struct AdsrParams { + std::int64_t attackFrames = 0; + std::int64_t decayFrames = 0; + double sustainLevel = 1.0; // 0..1 + std::int64_t releaseFrames = 0; +}; + +class AdsrEnvelope { +public: + enum class Stage { Idle, Attack, Decay, Sustain, Release, Finished }; + + void configure(const AdsrParams& params) { params_ = params; } + + // Gate on: (re)start from Attack. + void noteOn(); + // Gate off: enter Release from the current level. + void noteOff(); + + // Advances one frame and returns the amplitude for THIS frame (before advancing). + // Once Release completes the envelope latches Finished and returns 0.0 forever + // (until the next noteOn). A single, monotonic per-frame step — the caller pulls + // one value per output frame. + double tick(); + + Stage stage() const { return stage_; } + bool finished() const { return stage_ == Stage::Finished; } + double level() const { return level_; } + +private: + AdsrParams params_; + Stage stage_ = Stage::Idle; + double level_ = 0.0; + std::int64_t framesInStage_ = 0; + double releaseFrom_ = 0.0; // level at the moment noteOff() was called +}; + +// --------------------------------------------------------------------------- +// A single voice: one active note playing one repitched, enveloped sample. Reads +// the sample by fractional frame position with linear interpolation, advancing by +// the pitch ratio; loops the sustain region for held notes past the loop end. +// --------------------------------------------------------------------------- + +class Voice { +public: + // Starts this voice on `note` at `velocity`, playing `sample` (a stable reference + // the caller must keep alive for the voice's lifetime — the Keymap owns it), + // repitched from `rootNote`, with `adsr` as the amplitude envelope. + void start(int note, int velocity, const SampleData& sample, int rootNote, + const AdsrParams& adsr); + + // Gate off — begins the amplitude release. The voice keeps rendering (and looping, + // if it would) until the release finishes, then goes idle. + void release(); + + // True while this voice is producing (or about to produce) sound. + bool active() const { return active_; } + // The note this voice was started on (for note-off routing). Meaningless if idle. + int note() const { return note_; } + // Monotonic age counter — higher = started earlier relative to others. The voice + // engine uses this for its stealing policy (oldest first). Set by the engine. + std::uint64_t startOrder() const { return startOrder_; } + void setStartOrder(std::uint64_t order) { startOrder_ = order; } + bool releasing() const { return releasing_; } + + // Renders one frame's contribution, advancing the read head and envelope by one + // output frame. Returns 0.0 (and goes idle) once the envelope finishes or the + // sample runs out with no loop. The value is already velocity- and + // envelope-scaled — the engine sums voices directly. + AudioSample renderFrame(); + +private: + bool active_ = false; + bool releasing_ = false; + int note_ = 0; + double velocityGain_ = 1.0; + double ratio_ = 1.0; // fractional frames advanced per output frame + double readPos_ = 0.0; // fractional frame index into the sample + const SampleData* sample_ = nullptr; + AdsrEnvelope env_; + std::uint64_t startOrder_ = 0; +}; + +// --------------------------------------------------------------------------- +// The polyphonic voice engine: a fixed pool of voices, note-on allocation with +// bounded voice stealing, note-off routing, and block rendering (sum of voices). +// +// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a +// new note-on arrives, steal in this priority order: +// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut), +// 2. else the oldest voice overall (longest-held note gives way to the new one). +// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the +// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing +// that, the note that has already had the most time. +// --------------------------------------------------------------------------- + +class VoiceEngine { +public: + // Builds an engine with `maxVoices` voices (the polyphony bound) playing from + // `keymap`. The keymap must outlive the engine (the engine holds a reference — it + // reads zones and sample data through it, never copies PCM). + VoiceEngine(std::size_t maxVoices, const Keymap& keymap, const AdsrParams& adsr); + + // MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of + // zone) it is a defined no-op (no voice consumed). Otherwise allocates a free + // voice, or steals one per the policy above. Returns the index of the voice used, + // or kNoVoice for an out-of-zone (unplayed) note. + std::size_t noteOn(int note, int velocity); + + // MIDI note-off. Releases the most-recently-started active, non-releasing voice + // playing `note` (so a re-triggered same note releases the newest first, leaving + // the older tail to ring — matches hardware behavior). No-op if none match. + void noteOff(int note); + + // Renders `frameCount` mono output frames, summing all active voices, appending to + // `out` (does not clear it — the caller owns mixing/clearing). Voices that finish + // mid-block go idle and stop contributing. + void render(std::vector& out, std::size_t frameCount); + + // Count of currently active voices (for tests / diagnostics). + std::size_t activeVoiceCount() const; + + std::size_t maxVoices() const { return voices_.size(); } + + static constexpr std::size_t kNoVoice = static_cast(-1); + +private: + // Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen + // one per the documented policy. Always returns a valid index (maxVoices >= 1). + std::size_t allocateVoice(); + + std::vector voices_; + const Keymap& keymap_; + AdsrParams adsr_; + std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started" +}; + +} // namespace reasampler diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp new file mode 100644 index 0000000..e885c1e --- /dev/null +++ b/tests/test_sampler_core.cpp @@ -0,0 +1,528 @@ +// Standalone tests for reasampler::sampler_core — no VST3, no REAPER, no test +// framework. Same fast build/run loop as bank_model_tests / peaks_tests: feed known +// inputs, assert the engine's behavior. +// +// Covers (PLAN.md S3 / CONTEXT.md §Phase S pure core): +// 1. polyphonic allocation — N notes -> N voices; note-off releases the right voice. +// 2. voice stealing at the bound — deterministic policy (release-first, then oldest). +// 3. ADSR envelope shape vs a known signal, incl. release-before-sustain. +// 4. repitch ratio correctness across +/-1 octave from root incl. unity, asserted on +// the observed period of a synthesized sine. +// 5. loop-point sustain — held note past sample end loops [start,end) seamlessly; +// zero-length loop and absent-loop behavior. +// 6. keymap: chromatic-from-single-root; zoned ranges with boundary notes; velocity +// -> volume; out-of-zone note -> defined no-play. +// +// The plain-data boundary (no VST3/REAPER types in the core) is enforced STRUCTURALLY +// by the CMake target linking neither SDK — this file includes only sampler_core.h + +// the standard library, which is itself the compile-time proof. + +#include "../src/vst/sampler_core.h" + +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; } + +constexpr double kPi = 3.14159265358979323846; + +// A silent (all-1.0) sample so a rendered voice's output tracks the envelope * velocity +// directly (DC of amplitude 1). Root at note 60 by default. +static SampleData dcSample(std::size_t frames, int rootNote = 60) { + SampleData s; + s.frames.assign(frames, 1.0f); + s.rootNote = rootNote; + return s; +} + +// A mono sine of `cycles` periods over `frames` frames — used to observe repitch by +// measuring the played-back period. +static SampleData sineSample(std::size_t frames, double cycles, int rootNote = 60) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * cycles * + static_cast(i) / static_cast(frames))); + } + s.rootNote = rootNote; + return s; +} + +// An ADSR that stays fully open (level 1) forever while held, so voice output equals +// velocity gain — isolates allocation/repitch/loop tests from envelope shaping. +static AdsrParams flatAdsr() { + AdsrParams a; + a.attackFrames = 0; + a.decayFrames = 0; + a.sustainLevel = 1.0; + a.releaseFrames = 0; // note-off -> instant silence. + return a; +} + +// --------------------------------------------------------------------------- +// 6. Keymap resolution. +// --------------------------------------------------------------------------- + +static void testChromaticSingleRoot() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); + CHECK(km.zones.size() == 1); + // Every note in 0..127 resolves to the single zone. + for (int n = 0; n <= 127; ++n) { + ZoneResolution r = km.resolve(n, 100); + CHECK(r.matched); + CHECK(r.zoneIndex == 0); + } +} + +static void testZonedRangesBoundaries() { + Keymap km; + km.samples.push_back(dcSample(100, 48)); // low sample + km.samples.push_back(dcSample(100, 72)); // high sample + // Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the + // correct zone; a first-match order test would catch an off-by-one. + km.zones.push_back(KeyZone{36, 59, 48, 0}); + km.zones.push_back(KeyZone{60, 83, 72, 1}); + + CHECK(km.resolve(36, 100).matched); + CHECK(km.resolve(36, 100).zoneIndex == 0); + CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0 + CHECK(km.resolve(60, 100).zoneIndex == 1); // first note of zone 1 + CHECK(km.resolve(83, 100).zoneIndex == 1); // last note of zone 1 + + // Out of every zone -> defined no-play (not a match, not zone 0). + CHECK(!km.resolve(35, 100).matched); + CHECK(!km.resolve(84, 100).matched); + CHECK(!km.resolve(127, 100).matched); +} + +static void testFirstMatchOnOverlap() { + // Overlapping zones: the earlier zone wins (documented deterministic rule). + Keymap km; + km.samples.push_back(dcSample(10, 60)); + km.samples.push_back(dcSample(10, 60)); + km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first + km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all + CHECK(km.resolve(60, 100).zoneIndex == 0); +} + +// --------------------------------------------------------------------------- +// 4. Repitch ratio correctness. +// --------------------------------------------------------------------------- + +static void testPitchRatioMath() { + CHECK(approx(pitchRatio(60, 60), 1.0, 1e-9)); // unity at root + CHECK(approx(pitchRatio(72, 60), 2.0, 1e-9)); // +1 octave + CHECK(approx(pitchRatio(48, 60), 0.5, 1e-9)); // -1 octave + CHECK(approx(pitchRatio(61, 60), std::pow(2.0, 1.0 / 12.0), 1e-9)); // +1 semitone +} + +// Observe repitch on the rendered signal: a voice played an octave above root should +// advance through the sample twice as fast, so a sine's observed period halves. We +// measure the period by counting the interval between positive-going zero crossings. +static double observedPeriodFrames(const std::vector& out) { + std::vector upCrossings; + for (std::size_t i = 1; i < out.size(); ++i) { + if (out[i - 1] <= 0.0f && out[i] > 0.0f) upCrossings.push_back(i); + } + if (upCrossings.size() < 2) return 0.0; + // Average spacing between crossings. + double sum = 0.0; + for (std::size_t i = 1; i < upCrossings.size(); ++i) { + sum += static_cast(upCrossings[i] - upCrossings[i - 1]); + } + return sum / static_cast(upCrossings.size() - 1); +} + +static void testRepitchObservedPeriod() { + // A sine of 20 cycles over 8000 frames -> native period 400 frames at unity. + const std::size_t frames = 8000; + const double cycles = 20.0; + const double nativePeriod = static_cast(frames) / cycles; // 400 + + // Unity: played at root, observed period ~= native. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, frames); + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod, 2.0)); + } + // +1 octave: advances 2x, observed period halves. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(72, 127); + std::vector out; + eng.render(out, frames / 2); // half as many frames covers the whole sample + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod / 2.0, 2.0)); + } + // -1 octave: advances 0.5x, observed period doubles. + { + Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(48, 127); + std::vector out; + eng.render(out, frames); + double p = observedPeriodFrames(out); + CHECK(approx(p, nativePeriod * 2.0, 4.0)); + } +} + +// --------------------------------------------------------------------------- +// 3. ADSR envelope shape vs a known signal. +// --------------------------------------------------------------------------- + +static void testAdsrShape() { + AdsrParams p; + p.attackFrames = 10; + p.decayFrames = 10; + p.sustainLevel = 0.5; + p.releaseFrames = 10; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + // Attack: 0 -> ramps up. Frame 0 == 0, rising each frame. + double prev = -1.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v >= prev); // monotonic non-decreasing through attack + CHECK(v >= 0.0 && v <= 1.0); + prev = v; + } + // Decay: from 1.0 down toward sustain 0.5, monotonic non-increasing. + prev = 2.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); // non-increasing through decay + CHECK(v >= 0.5 - 1e-9); // never below sustain during decay + prev = v; + } + // Sustain: holds 0.5 indefinitely. + for (int i = 0; i < 100; ++i) { + CHECK(approx(env.tick(), 0.5, 1e-9)); + } + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); + + // Release: 0.5 -> 0 over 10 frames, then Finished + latched at 0. + env.noteOff(); + prev = 1.0; + for (int i = 0; i < 10; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); // non-increasing through release + prev = v; + } + CHECK(env.finished()); + for (int i = 0; i < 10; ++i) CHECK(approx(env.tick(), 0.0, 1e-12)); +} + +static void testAdsrReleaseBeforeSustain() { + // noteOff during the attack ramp releases from the PARTIAL level, not sustain. + AdsrParams p; + p.attackFrames = 100; + p.decayFrames = 10; + p.sustainLevel = 0.8; + p.releaseFrames = 20; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + + // Advance 50 frames into a 100-frame attack -> partial level ~0.5. + double last = 0.0; + for (int i = 0; i < 50; ++i) last = env.tick(); + CHECK(last > 0.3 && last < 0.7); // partway up the attack ramp + CHECK(env.stage() == AdsrEnvelope::Stage::Attack); + + env.noteOff(); + CHECK(env.stage() == AdsrEnvelope::Stage::Release); + // First release frame must be at or below the partial level we left off at — + // NOT jump up to sustain 0.8. This is the release-before-sustain guarantee. + double firstRelease = env.tick(); + CHECK(firstRelease <= last + 1e-9); + CHECK(firstRelease < p.sustainLevel); // proves it did not snap to sustain + // Decays to zero. + double prev = firstRelease; + for (int i = 0; i < 20; ++i) { + double v = env.tick(); + CHECK(v <= prev + 1e-9); + prev = v; + } + CHECK(env.finished()); +} + +static void testAdsrZeroAttackDecay() { + // Zero attack + zero decay -> jumps straight to sustain on the first ticks. + AdsrParams p; + p.attackFrames = 0; + p.decayFrames = 0; + p.sustainLevel = 0.7; + p.releaseFrames = 5; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + // Zero attack emits the attack peak (1.0) on frame 0 and immediately transitions + // through the (also zero-length) decay, so by frame 1 the envelope is holding + // sustain. The peak-at-boundary is the documented single-frame edge, not a bug. + CHECK(approx(env.tick(), 1.0, 1e-9)); // frame 0: attack peak + CHECK(approx(env.tick(), 0.7, 1e-9)); // frame 1: sustain + CHECK(approx(env.tick(), 0.7, 1e-9)); + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); +} + +// --------------------------------------------------------------------------- +// 1. Polyphonic allocation + note-off routing. +// --------------------------------------------------------------------------- + +static void testPolyphonicAllocation() { + Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); + VoiceEngine eng(8, km, flatAdsr()); + + // Four simultaneous notes -> four active voices, each on a distinct voice. + std::size_t v60 = eng.noteOn(60, 100); + std::size_t v64 = eng.noteOn(64, 100); + std::size_t v67 = eng.noteOn(67, 100); + std::size_t v72 = eng.noteOn(72, 100); + CHECK(v60 != VoiceEngine::kNoVoice); + CHECK(eng.activeVoiceCount() == 4); + CHECK(v60 != v64 && v64 != v67 && v67 != v72 && v60 != v72); + + // Note-off on 64 releases exactly one voice; with instant release it goes idle + // after the next render frame. + eng.noteOff(64); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 3); + + // The still-held notes keep sounding. + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 3); +} + +static void testNoteOffReleasesNewestSameNote() { + Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); + // Long release so we can observe which voice entered release. + AdsrParams a = flatAdsr(); + a.releaseFrames = 1000; + VoiceEngine eng(8, km, a); + + std::size_t first = eng.noteOn(60, 100); + std::size_t second = eng.noteOn(60, 100); // same note re-triggered + CHECK(first != second); + CHECK(eng.activeVoiceCount() == 2); + + eng.noteOff(60); // releases the NEWEST (second) + std::vector out; + eng.render(out, 1); + // Both still active (long release), but only the newest is releasing. + CHECK(eng.activeVoiceCount() == 2); + // A second note-off releases the older one too. + eng.noteOff(60); + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 2); // both releasing, not yet finished +} + +static void testOutOfZoneNoteConsumesNoVoice() { + Keymap km; + km.samples.push_back(dcSample(100, 60)); + km.zones.push_back(KeyZone{60, 72, 60, 0}); + VoiceEngine eng(4, km, flatAdsr()); + + std::size_t v = eng.noteOn(30, 100); // below the only zone + CHECK(v == VoiceEngine::kNoVoice); + CHECK(eng.activeVoiceCount() == 0); // no voice consumed +} + +// --------------------------------------------------------------------------- +// 2. Voice stealing at the bound. +// --------------------------------------------------------------------------- + +static void testStealsReleasingVoiceFirst() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); + AdsrParams a = flatAdsr(); + a.releaseFrames = 100000; // long release so a released voice stays "active". + VoiceEngine eng(2, km, a); + + std::size_t vA = eng.noteOn(60, 100); // startOrder 1 + std::size_t vB = eng.noteOn(62, 100); // startOrder 2 + CHECK(eng.activeVoiceCount() == 2); + + // Release the NEWER voice (62) — it becomes the only releasing voice. + eng.noteOff(62); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == 2); // both still ringing (long release) + + // A new note with the pool full must steal the RELEASING voice (vB), not the + // older held voice (vA) — release-first policy. + std::size_t vC = eng.noteOn(64, 100); + CHECK(vC == vB); + CHECK(eng.activeVoiceCount() == 2); +} + +static void testStealsOldestWhenNoneReleasing() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); + AdsrParams a = flatAdsr(); + a.releaseFrames = 100000; + VoiceEngine eng(2, km, a); + + std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) + std::size_t vB = eng.noteOn(62, 100); // startOrder 2 + CHECK(vA != vB); + + // No voice released; both held. A new note steals the OLDEST (vA). + std::size_t vC = eng.noteOn(64, 100); + CHECK(vC == vA); + CHECK(eng.activeVoiceCount() == 2); + + // The stolen voice now carries note 64; a note-off on 60 (the stolen-away note) + // finds nothing to release. + std::size_t before = eng.activeVoiceCount(); + eng.noteOff(60); + std::vector out; + eng.render(out, 1); + CHECK(eng.activeVoiceCount() == before); // 60 no longer exists; no-op +} + +// --------------------------------------------------------------------------- +// 5. Loop-point-aware sustain. +// --------------------------------------------------------------------------- + +static void testLoopSustainSeamless() { + // A sample whose [0,20) frames are a distinctive ramp and [20,40) is a flat loop + // region of value 0.5. Held far past the sample end, the voice must keep emitting + // the loop region (0.5) rather than going silent. + SampleData s; + s.frames.resize(40); + for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(i) / 20.0f; // attack + for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body + s.rootNote = 60; + s.loop.hasLoop = true; + s.loop.start = 20; + s.loop.end = 40; + + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); // unity ratio, full velocity + + std::vector out; + eng.render(out, 200); // 5x the sample length + + // Voice is still active (looping), not exhausted. + CHECK(eng.activeVoiceCount() == 1); + // Frames well past the loop start must sit at the loop body value 0.5. + for (std::size_t i = 60; i < out.size(); ++i) { + CHECK(approx(out[i], 0.5, 1e-4)); + } +} + +static void testZeroLengthLoopGoesSilent() { + // A zero-length loop (start == end) is the "no sustain" marker: the note runs off + // the sample end and the voice goes idle, rather than spinning on an empty span. + SampleData s = dcSample(50, 60); // 50 frames of 1.0 + s.loop.hasLoop = true; + s.loop.start = 25; + s.loop.end = 25; // zero length + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + + std::vector out; + eng.render(out, 100); // past the 50-frame end + // After frame ~50 the voice should have gone idle (no loop to sustain it). + CHECK(eng.activeVoiceCount() == 0); + // Tail frames are silent. + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +static void testAbsentLoopGoesSilent() { + // No loop at all: held note runs off the end and goes idle (same as zero-length). + SampleData s = dcSample(50, 60); + // s.loop.hasLoop stays false. + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 100); + CHECK(eng.activeVoiceCount() == 0); + for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); +} + +// --------------------------------------------------------------------------- +// velocity -> volume. +// --------------------------------------------------------------------------- + +static void testVelocityToVolume() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 + // Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the + // rendered value is exactly velocity/127 on a DC-1 sample). + { + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 127); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 1.0, 1e-4)); + } + { + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 64); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 64.0 / 127.0, 1e-4)); + } + { + VoiceEngine eng(1, km, flatAdsr()); + eng.noteOn(60, 1); + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 1.0 / 127.0, 1e-4)); + } +} + +// Two voices summed: polyphony mixes additively. +static void testPolyphonyMixesAdditively() { + Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 + VoiceEngine eng(4, km, flatAdsr()); + eng.noteOn(60, 127); // gain 1.0 + eng.noteOn(60, 127); // gain 1.0 (second voice, same note) + std::vector out; + eng.render(out, 1); + CHECK(approx(out[0], 2.0, 1e-4)); // both voices sum +} + +int main() { + testChromaticSingleRoot(); + testZonedRangesBoundaries(); + testFirstMatchOnOverlap(); + testPitchRatioMath(); + testRepitchObservedPeriod(); + testAdsrShape(); + testAdsrReleaseBeforeSustain(); + testAdsrZeroAttackDecay(); + testPolyphonicAllocation(); + testNoteOffReleasesNewestSameNote(); + testOutOfZoneNoteConsumesNoVoice(); + testStealsReleasingVoiceFirst(); + testStealsOldestWhenNoneReleasing(); + testLoopSustainSeamless(); + testZeroLengthLoopGoesSilent(); + testAbsentLoopGoesSilent(); + testVelocityToVolume(); + testPolyphonyMixesAdditively(); + + if (g_fail == 0) { + std::printf("all sampler_core tests passed\n"); + return 0; + } + std::printf("%d sampler_core check(s) failed\n", g_fail); + return 1; +}