382 lines
14 KiB
C++
382 lines
14 KiB
C++
// 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);
|
|
// Initial read position honors the sample's start-point offset (S11). Clamp into
|
|
// [0, frames): a start at or past the end degrades to 0 (play from the top) rather
|
|
// than starting a voice already off the end. A negative start (shouldn't occur —
|
|
// the map clamps) is likewise pinned to 0.
|
|
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
|
std::int64_t start = sample.startFrame;
|
|
if (start < 0 || start >= frameCount) start = 0;
|
|
readPos_ = static_cast<double>(start);
|
|
sample_ = &sample;
|
|
env_.configure(adsr);
|
|
env_.noteOn();
|
|
}
|
|
|
|
void Voice::release() {
|
|
if (!active_) return;
|
|
releasing_ = true;
|
|
env_.noteOff();
|
|
}
|
|
|
|
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
|
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
|
|
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
|
|
// every channel — only the PCM value read differs. The envelope ticks ONCE per frame and
|
|
// scales all channels equally (a voice is one envelope). The head advances by exactly one
|
|
// ratio step per call, so mono and stereo consume the sample at the same rate.
|
|
if (!active_ || sample_ == nullptr) {
|
|
if (stereo) outR = 0.0f;
|
|
return 0.0f;
|
|
}
|
|
|
|
const std::vector<AudioSample>& pcm = sample_->frames;
|
|
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
|
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
|
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
|
const bool haveR = stereo && sample_->channelCount() == 2;
|
|
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
|
|
|
// 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;
|
|
if (stereo) outR = 0.0f;
|
|
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. framesR is length-matched
|
|
// to frames (channelCount() enforces it), so the same indices are valid in both.
|
|
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
|
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
|
|
|
const double amp = env_.tick();
|
|
const double gain = amp * velocityGain_;
|
|
|
|
const double l0 = i0ok ? static_cast<double>(pcm[i0]) : 0.0;
|
|
const double l1 = i1ok ? static_cast<double>(pcm[i1]) : 0.0;
|
|
const double outL = (l0 + (l1 - l0) * frac) * gain;
|
|
|
|
if (stereo) {
|
|
const double r0 = i0ok ? static_cast<double>(pcmR[i0]) : 0.0;
|
|
const double r1 = i1ok ? static_cast<double>(pcmR[i1]) : 0.0;
|
|
outR = static_cast<AudioSample>((r0 + (r1 - r0) * frac) * gain);
|
|
}
|
|
|
|
readPos_ += ratio_;
|
|
|
|
if (env_.finished()) {
|
|
active_ = false;
|
|
}
|
|
return static_cast<AudioSample>(outL);
|
|
}
|
|
|
|
AudioSample Voice::renderFrame() {
|
|
AudioSample discard = 0.0f;
|
|
return advanceFrame(/*stereo=*/false, discard);
|
|
}
|
|
|
|
void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
|
r = 0.0f;
|
|
l = advanceFrame(/*stereo=*/true, r);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(AudioSample* out, std::size_t frameCount) {
|
|
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
|
// The VST3 process callback hands us the host's output channel buffer here, so the
|
|
// audio thread never touches the heap (S4 real-time discipline).
|
|
if (out == nullptr || frameCount == 0) return;
|
|
for (Voice& voice : voices_) {
|
|
if (!voice.active()) continue;
|
|
for (std::size_t f = 0; f < frameCount; ++f) {
|
|
if (!voice.active()) break;
|
|
out[f] += voice.renderFrame();
|
|
}
|
|
}
|
|
}
|
|
|
|
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
|
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
|
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
|
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
|
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
|
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
|
for (Voice& voice : voices_) {
|
|
if (!voice.active()) continue;
|
|
for (std::size_t f = 0; f < frameCount; ++f) {
|
|
if (!voice.active()) break;
|
|
AudioSample l = 0.0f, r = 0.0f;
|
|
voice.renderFrameStereo(l, r);
|
|
left[f] += l;
|
|
right[f] += r;
|
|
}
|
|
}
|
|
}
|
|
|
|
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
|
// Off-thread / test path: grow the buffer (this allocates — never call under
|
|
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
|
// overloads share exactly one summation path.
|
|
const std::size_t base = out.size();
|
|
out.resize(base + frameCount, 0.0f);
|
|
render(out.data() + base, frameCount);
|
|
}
|
|
|
|
std::size_t VoiceEngine::activeVoiceCount() const {
|
|
std::size_t n = 0;
|
|
for (const Voice& v : voices_) {
|
|
if (v.active()) ++n;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
} // namespace reasampler
|