// 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 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(windowFrames) : 0, 0.0f); } void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover, double stretchRate) { // 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(velocity)); sample_ = &sample; const PlayParams& p = sample.play; // Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ 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); pitchOffsetRatio_ = semitoneRatio(p.pitchOffsetSemitones); playMode_ = p.playMode; pitchEngine_ = p.pitchEngine; // THE clamp for both engines — the taper's ends are these bounds, so a knob can never ask for // a rate this moves. Clamped once here so the read head's increment and the feed cursor's // debt accumulate the SAME value: they must stay exactly one window apart for the note's // whole life. stretchRate_ = instrument::engine::clampStretchRate(stretchRate); // Keyed on the read path this note will ACTUALLY take, which is not the same question as // the stored engine: advanceFrame runs the Preserve branch only while the shifters are // configured, and a Preserve voice whose shifters were never sized falls back to the // varispeed read. Rate has to reach the increment there too, or that fallback would ignore // the control outright — the predicate is spelled the same way advanceFrame spells it. preserveRead_ = (pitchEngine_ == PitchEngine::Preserve) && shiftL_.configured(); rateRatio_ = preserveRead_ ? 1.0 : stretchRate_; recomputeBaseRatio(); // pitchOffsetRatio_ is a power of 2 and never zero, so this inverse is well-defined — and at // Pitch 0 it is a division by exactly 1.0. pitchSpanBaseRate_ = baseRatio_ / pitchOffsetRatio_; // 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(sample.frames.size()); std::int64_t start = sample.startFrame; if (start < 0 || start >= frameCount) start = 0; readPos_ = static_cast(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(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)) — // map/trigger_seam.h's formula, evaluated inline because the engine does not depend on // map/. The spline fold is effectiveLengthFraction (play_params.h); 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 = effectiveLengthFraction(p); if (!(frac > 0.0)) frac = 0.0; // %=0 (or a corrupt NaN) -> finishes immediately if (frac > 1.0) frac = 1.0; std::int64_t playLen = static_cast( static_cast(postStart) * frac + 0.5); // round if (playLen < 0) playLen = 0; if (playLen > postStart) playLen = postStart; playEnd_ = start + playLen; trigSpan = playLen; ampAhd_.configure(playLen, rateFittedAhd(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. The source->output conversion, and // why it is only first-order, are pitchEnvSpanFrames' own (voice.h). pitchEnv_.configure(pitchEnvSpanFrames(), 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(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(p.filter.settings.cutoffNorm); filterModAmount_ = p.filter.modAmount; filterKeyTrack_ = p.filter.keyTrack; filterVelCurve_ = p.filter.velocityCurve.eval(static_cast(velocity)); filterVelOffset_ = p.filter.velAmount * filterVelCurve_; filterRate_ = static_cast(sample.sampleRate); rModAmount_.set(p.filter.modAmount); rResonance_.set(static_cast(p.filter.settings.resonanceNorm)); rMorph_.set(static_cast(p.filter.settings.morphNorm)); rDrive_.set(static_cast(p.filter.settings.driveNorm)); if (playMode_ == PlayMode::Gate) { filterEnv_.configure(p.filter.env); filterEnv_.noteOn(); } else { filterAhd_.configure(trigSpan, rateFittedAhd(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(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& 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(i)] = (q < frameCount) ? crossfadedSource(pcmCh, loop_, q, crossfadeWeight(loop_, static_cast(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). stretch_.start(p); shiftL_.setFeedRate(stretchRate_); shiftR_.setFeedRate(stretchRate_); // Pitch-synchronous splices: the period was detected once at load (period_detect, // which this library deliberately does not link — the loader hands the answer down on // SampleData). 0 restores the fixed-window geometry, so a capture with no single // period plays exactly as it always did. shiftL_.setSourcePeriod(sample.sourcePeriodFrames); shiftR_.setSourcePeriod(sample.sourcePeriodFrames); 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). // // live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class, // delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The // latched stretchRate_ is what stageFitRate carries into every conversion below, so a // stage-time move mid-note lands in this note's own rate domain rather than resetting it. const bool gate = (playMode_ == PlayMode::Gate); // The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_ // up as one more factor of next frame's read increment, Preserve as the shifter's transpose. // Applied BEFORE the envelopes below, because under Varispeed it is a factor of the read rate // both of them are fitted against — a stale offset here would fit them to the previous move. pitchOffsetRatio_ = semitoneRatio(live.pitchOffsetSemitones); recomputeBaseRatio(); if (snap) { if (gate) env_.snapLive(live.adsr); else ampAhd_.snapLive(rateFittedAhd(live.ampAhd)); pitchEnv_.snapLive(pitchEnvSpanFrames(), live.pitchEnv); } else { if (gate) env_.applyLive(live.adsr); else ampAhd_.applyLive(sourceOffset(), rateFittedAhd(live.ampAhd)); pitchEnv_.applyLive(pitchEnvSpanFrames(), 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(rateFittedAhd(live.filterAhd)); } else { if (gate) filterEnv_.applyLive(live.filterEnv); else filterAhd_.applyLive(sourceOffset(), rateFittedAhd(live.filterAhd)); } filterCutoffNorm_ = static_cast(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(live.filterSettings.resonanceNorm)); rMorph_.set(static_cast(live.filterSettings.morphNorm)); rDrive_.set(static_cast(live.filterSettings.driveNorm)); filterBaseCutoff_ = static_cast(baseTarget); filterModAmount_ = live.filterModAmount; filterRamping_ = false; prepareFilterFromRamps(); return; } rBaseCutoff_.aim(baseTarget); rModAmount_.aim(live.filterModAmount); rResonance_.aim(static_cast(live.filterSettings.resonanceNorm)); rMorph_.aim(static_cast(live.filterSettings.morphNorm)); rDrive_.aim(static_cast(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 // (pitchEnvSpanFrames, whose base rate this deliberately does not move), 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. Rate and the Pitch offset ride through too: only the note moved. recomputeBaseRatio(); // 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); // stretchRate_ (Preserve's duration control) is untouched here too — it is a note-on latch // like velocityGain_, not a per-note property to re-resolve on a legato slide. } 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