Merge ps-w2-sampler-core: S3 pure sampler core (voices, ADSR, keymap, loop-aware repitch)
This commit is contained in:
@@ -446,6 +446,22 @@ add_library(card_drag STATIC src/card_drag.cpp)
|
|||||||
target_include_directories(card_drag PUBLIC src)
|
target_include_directories(card_drag PUBLIC src)
|
||||||
target_link_libraries(card_drag PUBLIC drag_out bank_grid)
|
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).
|
# 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)
|
target_link_libraries(card_drag_tests PRIVATE card_drag)
|
||||||
add_test(NAME card_drag_tests COMMAND card_drag_tests)
|
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.
|
# 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
|
# editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
// 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.
|
||||||
|
}
|
||||||
|
// i0 is always in [0, frameCount) after the early-out above; the guard is purely
|
||||||
|
// defensive. i1 (the interpolation partner) can exceed frameCount when no loop
|
||||||
|
// wraps it — only that partner actually needs the clamp.
|
||||||
|
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); // S4: caller must pre-reserve — no allocation allowed under the VST3 process callback.
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
// 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() {
|
||||||
|
// Prove that noteOff releases the NEWEST (highest startOrder) instance of a
|
||||||
|
// re-triggered note, leaving the older voice in sustain.
|
||||||
|
//
|
||||||
|
// Two voices at distinct velocities so their output is distinguishable:
|
||||||
|
// "first" (older) -> velocity 64 -> gain ~0.504 (G_old)
|
||||||
|
// "second" (newer) -> velocity 127 -> gain 1.0 (G_new)
|
||||||
|
//
|
||||||
|
// With a DC-1 sample and sustain=1, while both are held:
|
||||||
|
// render sum == G_old + G_new.
|
||||||
|
//
|
||||||
|
// After noteOff (must release newest), the newer voice enters a short release.
|
||||||
|
// Render past releaseFrames: newer voice finishes; only the older voice remains.
|
||||||
|
// Sum then equals G_old, and activeVoiceCount drops to 1. If the WRONG voice
|
||||||
|
// were released, the older would finish and the remaining sum would equal G_new
|
||||||
|
// (1.0 vs ~0.504) — the velocities make the error distinguishable.
|
||||||
|
const int velOld = 64;
|
||||||
|
const int velNew = 127;
|
||||||
|
const double gainOld = velOld / 127.0; // ~0.504
|
||||||
|
const double gainNew = velNew / 127.0; // 1.0
|
||||||
|
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60));
|
||||||
|
AdsrParams a = flatAdsr();
|
||||||
|
a.releaseFrames = 10; // short but non-zero so voice stays active through release
|
||||||
|
VoiceEngine eng(8, km, a);
|
||||||
|
|
||||||
|
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
|
||||||
|
std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain
|
||||||
|
CHECK(first != second);
|
||||||
|
CHECK(eng.activeVoiceCount() == 2);
|
||||||
|
|
||||||
|
// While both are held, combined output equals gainOld + gainNew.
|
||||||
|
{
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 1);
|
||||||
|
CHECK(approx(out[0], gainOld + gainNew, 1e-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release once — must target the NEWEST voice (second).
|
||||||
|
eng.noteOff(60);
|
||||||
|
|
||||||
|
// Render past the release (releaseFrames == 10): newer voice goes Finished.
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 20);
|
||||||
|
|
||||||
|
// Newer voice must be done; only the older voice remains.
|
||||||
|
CHECK(eng.activeVoiceCount() == 1);
|
||||||
|
// Tail frames must equal gainOld (~0.504), NOT gainNew (1.0).
|
||||||
|
// If the older voice were released instead, the tail would be ~1.0 here.
|
||||||
|
for (std::size_t i = 15; i < out.size(); ++i) {
|
||||||
|
CHECK(approx(out[i], gainOld, 1e-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second note-off releases the remaining older voice.
|
||||||
|
eng.noteOff(60);
|
||||||
|
eng.render(out, 20);
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 testSingleFrameLoop() {
|
||||||
|
// A loop of exactly one frame [start, start+1) — the narrowest valid loop.
|
||||||
|
// The path is correct-by-luck (loopLen = 1.0 divides evenly into any integer
|
||||||
|
// readPos advance at unity ratio), but Tier-2 tight loops make it load-bearing.
|
||||||
|
SampleData s;
|
||||||
|
s.frames.resize(10);
|
||||||
|
for (int i = 0; i < 10; ++i) s.frames[i] = static_cast<float>(i) * 0.1f;
|
||||||
|
s.rootNote = 60;
|
||||||
|
s.loop.hasLoop = true;
|
||||||
|
s.loop.start = 5;
|
||||||
|
s.loop.end = 6; // single-frame loop: [5, 6)
|
||||||
|
|
||||||
|
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, 50); // well past the sample end
|
||||||
|
|
||||||
|
// Voice must still be active — the single-frame loop keeps it alive.
|
||||||
|
CHECK(eng.activeVoiceCount() == 1);
|
||||||
|
// Every frame from the loop-start onward must be the value of frame 5 (0.5).
|
||||||
|
for (std::size_t i = 10; i < out.size(); ++i) {
|
||||||
|
CHECK(approx(out[i], 0.5, 1e-4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
testSingleFrameLoop();
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user