// sampler_core — pure sampler engine implementation. See sampler_core.h for the // contract and the design rationale (keymap resolution, pitch ratio, ADSR shape, // voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes. #include "sampler_core.h" #include namespace reasampler { // --------------------------------------------------------------------------- // pitchRatio // --------------------------------------------------------------------------- double pitchRatio(int note, int rootNote) { // Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0. return std::pow(2.0, static_cast(note - rootNote) / 12.0); } // --------------------------------------------------------------------------- // Keymap // --------------------------------------------------------------------------- ZoneResolution Keymap::resolve(int note, int velocity) const { (void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1. for (std::size_t i = 0; i < zones.size(); ++i) { const KeyZone& z = zones[i]; if (note >= z.lowNote && note <= z.highNote) { return ZoneResolution{true, i}; } } return ZoneResolution{false, 0}; } Keymap Keymap::singleSampleChromatic(SampleData sample) { const int root = sample.rootNote; Keymap km; km.samples.push_back(std::move(sample)); KeyZone zone; zone.lowNote = 0; zone.highNote = 127; zone.rootNote = root; zone.sampleIndex = 0; km.zones.push_back(zone); return km; } // --------------------------------------------------------------------------- // AdsrEnvelope // --------------------------------------------------------------------------- void AdsrEnvelope::noteOn() { stage_ = Stage::Attack; level_ = 0.0; framesInStage_ = 0; } void AdsrEnvelope::noteOff() { if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) { return; // already released / not sounding. } // Release from the CURRENT level — release-before-sustain releases from the // partial attack/decay level, not from sustainLevel. releaseFrom_ = level_; stage_ = Stage::Release; framesInStage_ = 0; } double AdsrEnvelope::tick() { switch (stage_) { case Stage::Idle: case Stage::Finished: level_ = 0.0; return 0.0; case Stage::Attack: { if (params_.attackFrames <= 0) { level_ = 1.0; } else { level_ = static_cast(framesInStage_) / static_cast(params_.attackFrames); if (level_ > 1.0) level_ = 1.0; } const double out = level_; ++framesInStage_; if (framesInStage_ >= params_.attackFrames) { stage_ = Stage::Decay; framesInStage_ = 0; level_ = 1.0; } return out; } case Stage::Decay: { if (params_.decayFrames <= 0) { level_ = params_.sustainLevel; } else { const double t = static_cast(framesInStage_) / static_cast(params_.decayFrames); level_ = 1.0 + (params_.sustainLevel - 1.0) * t; } const double out = level_; ++framesInStage_; if (framesInStage_ >= params_.decayFrames) { stage_ = Stage::Sustain; framesInStage_ = 0; level_ = params_.sustainLevel; } return out; } case Stage::Sustain: level_ = params_.sustainLevel; return level_; case Stage::Release: { if (params_.releaseFrames <= 0) { level_ = 0.0; stage_ = Stage::Finished; return 0.0; } const double t = static_cast(framesInStage_) / static_cast(params_.releaseFrames); level_ = releaseFrom_ * (1.0 - t); if (level_ < 0.0) level_ = 0.0; const double out = level_; ++framesInStage_; if (framesInStage_ >= params_.releaseFrames) { stage_ = Stage::Finished; level_ = 0.0; } return out; } } return 0.0; // unreachable; silences a warning. } // --------------------------------------------------------------------------- // Voice // --------------------------------------------------------------------------- void Voice::start(int note, int velocity, const SampleData& sample, int rootNote, const AdsrParams& adsr) { active_ = true; releasing_ = false; note_ = note; // MIDI velocity 1..127 -> linear gain 0..1. Clamp defensively. int v = velocity; if (v < 0) v = 0; if (v > 127) v = 127; velocityGain_ = static_cast(v) / 127.0; ratio_ = pitchRatio(note, rootNote); // 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(sample.frames.size()); std::int64_t start = sample.startFrame; if (start < 0 || start >= frameCount) start = 0; readPos_ = static_cast(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& pcm = sample_->frames; const std::int64_t frameCount = static_cast(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& 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(loopEnd - loopStart); while (readPos_ >= static_cast(loopEnd)) { readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase. } } // Ran off the end with no usable loop -> voice is done. if (readPos_ >= static_cast(frameCount)) { active_ = false; 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(readPos_); const double frac = readPos_ - static_cast(i0); std::int64_t i1 = i0 + 1; if (loopUsable && i1 >= loop.end) { i1 = loop.start; // seamless wrap for the interpolation partner. } // 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(pcm[i0]) : 0.0; const double l1 = i1ok ? static_cast(pcm[i1]) : 0.0; const double outL = (l0 + (l1 - l0) * frac) * gain; if (stereo) { const double r0 = i0ok ? static_cast(pcmR[i0]) : 0.0; const double r1 = i1ok ? static_cast(pcmR[i1]) : 0.0; outR = static_cast((r0 + (r1 - r0) * frac) * gain); } readPos_ += ratio_; if (env_.finished()) { active_ = false; } return static_cast(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& 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