Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// voice.cpp — the PER-NOTE half of Voice: note-on setup (including the Preserve ring
|
||||
// prime), legato retune, gate-off, and the off-thread shifter presize. The per-sample
|
||||
// render half is inline in voice.h by RT constraint — see that file's header.
|
||||
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const PlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// 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.
|
||||
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);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
|
||||
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
|
||||
// % play length.
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where
|
||||
// playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop
|
||||
// geometry, not on channel PCM values) — compute `p` once for channel 0, reuse for 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a
|
||||
// legato phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_ || sample_ == nullptr) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
Reference in New Issue
Block a user