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