Play cross-rate captures at their own pitch and length

The voice now converts the file's rate to the host's at note-on instead of assuming they match. Stage times resolve at the host rate, the filter solves at it, and the bake follows. Same-rate renders are unchanged bit for bit.
This commit is contained in:
2026-09-25 13:44:39 -04:00
parent 9ff34b0a9e
commit ec60cf78c6
25 changed files with 749 additions and 137 deletions
+19 -1
View File
@@ -170,6 +170,24 @@ pitch envelope/curve (AD?) which is off by default."*
- **S15/S16 are Tier 0–1 engine features, not Tier 2/3** — do not let the held Tier-2
feature list (velocity layers / round-robin / filter work) drive their build shape.
### Two frame domains — a capture plays at its own rate in any host
A capture's file rate and the host's are independent, and the engine never resamples: the
voice reads SOURCE frames and emits OUTPUT frames, and `SampleData::sourceRateRatio`
(`play_params.h`) is the one conversion between them, folded into the read, the Preserve feed and
the shifter's tap at note-on.
- **Source frames:** the PCM, the loop and its crossfade, the start point, the Trigger span, the
spline phase, the detected period, and the Preserve ring (`preserveWindowFrames`).
- **Output frames (the host's):** every stage time in `SampleData::play` (resolved at
`SampleData::renderRate`), the filter solve, the live ramps and the declick. A source-offset
envelope converts through `stageFitRate`, which carries the ratio.
- **Rejected alternative: resample at load.** It would round every loop point into the host
domain (detuning a single-cycle loop), force the editor and the bank intrinsics through a
conversion at every edge, and need a band-limited resampler in the core. The cost of the
chosen shape is that a mismatched capture's ROOT note is interpolated like any other repitched
note already is.
### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30)
Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other envelopes. We
@@ -325,7 +343,7 @@ anything for a trigger shape.
### `map/`
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the HOST rate the voice emits at (never the WAV's own — see "Two frame domains" above) — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v16), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable, v16 the playback rate + the baseline pitch offset. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
+8 -3
View File
@@ -97,9 +97,14 @@ decision about what the render made obsolete.
- **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that
slows the read makes the head take longer to cross its span, so the window is scaled by the
slowest read the voice can reach — a shallower excursion leaves trailing silence in the file.
Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed
alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length
take the product, and the Gate-with-loop branch takes neither.
Rate and the source/host rate conversion are terms of it under BOTH engines and the deepest
downward pitch offset under Varispeed alone (`playbackStretch` argues each); both the Trigger
span and the Gate exhaustion length take the product, and the Gate-with-loop branch takes
neither.
- **The snapshot and the plan must share one rate.** The snapshot's frame counts are resolved
for its `renderRate`; the shell passes the plan's rate into `bakeSnapshot` so they agree, and
`renderBake` refuses a pair that does not rather than printing every stage time off by the
ratio.
- **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves
are live, so the velocity is a property of the sound being printed and not a detail of the
render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window).
+10 -5
View File
@@ -30,19 +30,23 @@ bool toFrames(double seconds, int rate, std::int64_t& out) {
}
// OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the
// factor a source span is scaled by to bound how long it takes to play out. Two terms:
// factor a source span is scaled by to bound how long it takes to play out. Three terms:
//
// Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve
// feeds the stretcher at it, so either way the source is consumed at that many frames per
// output frame. Taken through the engine's clamp, because that is the value Voice::start
// actually plays.
//
// The source/host rate conversion divides too, under both engines and for the same reason:
// it is a factor of the read and of the feed alike (SampleData::sourceRateRatio).
//
// The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head
// advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read
// rate alone, which is the only sense in which the two are decoupled there.
//
// A Gate release is ticked per output frame, so neither term touches it.
double playbackStretch(const PlayParams& play, int velocity) {
// A Gate release is ticked per output frame, so no term touches it.
double playbackStretch(const SampleData& dialed, int velocity) {
const PlayParams& play = dialed.play;
double down = 0.0;
if (play.pitchEngine == PitchEngine::Varispeed) {
down = (std::min)(0.0, kVelocityPitchRangeSemitones *
@@ -58,7 +62,8 @@ double playbackStretch(const PlayParams& play, int velocity) {
: (std::min)(0.0, play.pitchEnv.peakSemitones);
}
}
return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate);
return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate) /
dialed.sourceRateRatio();
}
// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
@@ -93,7 +98,7 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
const double rate = static_cast<double>(renderSampleRate);
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
const std::int64_t start = effectiveStart(dialed);
const double stretch = playbackStretch(dialed.play, p.velocity.value());
const double stretch = playbackStretch(dialed, p.velocity.value());
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
double endOffsetSeconds = 0.0;
+5 -3
View File
@@ -29,11 +29,13 @@ bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t cro
bool bakeWindowNeedsHold(const SampleData& dialed);
// The bake's programmed note, DERIVED from the dialed sound at `renderSampleRate` (the rate
// the bake renders at, which is what the engine's frame counts are consumed against):
// the bake renders at, which is what the engine's frame counts are consumed against — and must
// be the rate `dialed` was resolved at, its renderRate):
//
// Trigger — the note IS the play span (note-off is ignored anyway), stretched by the
// slowest read the dialed voice can reach: Rate under BOTH engines, plus the
// deepest downward pitch offset under Varispeed.
// slowest read the dialed voice can reach: Rate and the source/host rate
// conversion under BOTH engines, plus the deepest downward pitch offset under
// Varispeed.
// Gate, loop — `hold` is the note length; the end offset is the release.
// Gate, no loop— the read head runs off the source and frees the voice whatever the gate is
// doing, so the note is the whole post-start span, stretched the same way.
+6 -5
View File
@@ -23,6 +23,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
bool limiterEnabled) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
// The snapshot's frame counts were resolved at ITS output rate; rendered at another, every
// stage time and the filter corner would print off by the ratio.
if (sample.outputRate() > 0 && sample.outputRate() != plan.sampleRate) return out;
// Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan
// (planBake already bounds both — bake_plan.cpp) could otherwise carry leadInFrames
// near INT64_MAX and signed-overflow inside the guard meant to catch exactly that.
@@ -51,11 +54,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
// does for its live engine — a cold shifter would smear the onset.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(plan.sampleRate) / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0,
preserveWindowFrames(sample), VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/false);
for (std::int64_t pos = 0; pos < plan.renderFrames();) {
if (pos == plan.noteOnFrame) engine.noteOn(plan.note, plan.velocity);
+2 -1
View File
@@ -33,7 +33,8 @@ struct BakeAudio {
// runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see
// bake_render.cpp for why both print here rather than in the processor. `limiterEnabled`
// false yields the pre-limiter render. The result is the plan's captured window: the
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
// lead-in frames are rendered and dropped. An unplayable sample, or one resolved for an
// output rate other than the plan's, yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
bool limiterEnabled);
@@ -56,6 +56,10 @@ reasampler_test(live_delivery LINK sampler_core)
# both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core)
# A capture whose file rate differs from the host's, across the same engine: pitch, duration,
# envelope, loop and filter quantities asserted in wall-clock terms.
reasampler_test(source_rate LINK sampler_core)
# Measurement harness for Preserve on low-frequency material: how the splice search's
# reachable relocation interval interacts with a long source period. Written longhand and
# deliberately NOT add_test()'d — it sweeps frequencies, windows and spectra and takes ~2m40s
+37 -5
View File
@@ -103,8 +103,8 @@ enum class PitchEngine { Varispeed, Preserve };
// still halves duration with no params set).
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
// on big transpositions. Onset latency is zero — start() primes the ring with the first window
// OLA window for the Preserve PitchShifter, in ms of SOURCE (preserveWindowFrames); larger =
// smoother on big transpositions. Onset latency is zero — start() primes the ring with the first window
// of real source, so output frame 0 is source frame 0 regardless of window size.
inline constexpr double kPreserveWindowMs = 50.0;
@@ -253,9 +253,13 @@ struct SampleLoop {
struct SampleData {
std::vector<AudioSample> frames;
std::vector<AudioSample> framesR; // empty for a mono sample
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
// repitch; still, 0 is invalid — every consumer must
// receive a real rate before use.
// The SOURCE's own rate: the PCM, the loop, the start, the crossfade and the source period
// are all frames at this rate.
int sampleRate = 0;
// The rate the voice EMITS frames at (the host's), and so the rate every frame count in
// `play` was resolved against. 0 = not supplied, which renders the source at its own rate —
// a hand-built SampleData, or a load before the host reported a rate.
int renderRate = 0;
int rootNote = 60;
SampleLoop loop;
@@ -305,6 +309,34 @@ struct SampleData {
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
// voice on an empty read span.
bool playable() const { return !frames.empty(); }
int outputRate() const { return renderRate > 0 ? renderRate : sampleRate; }
// Source frames consumed per output frame at unity pitch — THE conversion between the two
// frame domains, folded into the read and the Preserve feed at note-on. Exactly 1.0 when the
// rates agree or either is unknown, which is what keeps a same-rate render bit-identical to
// the engine before the factor existed.
double sourceRateRatio() const {
const int out = outputRate();
return (sampleRate > 0 && out > 0 && sampleRate != out)
? static_cast<double>(sampleRate) / static_cast<double>(out)
: 1.0;
}
};
// The largest sourceRateRatio a load accepts. The Preserve feed consumes that many times the
// stretch rate in source frames per output frame, so the ratio bounds per-frame work on the
// audio thread; a header claiming more than this is treated as corrupt (buildSampleData).
inline constexpr double kMaxSourceRateRatio = 32.0;
// The Preserve ring's window in SOURCE frames. The ring holds source and its feed and tap both
// carry sourceRateRatio, so a window sized at the source rate keeps every splice interval,
// search reach and alignable period in time_stretch.h fixed in Hz at any host rate. Floored at 2
// so a valid window is always a real ring.
inline std::int64_t preserveWindowFrames(const SampleData& s) {
const std::int64_t w = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(s.sampleRate) / 1000.0 + 0.5);
return w < 2 ? 2 : w;
}
} // namespace reasampler
+14 -5
View File
@@ -77,9 +77,13 @@ namespace reasampler::instrument::engine {
// quantity from the raw percentages here. What survives: a period longer than the reachable
// jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no
// single period falls back to it by design (periodAlignedJump, pitch_shift.h).
//
// These bound the MUSICAL rate. The feed is that rate times the source/host rate ratio
// (StretchCursor::start), and the ratio is deliberately NOT clamped here: the geometry above
// holds in wall-clock terms at any ratio (preserveWindowFrames, play_params.h, owns why).
inline constexpr double kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0;
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax)
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) at a matched source rate
// Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave
// playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to
@@ -97,16 +101,20 @@ inline double clampStretchRate(double rate) {
class StretchCursor {
public:
// `frame` is where the ring prime stopped; the per-frame feed continues there.
void start(std::int64_t frame) {
// `sourceRateRatio` is source frames per output frame at rate 1.0 (SampleData's own) —
// exactly 1.0 at a matched rate, where the multiply below is the identity.
void start(std::int64_t frame, double sourceRateRatio = 1.0) {
frame_ = frame;
debt_ = 0.0;
sourceRatio_ = sourceRateRatio > 0.0 ? sourceRateRatio : 1.0;
}
// Adds one output frame's worth of source at `rate` and returns how many whole source
// frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp
// lives here rather than at the caller because this return value is the loop bound.
// frames are now due, in [0, ceil(kStretchRateMax * sourceRateRatio)]. Take each of them
// with next(). The clamp lives here rather than at the caller because this return value is
// the loop bound; the load's kMaxSourceRateRatio (play_params.h) is what bounds the ratio.
std::int64_t due(double rate) {
debt_ += clampStretchRate(rate);
debt_ += clampStretchRate(rate) * sourceRatio_;
const std::int64_t whole = static_cast<std::int64_t>(debt_); // debt_ >= 0: trunc = floor
debt_ -= static_cast<double>(whole);
return whole;
@@ -127,6 +135,7 @@ public:
private:
std::int64_t frame_ = 0;
double debt_ = 0.0; // fractional source frames carried into the next output frame
double sourceRatio_ = 1.0;
};
} // namespace reasampler::instrument::engine
+11 -7
View File
@@ -65,6 +65,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// debt accumulate the SAME value: they must stay exactly one window apart for the note's
// whole life.
stretchRate_ = instrument::engine::clampStretchRate(stretchRate);
sourceRatio_ = sample.sourceRateRatio();
feedRate_ = stretchRate_ * sourceRatio_;
// 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
@@ -142,10 +144,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
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.
// from the rate this voice emits at rather than any assumed one.
filterRamping_ = false;
const double rampStep = instrument::engine::liveRampStep(
static_cast<double>(sample.sampleRate));
static_cast<double>(sample.outputRate()));
rBaseCutoff_.step = rampStep;
rModAmount_.step = rampStep;
rResonance_.step = rampStep;
@@ -163,7 +165,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
filterKeyTrack_ = p.filter.keyTrack;
filterVelCurve_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterVelOffset_ = p.filter.velAmount * filterVelCurve_;
filterRate_ = static_cast<double>(sample.sampleRate);
// The emitted rate, never the source's: the corner is a frequency of what the voice
// outputs, and one output frame is one host frame.
filterRate_ = static_cast<double>(sample.outputRate());
rModAmount_.set(p.filter.modAmount);
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
rMorph_.set(static_cast<double>(p.filter.settings.morphNorm));
@@ -241,9 +245,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
}
// 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_);
stretch_.start(p, sourceRatio_);
shiftL_.setFeedRate(feedRate_);
shiftR_.setFeedRate(feedRate_);
// 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
@@ -269,7 +273,7 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
//
// 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
// latched feedRate_ 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_
+23 -15
View File
@@ -207,24 +207,27 @@ private:
// THE fold of every pitch factor that is constant for the note into one number, so
// advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has
// always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset,
// and the Rate ratio — which start() zeroes out of this product when the note is running the
// the Rate ratio — which start() zeroes out of this product when the note is running the
// Preserve read, since Rate feeds stretch_ (duration) there and must never reach the
// shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame.
// shifter's transpose — and the source/host rate conversion, which belongs in BOTH reads:
// under Preserve the shifter's ring holds source frames, so its tap needs the same factor
// to land the root on the root. Cold: note-on, legato retune, and a live block.
void recomputeBaseRatio() {
if (sample_ == nullptr) return;
baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) *
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_;
velPitchRatio_ * pitchOffsetRatio_ * rateRatio_ * sourceRatio_;
}
// The rate the read head consumes SOURCE at, counting only the factors whose stage-time
// coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset
// coupling is compensated. Under Preserve that is the feed rate alone — the Pitch offset
// transposes inside the shifter and never touches the read. Under Varispeed both Rate and
// Pitch are factors of the read increment and both are compensated: they are two views of one
// multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the
// velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped
// sounds, and compensating them would move every note off the root.
// multiply, so the "30 ms is 30 ms" rule binds them identically. The source/host conversion
// (inside feedRate_) is compensated under both, because it is a change of units, not a
// sound. Key-tracking and the velocity->pitch transpose are deliberately LEFT OUT — those
// predate Rate, are shipped sounds, and compensating them would move every note off the root.
double stageFitRate() const {
return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_;
return preserveRead_ ? feedRate_ : feedRate_ * pitchOffsetRatio_;
}
// A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the
@@ -258,7 +261,7 @@ private:
const double postStart = static_cast<double>(
static_cast<std::int64_t>(sample_->frames.size()) - startFrame_);
const double readRate =
preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
preserveRead_ ? feedRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_;
const double span = (readRate > 0.0) ? postStart / readRate : postStart;
return static_cast<std::int64_t>(span + 0.5);
}
@@ -529,8 +532,8 @@ private:
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// The two rates the shifter takes (pitch_shift.h owns why they are independent):
// the source is FED at stretchRate_, and the tap is SHIFTED by
// 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount,
// the source is FED at feedRate_, and the tap is SHIFTED by baseRatio_ x
// 2^(pitchEnvSemis/12) — the pitch envelope adds to the shift amount,
// never to the read rate. The feed runs one window ahead of readPos_ (the rings were
// primed with that window at start()), under the same sustain-loop wrap rule,
// reading integer source frames into the ring — no RATE-DEPENDENT interpolation
@@ -607,13 +610,13 @@ private:
outRlocal = shiftedL;
}
}
// Preserve advances the read head at the STRETCH rate — the one duration control.
// Everything downstream of it (the loop wrap, the Trigger span, the spline phase)
// therefore stays a source-frame fact and scales by construction.
// Preserve advances the read head at the FEED rate — the one duration control, in
// source frames. Everything downstream of it (the loop wrap, the Trigger span, the
// spline phase) therefore stays a source-frame fact and scales by construction.
//
// The two sustain-less envelopes are evaluated at sourceOffset(), which advances at
// this rate — rateFittedAhd is what keeps their stage times wall-clock anyway.
ratio_ = stretchRate_;
ratio_ = feedRate_;
} else {
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
@@ -816,6 +819,11 @@ private:
PitchShifter shiftR_;
instrument::engine::StretchCursor stretch_;
double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on
// SampleData::sourceRateRatio, latched at note-on: source frames per output frame at unity.
double sourceRatio_ = 1.0;
// stretchRate_ in source frames per output frame (x sourceRatio_): the Preserve read head's
// and feed's exact rate, and the Rate factor every stage-time fit converts by.
double feedRate_ = 1.0;
std::vector<AudioSample> primeBuf_;
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
+11 -5
View File
@@ -309,28 +309,34 @@ std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
return resolveCapture(*ref, params);
}
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded,
int renderRate) {
SampleData data;
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
assert(decoded.sampleRate > 0 &&
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
data.sampleRate = decoded.sampleRate;
data.renderRate = renderRate > 0 ? renderRate : 0;
// A header rate this far above the host's is corrupt, not a capture — and playing it would
// put that ratio's worth of feed work on the audio thread per frame (kMaxSourceRateRatio).
if (data.sourceRateRatio() > kMaxSourceRateRatio) return SampleData{};
data.frames = std::move(decoded.monoFrames);
// Carry the second channel only when it length-matches channel 0 (channelCount()
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
data.framesR = std::move(decoded.framesR);
}
data.sampleRate = decoded.sampleRate;
data.rootNote = resolved.rootNote;
data.loop = resolved.loop;
data.loopCrossfadeFrames = resolved.loopCrossfadeFrames;
data.startFrame = resolved.startFrame;
data.keyTrack = resolved.keyTrack;
data.velocityCurve = resolved.velocityCurve;
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(resolved.play, data.sampleRate);
// Resolve the stored wall-clock SECONDS to OUTPUT frames — every stage the engine counts is
// counted per emitted frame, or converted into source frames at note-on. Source-timeline
// params (trigger %-length, start, loop, crossfade) carry through in the WAV's own frames.
data.play = resolvePlay(resolved.play, data.outputRate());
// The one place Preserve's source period is computed: the load, off the audio thread.
// Channel 0 only — a stereo pair's two channels share a fundamental, and the splice
// schedule is linked across them anyway. The span is the sustain loop where one is long
+10 -7
View File
@@ -145,9 +145,9 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
// --- Seconds -> frames --------------------------------------------------------
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
// unchanged. `sampleRate` must be > 0 (the caller guards this).
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against the rate the
// voice EMITS at — the host's (frames = round(seconds * rate)). Source-timeline fields carry
// through unchanged. `sampleRate` must be > 0 (the caller guards this).
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate);
// --- The instrument's ONE parameter set (its OWN state) -----------------------
@@ -263,10 +263,13 @@ DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
// Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine
// plays across the whole keyboard, repitched from the effective root. A second channel is
// carried only when it length-matches channel 0 (SampleData::channelCount() enforces the
// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to
// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an
// unplayable SampleData (silence, never a crash).
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded);
// same rule, so a bad pair never half-plays). `renderRate` is the host's rate: the stored
// wall-clock SECONDS resolve to frames against it, and the voice converts the decode's own
// rate to it (SampleData::sourceRateRatio). A non-positive renderRate (the host has not
// reported one yet) renders at the decode's rate. Empty PCM, a non-positive decode rate, or a
// decode rate more than kMaxSourceRateRatio times the host's yields an unplayable SampleData
// (silence, never a crash).
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded, int renderRate);
// The ComponentState envelope + params-payload binary codec lives in component_state_io.h:
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,