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
+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