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:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user