336 lines
18 KiB
C++
336 lines
18 KiB
C++
// 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>
|
|
|
|
#include "core/instrument/map/trigger_seam.h" // effectiveLengthFraction (the one %-length rule)
|
|
|
|
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));
|
|
sample_ = &sample;
|
|
|
|
const PlayParams& p = sample.play;
|
|
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather
|
|
// than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed
|
|
// read-rate bias and Preserve shift amount both derive from it below).
|
|
velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity);
|
|
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_;
|
|
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; // the span-offset origin: readPos - startFrame
|
|
|
|
// The one fold of the stored span + crossfade into what the read path wraps on.
|
|
loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
|
|
frameCount, playMode_ == PlayMode::Gate);
|
|
|
|
// Bind whichever EGs are drawn. Rebound on EVERY note-on rather than cached: a reload hands
|
|
// the engine a fresh SampleData, so a stale pointer into the previous one is the bug this
|
|
// avoids. A Staged EG clears its cursor, which is what keeps the per-sample path off the
|
|
// spline branch entirely.
|
|
splineScale_ = frameCount > 0 ? 1.0 / static_cast<double>(frameCount) : 0.0;
|
|
if (p.ampSpline.mode == EnvMode::Spline) ampSplineCur_.bind(p.ampSpline.contour);
|
|
else ampSplineCur_.clear();
|
|
if (p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) {
|
|
pitchSplineCur_.bind(p.pitchSpline.contour);
|
|
pitchSplineDepth_ = p.pitchEnv.peakSemitones;
|
|
} else {
|
|
pitchSplineCur_.clear();
|
|
pitchSplineDepth_ = 0.0;
|
|
}
|
|
if (p.filter.enabled && p.filterSpline.mode == EnvMode::Spline) {
|
|
filterSplineCur_.bind(p.filterSpline.contour);
|
|
} else {
|
|
filterSplineCur_.clear();
|
|
}
|
|
|
|
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
|
|
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
|
|
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
|
|
std::int64_t trigSpan = 0;
|
|
if (playMode_ == PlayMode::Gate) {
|
|
env_.configure(p.adsr);
|
|
env_.noteOn();
|
|
playEnd_ = 0; // unused in Gate
|
|
} else {
|
|
// Trigger: play [start, playEnd) where playEnd = start + round(frac*(frames-start)).
|
|
// The spline fold lives in effectiveLengthFraction (trigger_seam.h), which the bake's
|
|
// window derivation reads too — a second copy of it here is what let a stored-but-inert
|
|
// %-knob shorten the bake while the voice played the whole take.
|
|
double frac = instrument::map::effectiveLengthFraction(p);
|
|
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
|
if (frac > 1.0) frac = 1.0;
|
|
std::int64_t playLen = static_cast<std::int64_t>(
|
|
static_cast<double>(postStart) * frac + 0.5); // round
|
|
if (playLen < 0) playLen = 0;
|
|
if (playLen > postStart) playLen = postStart;
|
|
playEnd_ = start + playLen;
|
|
trigSpan = playLen;
|
|
ampAhd_.configure(playLen, p.trigAhd);
|
|
}
|
|
|
|
// The pitch AHD's Hold fraction is taken against the whole playable span, so its three
|
|
// stages lay 1:1 over the waveform from the start point. postStart is a SOURCE-frame count
|
|
// and this envelope counts OUTPUT frames (envelopes.h), so Varispeed — which consumes
|
|
// baseRatio_ source frames per output frame — needs the span converted, or a transposed
|
|
// note's envelope outruns (or outlives) the note it shapes. Preserve reads at the source
|
|
// rate, so its two domains already coincide.
|
|
// Divides by baseRatio_ alone, though the actual Varispeed read rate is baseRatio_ x
|
|
// envFactor — a deep pitch envelope makes this a first-order approximation, not exact.
|
|
// Strictly better than the un-converted source-frame span it replaced.
|
|
const double pitchSpan =
|
|
(pitchEngine_ == PitchEngine::Preserve || !(baseRatio_ > 0.0))
|
|
? static_cast<double>(postStart)
|
|
: static_cast<double>(postStart) / baseRatio_;
|
|
pitchEnv_.configure(static_cast<std::int64_t>(pitchSpan + 0.5), p.pitchEnv);
|
|
pitchEnv_.noteOn();
|
|
|
|
// A restart lands every live glide back on the new note's own values, at a step derived
|
|
// from this sample's rate rather than any assumed one.
|
|
filterRamping_ = false;
|
|
const double rampStep = instrument::engine::liveRampStep(
|
|
static_cast<double>(sample.sampleRate));
|
|
rBaseCutoff_.step = rampStep;
|
|
rModAmount_.step = rampStep;
|
|
rResonance_.step = rampStep;
|
|
rMorph_.step = rampStep;
|
|
rDrive_.step = rampStep;
|
|
|
|
// Filter: reset() clears integrator state for the new note (prepare() preserves it —
|
|
// voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the
|
|
// per-frame path, exactly as the amp's velocityGain_ does.
|
|
filterOn_ = p.filter.enabled;
|
|
if (filterOn_) {
|
|
filterSettings_ = p.filter.settings;
|
|
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
|
|
filterModAmount_ = p.filter.modAmount;
|
|
filterKeyTrack_ = p.filter.keyTrack;
|
|
filterVelCurve_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
|
filterVelOffset_ = p.filter.velAmount * filterVelCurve_;
|
|
filterRate_ = static_cast<double>(sample.sampleRate);
|
|
rModAmount_.set(p.filter.modAmount);
|
|
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
|
|
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
|
|
rDrive_.set(static_cast<double>(p.filter.settings.driveNorm));
|
|
if (playMode_ == PlayMode::Gate) {
|
|
filterEnv_.configure(p.filter.env);
|
|
filterEnv_.noteOn();
|
|
} else {
|
|
filterAhd_.configure(trigSpan, p.filter.trigEnv);
|
|
}
|
|
filter_.reset();
|
|
updateFilterCutoffBase(note);
|
|
// The note's ONE full solve — Q, morph and drive are constants for its lifetime unless
|
|
// a live move glides them, so every later re-solve is the cheap cutoff-only path. A
|
|
// modulated voice supersedes this cutoff in tickFilterCutoff on its first frame,
|
|
// before any sample reaches the kernel.
|
|
instrument::engine::filter::FilterSettings s = p.filter.settings;
|
|
s.cutoffNorm = filterBaseCutoff_;
|
|
filter_.prepare(s, filterRate_);
|
|
filterSolvedCutoff_ = filterBaseCutoff_;
|
|
filterSolved_ = true;
|
|
}
|
|
|
|
// 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 = loop_.active;
|
|
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 -= loop_.length;
|
|
}
|
|
// q < frameCount holds by construction on the non-loop path (primeCount is
|
|
// bounded); the guard stays as a belt for the loop-wrap walk. The prime runs
|
|
// the SAME crossfade the per-frame feed does — a ring primed with an un-faded
|
|
// seam would put the click back one window into the note.
|
|
primeBuf_[static_cast<std::size_t>(i)] =
|
|
(q < frameCount)
|
|
? crossfadedSource(pcmCh, loop_, q, crossfadeWeight(loop_,
|
|
static_cast<double>(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::applyLive(const instrument::engine::LiveValues& live, bool snap) {
|
|
// Each envelope applies only the shape its play mode selected at note-on; the block
|
|
// carries both so the mode never changes what is published.
|
|
//
|
|
// A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
|
|
// flag: a voice that has rendered nothing has no phase to hold and nothing to be
|
|
// continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h).
|
|
const bool gate = (playMode_ == PlayMode::Gate);
|
|
if (snap) {
|
|
if (gate) env_.snapLive(live.adsr);
|
|
else ampAhd_.snapLive(live.ampAhd);
|
|
pitchEnv_.snapLive(live.pitchEnv);
|
|
} else {
|
|
if (gate) env_.applyLive(live.adsr);
|
|
else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
|
|
pitchEnv_.applyLive(live.pitchEnv);
|
|
}
|
|
// The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but
|
|
// pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_,
|
|
// which already glides through rModAmount_'s live ramp regardless of spline state (below),
|
|
// this is the one place a live pitch-depth move must be re-applied by hand. Only meaningful
|
|
// while pitchSplineCur_ is bound; harmless (and cheap) to set otherwise.
|
|
pitchSplineDepth_ = live.pitchEnv.peakSemitones;
|
|
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
|
|
|
|
if (snap) {
|
|
if (gate) filterEnv_.snapLive(live.filterEnv);
|
|
else filterAhd_.snapLive(live.filterAhd);
|
|
} else {
|
|
if (gate) filterEnv_.applyLive(live.filterEnv);
|
|
else filterAhd_.applyLive(sourceOffset(), live.filterAhd);
|
|
}
|
|
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
|
|
filterKeyTrack_ = live.filterKeyTrack;
|
|
// The note's curve value stays latched; only the depth over it is live. Both this and the
|
|
// key-track depth land in the base cutoff, so they glide through rBaseCutoff_ below.
|
|
filterVelOffset_ = live.filterVelAmount * filterVelCurve_;
|
|
filterSettings_.morphLaw = live.filterSettings.morphLaw;
|
|
const double baseTarget = filterCutoffBaseTarget(note_);
|
|
if (snap) {
|
|
rBaseCutoff_.set(baseTarget);
|
|
rModAmount_.set(live.filterModAmount);
|
|
rResonance_.set(static_cast<double>(live.filterSettings.resonanceNorm));
|
|
rMorph_.set(static_cast<double>(live.filterSettings.morphNorm));
|
|
rDrive_.set(static_cast<double>(live.filterSettings.driveNorm));
|
|
filterBaseCutoff_ = static_cast<float>(baseTarget);
|
|
filterModAmount_ = live.filterModAmount;
|
|
filterRamping_ = false;
|
|
prepareFilterFromRamps();
|
|
return;
|
|
}
|
|
rBaseCutoff_.aim(baseTarget);
|
|
rModAmount_.aim(live.filterModAmount);
|
|
rResonance_.aim(static_cast<double>(live.filterSettings.resonanceNorm));
|
|
rMorph_.aim(static_cast<double>(live.filterSettings.morphNorm));
|
|
rDrive_.aim(static_cast<double>(live.filterSettings.driveNorm));
|
|
filterRamping_ = rBaseCutoff_.moving() || rModAmount_.moving() || rResonance_.moving() ||
|
|
rMorph_.moving() || rDrive_.moving();
|
|
}
|
|
|
|
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;
|
|
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
|
|
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the
|
|
// first note's domain — consistent with "touch nothing else," but the drift lives here.
|
|
// The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ —
|
|
// one gesture, one strike.
|
|
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_;
|
|
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
|
|
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
|
|
if (filterOn_) updateFilterCutoffBase(note);
|
|
}
|
|
|
|
void Voice::release() {
|
|
if (!active_) return;
|
|
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
|
releasing_ = true;
|
|
env_.noteOff();
|
|
filterEnv_.noteOff();
|
|
}
|
|
|
|
} // namespace reasampler
|