S3: pure sampler core — voices, ADSR, keymap, loop-aware repitch

REAPER-free and VST3-free voice engine with bounded stealing, ADSR
envelope, key/velocity keymap resolution, and repitch from root note
with loop-point sustain. Test target links neither SDK.
This commit is contained in:
2026-07-26 15:58:30 -04:00
parent 09f49441d9
commit 74d2e976e7
4 changed files with 1122 additions and 0 deletions
+23
View File
@@ -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
+309
View File
@@ -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 <cmath>
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<double>(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<double>(framesInStage_) /
static_cast<double>(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<double>(framesInStage_) /
static_cast<double>(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<double>(framesInStage_) /
static_cast<double>(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<double>(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<AudioSample>& pcm = sample_->frames;
const std::int64_t frameCount = static_cast<std::int64_t>(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<double>(loopEnd - loopStart);
while (readPos_ >= static_cast<double>(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<double>(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<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(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<double>(pcm[i0]) : 0.0;
const double s1 = (i1 >= 0 && i1 < frameCount) ? static_cast<double>(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<AudioSample>(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<AudioSample>& 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
+262
View File
@@ -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 <cstddef>
#include <cstdint>
#include <vector>
#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<AudioSample> 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<SampleData> samples;
std::vector<KeyZone> 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<AudioSample>& 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<std::size_t>(-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<Voice> voices_;
const Keymap& keymap_;
AdsrParams adsr_;
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
};
} // namespace reasampler
+528
View File
@@ -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 <cmath>
#include <cstdio>
#include <vector>
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<float>(std::sin(2.0 * kPi * cycles *
static_cast<double>(i) / static_cast<double>(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<AudioSample>& out) {
std::vector<std::size_t> 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<double>(upCrossings[i] - upCrossings[i - 1]);
}
return sum / static_cast<double>(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<double>(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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> 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<float>(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<AudioSample> 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<AudioSample> 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<AudioSample> 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<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 1.0, 1e-4));
}
{
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 64);
std::vector<AudioSample> 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<AudioSample> 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<AudioSample> 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;
}