Merge cross-rate playback: ReaSampler 9000 plays any file rate at its own pitch and length
This commit is contained in:
@@ -2,20 +2,6 @@
|
||||
|
||||
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
|
||||
|
||||
## The per-voice filter is solved against the WAV's sample rate, not the render rate
|
||||
|
||||
**Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates.
|
||||
|
||||
**The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it.
|
||||
|
||||
**Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism.
|
||||
|
||||
**The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default.
|
||||
|
||||
**Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge.
|
||||
|
||||
**Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit.
|
||||
|
||||
## Filter ring-out is truncated on the source-exhaustion path
|
||||
|
||||
**Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out.
|
||||
|
||||
@@ -170,6 +170,33 @@ 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`) with every
|
||||
splice distance in it.
|
||||
- **Output frames (the host's):** every stage time in `SampleData::play` (resolved at
|
||||
`SampleData::renderRate`), the filter solve, the live ramps, the declick, and the shifter's
|
||||
splice crossfade (`PitchShifter::setSourceRateRatio`, which also strides the splice search to
|
||||
a matched rate's cost). 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.
|
||||
- **That cost is asymmetric.** Nothing band-limits the read, so a file ABOVE the host rate
|
||||
folds whatever it holds over the host's Nyquist into the audible band even at its root — the
|
||||
aliasing a matched capture only meets when transposed up. A file BELOW the host rate is read
|
||||
slower than one frame per frame and only images, the milder direction. Offline captures
|
||||
render at the project rate (`shell/capture` pins `RENDER_SRATE`), so the exposed case is an
|
||||
ingested high-rate file, or a host running below the rate a capture was made at.
|
||||
|
||||
### 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
|
||||
@@ -334,7 +361,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.
|
||||
@@ -52,11 +55,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);
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ reasampler_test(live_contours 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
|
||||
|
||||
@@ -60,6 +60,8 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
filled_ = 0;
|
||||
ratio_ = 1.0;
|
||||
feedRate_ = 1.0;
|
||||
sourceRatio_ = 1.0;
|
||||
stride_ = 1;
|
||||
tailFrozen_ = false;
|
||||
lastSplice_ = SpliceEvent{};
|
||||
return;
|
||||
@@ -67,26 +69,43 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
|
||||
ringLen_ = 2 * window_;
|
||||
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
|
||||
// Geometry (all quarters of the window):
|
||||
// - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
|
||||
// the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
|
||||
// past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
|
||||
// Geometry (all quarters of the window, in SOURCE frames):
|
||||
// - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
|
||||
// down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
||||
// - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay).
|
||||
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
|
||||
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
|
||||
// the splice burst.
|
||||
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
|
||||
// fadeFrames_ and corrFrames_ also scale with the source ratio — applySourceRatio.
|
||||
maxLag_ = window_ / 4;
|
||||
dLow_ = window_ / 4;
|
||||
dHigh_ = ringLen_ - window_ / 4;
|
||||
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512));
|
||||
// The delay band is (dHigh_ - dLow_) wide and the search can add up to maxLag_ on either
|
||||
// side; one frame more than that and a jump could land exactly ON a trigger boundary.
|
||||
jumpMax_ = std::max<std::int64_t>(1, dHigh_ - dLow_ - maxLag_ - 1);
|
||||
fadeLen_ = 0;
|
||||
reset();
|
||||
reset(); // applies the unity source ratio
|
||||
}
|
||||
|
||||
void PitchShifter::applySourceRatio() {
|
||||
if (window_ <= 1) return;
|
||||
// (setSourceRateRatio owns why each of these moves with the ratio.)
|
||||
// - fadeFrames_: nominal splice crossfade, in OUTPUT frames. Only safe while the outgoing
|
||||
// tap can reach neither the writer nor the ring's far end before the fade ends; splice()
|
||||
// shortens the live fade whenever the drift says it would. Rounded, so 1.0 lands on
|
||||
// window/4 exactly.
|
||||
// - stride_: unstrided, the search's cost would grow with the ratio — a burst on one audio
|
||||
// frame, stacked across every voice that splices on it.
|
||||
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
|
||||
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 SAMPLED
|
||||
// frames bounds the splice burst and keeps the reference's wall-clock span.
|
||||
// Both clamped in double before the int64 cast: the setter accepts any positive ratio.
|
||||
const double fade = static_cast<double>(window_ / 4) / sourceRatio_ + 0.5;
|
||||
fadeFrames_ = std::max<std::int64_t>(
|
||||
static_cast<std::int64_t>(
|
||||
std::min(fade, static_cast<double>(std::numeric_limits<std::int32_t>::max()))),
|
||||
1);
|
||||
const double stride = std::min(sourceRatio_ + 0.5, static_cast<double>(maxLag_));
|
||||
stride_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(stride));
|
||||
corrFrames_ =
|
||||
std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512 * stride_));
|
||||
}
|
||||
|
||||
void PitchShifter::reset() {
|
||||
@@ -111,6 +130,8 @@ void PitchShifter::reset() {
|
||||
filled_ = 0;
|
||||
ratio_ = 1.0;
|
||||
feedRate_ = 1.0;
|
||||
sourceRatio_ = 1.0;
|
||||
applySourceRatio();
|
||||
period_ = 0.0;
|
||||
jump_ = window_ > 1 ? window_ : 0;
|
||||
tailFrozen_ = false;
|
||||
@@ -195,6 +216,12 @@ void PitchShifter::setFeedRate(double rate) {
|
||||
if (rate > 0.0) feedRate_ = rate;
|
||||
}
|
||||
|
||||
void PitchShifter::setSourceRateRatio(double ratio) {
|
||||
if (!(ratio > 0.0)) return;
|
||||
sourceRatio_ = ratio;
|
||||
applySourceRatio();
|
||||
}
|
||||
|
||||
double PitchShifter::readTap(double pos) const {
|
||||
// Fractional linear interpolation with ring wrap.
|
||||
double p = pos;
|
||||
@@ -214,9 +241,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
// Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
|
||||
// content, -window_ = toward the writer), refined by a correlation search so the relocated
|
||||
// read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
|
||||
// (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
|
||||
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
|
||||
// per splice.
|
||||
// (step 4*stride_ over +/- maxLag_) then fine (+/- 3 strides around the coarse best, +/-
|
||||
// stride_-1 single frames around that, then a parabolic sub-sample peak), each score
|
||||
// sampling every stride_-th frame: a bounded burst of ~ (maxLag_/(2*stride_) + 2*stride_ +
|
||||
// 7) * corrFrames_/stride_ multiply-adds, once per splice.
|
||||
const std::int64_t d = static_cast<std::int64_t>(delay);
|
||||
|
||||
// An up-jump may only relocate into valid history. The deepest slot the search (plus the
|
||||
@@ -247,13 +275,13 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
std::int64_t ia = iA;
|
||||
std::int64_t ic = ((iA - jump + lag) % ringLen_ + ringLen_) % ringLen_;
|
||||
double s = 0.0, ec = 0.0;
|
||||
for (std::int64_t k = 0; k < corr; ++k) {
|
||||
for (std::int64_t k = 0; k < corr; k += stride_) {
|
||||
const double a = static_cast<double>(ring_[static_cast<std::size_t>(ia)]);
|
||||
const double c = static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
|
||||
s += a * c;
|
||||
ec += c * c;
|
||||
if (++ia >= ringLen_) ia = 0;
|
||||
if (++ic >= ringLen_) ic = 0;
|
||||
if ((ia += stride_) >= ringLen_) ia -= ringLen_;
|
||||
if ((ic += stride_) >= ringLen_) ic -= ringLen_;
|
||||
}
|
||||
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
|
||||
// so on a decaying tail every up-splice would prefer the loudest candidate over the
|
||||
@@ -264,7 +292,7 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
|
||||
std::int64_t bestLag = 0;
|
||||
double bestScore = -std::numeric_limits<double>::infinity();
|
||||
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4) {
|
||||
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4 * stride_) {
|
||||
const double s = scoreAt(lag);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
@@ -272,7 +300,7 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
}
|
||||
}
|
||||
const std::int64_t coarse = bestLag;
|
||||
for (std::int64_t lag = coarse - 3; lag <= coarse + 3; ++lag) {
|
||||
for (std::int64_t lag = coarse - 3 * stride_; lag <= coarse + 3 * stride_; lag += stride_) {
|
||||
if (lag == coarse || lag < -maxLag_ || lag > maxLag_) continue;
|
||||
const double s = scoreAt(lag);
|
||||
if (s > bestScore) {
|
||||
@@ -280,6 +308,16 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
// Empty at a stride of 1, which is what keeps the matched-rate search unchanged.
|
||||
const std::int64_t strided = bestLag;
|
||||
for (std::int64_t lag = strided - (stride_ - 1); lag <= strided + (stride_ - 1); ++lag) {
|
||||
if (lag == strided || lag < -maxLag_ || lag > maxLag_) continue;
|
||||
const double s = scoreAt(lag);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
|
||||
// sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
|
||||
@@ -305,26 +343,33 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
while (p < 0.0) p += len;
|
||||
while (p >= len) p -= len;
|
||||
posA_ = p;
|
||||
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
|
||||
// writer at (ratio - feedRate) per frame; the nominal window/4 fade only keeps it behind
|
||||
// the writer while that rate stays under ~1 — beyond that (e.g. +24 st = ratio 4, or a
|
||||
// half-speed feed under any up-shift) it would cross mid-fade and play stale read-ahead
|
||||
// data. Cap the live fade at the drain headroom actually available, minus 2 (trigger
|
||||
// undershoot + interpolator read-ahead margin). A drain rate at or below zero (down-shifts,
|
||||
// and up-shifts the feed outruns) can't reach the ring end within window/4 frames, so those
|
||||
// always keep the full fade.
|
||||
// Drift-scaled fade length. The outgoing tap's delay behind the writer moves by
|
||||
// (feedRate - ratio) source frames per output frame, and the fade lasts fadeFrames_ OUTPUT
|
||||
// frames, so it moves the tap drift x fadeFrames_ — a quarter window at |drift| == 1 at a
|
||||
// matched rate. Past that it runs out of ring in whichever direction it drifts:
|
||||
// - drain (ratio > feedRate; up-shifts, a slow feed): toward the writer, where it would
|
||||
// read stale read-ahead data (e.g. +24 st = ratio 4, or a half-speed feed under any
|
||||
// up-shift);
|
||||
// - recede (ratio < feedRate; down-shifts, a fast feed): toward the ring's far end, where
|
||||
// it would read audio the writer has already replaced (e.g. -24 st at Rate 200 %).
|
||||
// Cap the live fade at the headroom actually left in that direction — a quarter window
|
||||
// either side of the band — minus the one frame's trigger overshoot and 2 (the integer
|
||||
// feed's jitter + interpolator read-ahead margin).
|
||||
//
|
||||
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
|
||||
// either shift direction, so the drain rate is ratio_ regardless of feed and the cap applies
|
||||
// at every ratio (including unity, since delay now drains at unity too).
|
||||
fadeLen_ = fadeFrames_;
|
||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - feedRate_);
|
||||
if (drainRate > 0.0) {
|
||||
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
|
||||
if (drainRate != 0.0) {
|
||||
const double rate = std::fabs(drainRate);
|
||||
const double room = drainRate > 0.0 ? static_cast<double>(dLow_)
|
||||
: static_cast<double>(ringLen_ - dHigh_);
|
||||
const double headroom = room - rate - 2.0;
|
||||
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
|
||||
// at very high sample rates (where headroom/drainRate could overflow int64).
|
||||
// at very high sample rates (where headroom/rate could overflow int64).
|
||||
const double safeDbl = headroom > 0.0
|
||||
? std::min(headroom / drainRate, static_cast<double>(fadeFrames_))
|
||||
? std::min(headroom / rate, static_cast<double>(fadeFrames_))
|
||||
: 1.0;
|
||||
fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl));
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ struct SpliceEvent {
|
||||
std::int64_t jump = 0; // the clamped nominal jump actually applied (signed)
|
||||
std::int64_t lag = 0; // correlation best integer lag
|
||||
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
|
||||
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
|
||||
std::int64_t fadeLen = 0; // live crossfade length chosen (drift-scaled, either direction)
|
||||
};
|
||||
|
||||
// The nominal splice jump for a source whose period is known: the multiple of `periodFrames`
|
||||
@@ -118,6 +118,15 @@ public:
|
||||
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
|
||||
void setFeedRate(double rate);
|
||||
|
||||
// Source frames per output frame at unity (SampleData::sourceRateRatio). The ring and every
|
||||
// splice distance are SOURCE frames, but a crossfade is counted per OUTPUT frame, so the
|
||||
// fade is window/4 source frames' worth of output time — the same wall-clock length and the
|
||||
// same share of the splice cadence at any ratio. It also strides the correlation search by
|
||||
// the nearest whole ratio, so a splice's cost and its wall-clock reach are a matched rate's
|
||||
// at any file rate. Values <= 0 are ignored; exactly 1.0 is the matched geometry bit for
|
||||
// bit. Reset to 1.0 by configure()/reset(), like the feed rate.
|
||||
void setSourceRateRatio(double ratio);
|
||||
|
||||
// The period of the source being fed, in SOURCE frames, making every splice jump a whole
|
||||
// number of it — <= 0 means "unknown" (see periodAlignedJump for the exact fallback); the
|
||||
// default, so a caller that never calls this sees no change.
|
||||
@@ -195,6 +204,9 @@ private:
|
||||
// `write` false is the starved stretch frame: read/splice/advance the taps, but consume no
|
||||
// input and hold the write head (the same shape tail-freezing already takes).
|
||||
AudioSample processImpl(AudioSample in, const SpliceEvent* linked, bool write);
|
||||
// Re-derives the three quantities sourceRatio_ scales (fadeFrames_, stride_, corrFrames_)
|
||||
// from the window's fixed geometry.
|
||||
void applySourceRatio();
|
||||
|
||||
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
|
||||
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
|
||||
@@ -204,11 +216,13 @@ private:
|
||||
double posB_ = 0.0; // outgoing tap during a splice crossfade
|
||||
bool fading_ = false; // a splice crossfade is in flight
|
||||
std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeLen_)
|
||||
std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length (window_/4)
|
||||
std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length in OUTPUT frames: window_/4
|
||||
// source frames at sourceRatio_ (window_/4 at 1.0)
|
||||
std::int64_t fadeLen_ = 0; // LIVE crossfade length for the in-flight splice —
|
||||
// ratio-scaled at splice time so an up-shift's outgoing
|
||||
// tap can never drain into the writer mid-fade
|
||||
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
|
||||
// drift-scaled at splice time so the outgoing tap can
|
||||
// never reach the writer, or lap the ring's far end,
|
||||
// mid-fade
|
||||
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4 source frames)
|
||||
double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window)
|
||||
std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ retunes it
|
||||
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY
|
||||
@@ -216,15 +230,19 @@ private:
|
||||
// period-sized jump can never land back on a trigger and
|
||||
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At
|
||||
// jump_==jumpMax_ a DOWN-splice's correlation read comes
|
||||
// within ~41 frames of the write head (measured: the
|
||||
// exact ring/lag/corrFrames_ geometry at the product
|
||||
// window, worst case over every lag the search reaches) —
|
||||
// within dLow_+2-corrFrames_ frames of the write head at
|
||||
// the worst lag the search reaches (41 at a 44.1k 50 ms
|
||||
// window; never under 3, since corrFrames_ <= dLow_-1) —
|
||||
// real margin, not zero, but tight enough that widening
|
||||
// maxLag_, corrFrames_ or jumpMax_ without re-deriving
|
||||
// this bound risks reading unwritten ring content.
|
||||
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
|
||||
// the reference read forward from the tap stays behind
|
||||
// the writer by construction at an up-splice)
|
||||
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512
|
||||
// SAMPLED frames — 512*stride_ — so the reference read
|
||||
// forward from the tap stays behind the writer by
|
||||
// construction at an up-splice)
|
||||
std::int64_t stride_ = 1; // correlation sample + coarse-lag stride: the nearest whole
|
||||
// sourceRatio_, floored at 1
|
||||
double sourceRatio_ = 1.0; // source frames per output frame at unity
|
||||
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
|
||||
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
|
||||
std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
|
||||
|
||||
@@ -104,9 +104,9 @@ 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
|
||||
// of real source, so output frame 0 is source frame 0 regardless of window size.
|
||||
// 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;
|
||||
|
||||
// AHD pitch-modulation envelope, off by default (enabled=false -> offset always 0 ->
|
||||
@@ -254,9 +254,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;
|
||||
|
||||
@@ -309,6 +313,46 @@ 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. It is the one per-frame cost the ratio scales: a
|
||||
// Preserve voice writes up to ceil(kStretchRateMax x ratio) source frames into its ring per
|
||||
// output frame (StretchCursor::due). The splice search does NOT scale with it — pitch_shift
|
||||
// strides it to a matched rate's cost. A header claiming more than this is treated as corrupt
|
||||
// (buildSampleData).
|
||||
inline constexpr double kMaxSourceRateRatio = 32.0;
|
||||
|
||||
// THE refusal predicate for that bound: whether a file at `sourceRate` may play in a host
|
||||
// emitting `renderRate` (<= 0: not reported, so the file plays at its own rate). Named apart
|
||||
// from the build so a shell can tell the user WHY a load is silent — the file decoded fine.
|
||||
inline bool sourceRateSupported(int sourceRate, int renderRate) {
|
||||
if (sourceRate <= 0 || renderRate <= 0) return true;
|
||||
return static_cast<double>(sourceRate) / static_cast<double>(renderRate) <=
|
||||
kMaxSourceRateRatio;
|
||||
}
|
||||
|
||||
// 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; the
|
||||
// shifter's crossfade and search stride are what it converts to output frames
|
||||
// (PitchShifter::setSourceRateRatio). 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
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The playback rates the Preserve DSP is measured over, and therefore the only ones it
|
||||
// accepts. The ceiling also bounds a voice's per-output-frame feed loop (kMaxFeedPerFrame
|
||||
// source frames) — the RT-safety argument for feeding a variable count at all.
|
||||
// accepts. The ceiling, times the load-bounded source/host ratio, also bounds a voice's
|
||||
// per-output-frame feed loop (StretchCursor::due) — the RT-safety argument for feeding a
|
||||
// variable count at all.
|
||||
//
|
||||
// This range NARROWS the splice-cadence failure onto the source fundamental; it does not
|
||||
// eliminate it. A splice recurs every `pitch_shift.h`'s spliceJump() / |rate - shift| output
|
||||
@@ -23,14 +24,16 @@ namespace reasampler::instrument::engine {
|
||||
// 0.25 (-24 st), fixed-window jump (2205): interval 2205/3.75 ~= 588 vs period ~4*P ~= 785
|
||||
// frames (P ~= 196) — matches the originally observed 539-vs-785 failure. This range's ceiling
|
||||
// (2.0, not 4.0) raises the safe floor, it does not remove it: at rate 2.0, shift 0.25, interval
|
||||
// = 2205/1.75 = 1260 still produces measurable splice debris for any source period P > 315
|
||||
// frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -24 st is reachable from the
|
||||
// Pitch knob alone. pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter)
|
||||
// asserts this corner directly at P=500/600/700: energy outside the fundamental runs 7-21% there
|
||||
// against ~0% on an aligned control at the same rate/shift — zero-crossing period is NOT what it
|
||||
// checks, since splice debris fools that estimator into reading the wrong period on a render
|
||||
// whose fundamental is provably correct. (The pre-stretch rate-1.0 engine's floor by the same
|
||||
// inequality is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
|
||||
// = 2205/1.75 = 1260 puts any source period P > 315 frames (~140 Hz at 44.1k) inside the
|
||||
// inequality — bass/low-vocal material, and -24 st is reachable from the Pitch knob alone.
|
||||
// pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) measures this
|
||||
// corner directly at P=500/600/700 — by energy outside the fundamental, NOT zero-crossing
|
||||
// period, since splice debris fools that estimator into reading the wrong period on a render
|
||||
// whose fundamental is provably correct. Its raw 7-21% readings are the metric's own floor, and
|
||||
// the 0.2-0.5% excess once measured over that floor was not the cadence at all: it was this
|
||||
// corner's down-shift crossfade lapping the ring's far end, which splice()'s fade cap closed —
|
||||
// the excess now reads 0.00%. (The pre-stretch rate-1.0 engine's floor by the same inequality
|
||||
// is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
|
||||
//
|
||||
// The above derives the floor with jump == window(), which is only the FIXED-WINDOW half of
|
||||
// the story. Once a source period is known, spliceJump() is periodAlignedJump's answer instead
|
||||
@@ -77,9 +80,12 @@ 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)
|
||||
|
||||
// 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 +103,21 @@ 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. Required, so no
|
||||
// caller can drop the source/host conversion from the feed by omission.
|
||||
void start(std::int64_t frame, double sourceRateRatio) {
|
||||
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 +138,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
|
||||
@@ -145,10 +147,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;
|
||||
@@ -166,7 +168,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));
|
||||
@@ -244,9 +248,11 @@ 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_);
|
||||
shiftL_.setSourceRateRatio(sourceRatio_);
|
||||
shiftR_.setSourceRateRatio(sourceRatio_);
|
||||
// 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
|
||||
@@ -272,7 +278,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_
|
||||
|
||||
@@ -216,24 +216,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
|
||||
@@ -267,7 +270,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);
|
||||
}
|
||||
@@ -553,8 +556,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
|
||||
@@ -577,9 +580,10 @@ private:
|
||||
shiftL_.setShiftRatio(shift);
|
||||
if (stereoOut) shiftR_.setShiftRatio(shift);
|
||||
|
||||
// 0..kMaxFeedPerFrame source frames fall due this output frame. All but the LAST are
|
||||
// written without producing output; the last rides the ordinary 1-in-1-out
|
||||
// process(), so a rate of exactly 1.0 walks the pre-stretch code path unchanged.
|
||||
// 0..ceil(kStretchRateMax x sourceRatio_) source frames fall due this output frame
|
||||
// (StretchCursor::due). All but the LAST are written without producing output; the
|
||||
// last rides the ordinary 1-in-1-out process(), so a feed of exactly 1.0 walks the
|
||||
// pre-stretch code path unchanged.
|
||||
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
|
||||
// shift the output.
|
||||
const std::int64_t due = stretch_.due(stretchRate_);
|
||||
@@ -631,13 +635,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
|
||||
@@ -845,6 +849,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
|
||||
// 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 (!sourceRateSupported(decoded.sampleRate, renderRate)) return data;
|
||||
data.sampleRate = decoded.sampleRate;
|
||||
data.renderRate = renderRate > 0 ? renderRate : 0;
|
||||
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,
|
||||
|
||||
@@ -227,7 +227,7 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
|
||||
## Modules
|
||||
|
||||
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. A host-rate change while inactive does NOT re-decode: every stage time in the park (or in a publish that landed while inactive) and in the live block is in that rate's frames, so the resume re-stamps those in place (`restampHostRateLocked`) and falls back to the full reload only when the new rate refuses the file's (`sourceRateSupported`). Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
|
||||
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
|
||||
@@ -247,8 +247,9 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
|
||||
`LoadedInstrument`) and the automation lane's own state. All three are split out —
|
||||
`processor_snapshot.h` and `automation_channel.h`, on the same grounds `editor_interaction.h`
|
||||
was split out of `reasampler_editor.h` in this directory: neither is behaviour. What remains is
|
||||
the class declaration itself, a handful of lines over the ceiling since the live-contour block
|
||||
(`liveContours_` + `publishLiveContours`) landed beside `liveParams_` under the same ordering
|
||||
the class declaration itself, 630 lines (measured post-split) — over the ceiling since the
|
||||
live-contour block (`liveContours_` + `publishLiveContours`) landed beside `liveParams_` under
|
||||
the same ordering
|
||||
constraint. No further seam exists that isn't behaviour: every remaining aggregate-shaped chunk
|
||||
(the preview mailbox, the drain-slot triple) is read and written inline by `process()` on the
|
||||
audio thread, so splitting it into its own header would either duplicate the RT-discipline
|
||||
|
||||
@@ -100,8 +100,9 @@ void ReaSamplerEditor::resetParamControl(int id) {
|
||||
resetDeckParam(static_cast<instrument::ui::DeckParam>(id), params_.play);
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::liveSampleRate() const {
|
||||
return processor_ ? processor_->sampleRate() : 0.0;
|
||||
double ReaSamplerEditor::selectedSourceRate() const {
|
||||
const auto it = pcmCache_.find(selectedId_);
|
||||
return it != pcmCache_.end() ? static_cast<double>(it->second.sampleRate) : 0.0;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::previewVelocity01() const {
|
||||
|
||||
@@ -42,7 +42,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
|
||||
// so at most one of the two hit-tests below is ever live for the same click — both feed the
|
||||
// SAME arbitration slot below rather than either one getting its own check-order return.
|
||||
const double rate = liveSampleRate();
|
||||
const double rate = selectedSourceRate();
|
||||
const bool nodesLive =
|
||||
overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
|
||||
NodeHit envNodeHit;
|
||||
@@ -247,7 +247,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
|
||||
// set. The StageEnvelope was snapshotted at grab (dragStartEnv_) so the delta is
|
||||
// absolute.
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
const double rate = liveSampleRate();
|
||||
const double rate = selectedSourceRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const DragModifiers mods = dragModifiers();
|
||||
|
||||
@@ -63,7 +63,7 @@ StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds
|
||||
std::int64_t frames,
|
||||
std::int64_t startFrame) const {
|
||||
StageEnvelope env;
|
||||
const double rate = liveSampleRate();
|
||||
const double rate = selectedSourceRate();
|
||||
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
|
||||
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
|
||||
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
|
||||
|
||||
@@ -107,8 +107,14 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
if (!selectedId_.empty()) {
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_);
|
||||
// The waveform still draws (the editor decodes on its own), so without this a
|
||||
// refused capture would look loaded and simply never sound.
|
||||
if (processor_->sourceRateRefused())
|
||||
title += " -- " + sourceRateRefusalText() + ", not played";
|
||||
title += "]";
|
||||
}
|
||||
else if (samples_.empty()) title += " [bank empty]";
|
||||
else title += " [pick a capture]";
|
||||
} else {
|
||||
|
||||
@@ -388,7 +388,7 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
|
||||
paintSplineOverlay(bmp, waveArea);
|
||||
return;
|
||||
}
|
||||
const double rate = liveSampleRate();
|
||||
const double rate = selectedSourceRate();
|
||||
if (rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
||||
|
||||
@@ -438,12 +438,12 @@ const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor(
|
||||
|
||||
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
|
||||
auto it = pcmCache_.find(sampleId);
|
||||
if (it != pcmCache_.end()) return it->second;
|
||||
if (it != pcmCache_.end()) return it->second.frames;
|
||||
|
||||
// Every failure path caches an empty vector so a broken/missing file is not re-decoded on
|
||||
// Every failure path caches an empty entry so a broken/missing file is not re-decoded on
|
||||
// every paint. Keyed by id (width-independent) — the thumbnail bins this at whatever
|
||||
// width, the snap scans it directly.
|
||||
std::vector<AudioSample> mono;
|
||||
MonoPcm mono;
|
||||
const std::string relativePath = samplePathFor(sampleId);
|
||||
if (!relativePath.empty()) {
|
||||
const std::string projectDir = processor_->bridge().activeProjectDir();
|
||||
@@ -453,11 +453,12 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
||||
if (layout.valid) {
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||
mono = downmixToMono(interleaved, layout.channelCount);
|
||||
mono.frames = downmixToMono(interleaved, layout.channelCount);
|
||||
mono.sampleRate = static_cast<int>(layout.sampleRate);
|
||||
}
|
||||
}
|
||||
auto ins = pcmCache_.emplace(sampleId, std::move(mono));
|
||||
return ins.first->second;
|
||||
return ins.first->second.frames;
|
||||
}
|
||||
|
||||
const Envelope& ReaSamplerEditor::heroEnvelope(std::size_t bins, int laneCount) {
|
||||
|
||||
@@ -114,7 +114,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
if (!sourceEntry) return fail("the loaded capture has no resolvable file");
|
||||
const SelectedSample* source = &sourceEntry->ref;
|
||||
|
||||
const int sampleRate = static_cast<int>(processor.sampleRate());
|
||||
const int sampleRate = processor.hostRenderRate();
|
||||
if (sampleRate <= 0) return fail("the host has not reported a sample rate yet");
|
||||
|
||||
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
|
||||
@@ -128,14 +128,16 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
|
||||
// The snapshot comes first: the default program's window is derived from the sound it
|
||||
// carries (a Gate release, a Trigger play span), not from a constant.
|
||||
std::optional<SampleData> snapshot = processor.bakeSnapshot();
|
||||
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
|
||||
BuiltCapture built = processor.bakeSnapshot(sampleRate);
|
||||
if (built.rateRefused) return fail("cannot render: " + sourceRateRefusalText());
|
||||
if (!built.sample) return fail("the loaded capture could not be decoded for the render");
|
||||
SampleData& snapshot = *built.sample;
|
||||
|
||||
// The note fires at the velocity the user has been auditioning at: three velocity curves
|
||||
// are live, so a sound dialed at 127 does not bake as one dialed at 40.
|
||||
const Velocity velocity = Velocity::of(processor.previewVelocity());
|
||||
const instrument::bake::PlannedBake planned = planBake(
|
||||
resolveNote(defaultBakeProgram(*snapshot, sampleRate, dialed.bakeHold, velocity),
|
||||
resolveNote(defaultBakeProgram(snapshot, sampleRate, dialed.bakeHold, velocity),
|
||||
*tempo),
|
||||
sampleRate, rootNote);
|
||||
if (!planned.plan) {
|
||||
@@ -146,7 +148,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
|
||||
const instrument::bake::BakePlan& plan = *planned.plan;
|
||||
|
||||
const instrument::bake::BakeAudio audio =
|
||||
renderBake(std::move(*snapshot), plan, processor.masterGainLinear(),
|
||||
renderBake(std::move(snapshot), plan, processor.masterGainLinear(),
|
||||
dialed.limiterEnabled);
|
||||
if (audio.empty()) return fail("the offline pass produced no audio");
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -79,18 +80,26 @@ std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
|
||||
|
||||
// The ONE decode + build the reload and the bake snapshot share, so a baked render can
|
||||
// never be built from a different fold than the one the user is hearing.
|
||||
std::optional<SampleData> buildFromRef(const SelectedSample& sel,
|
||||
const InstrumentParams& params,
|
||||
const std::string& projectDir, ChannelMode mode) {
|
||||
BuiltCapture buildFromRef(const SelectedSample& sel,
|
||||
const InstrumentParams& params,
|
||||
const std::string& projectDir,
|
||||
ChannelMode mode, int renderRate) {
|
||||
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
|
||||
if (!pcm) return std::nullopt;
|
||||
SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm));
|
||||
if (!sample.playable()) return std::nullopt;
|
||||
return sample;
|
||||
if (!pcm) return {};
|
||||
if (!sourceRateSupported(pcm->sampleRate, renderRate)) return {std::nullopt, true};
|
||||
SampleData sample =
|
||||
buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate);
|
||||
if (!sample.playable()) return {};
|
||||
return {std::move(sample), false};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string sourceRateRefusalText() {
|
||||
return "sample rate over " + std::to_string(static_cast<int>(kMaxSourceRateRatio)) +
|
||||
"x the host's";
|
||||
}
|
||||
|
||||
std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the
|
||||
// retired-slot free is single-writer; never taken on the audio thread.
|
||||
@@ -125,6 +134,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
SampleData sample;
|
||||
bool havePlayable = false;
|
||||
bool rateRefused = false;
|
||||
|
||||
// 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
|
||||
// repitched from its effective root. No first-sample fallback: an empty selection
|
||||
@@ -142,9 +152,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
channelModeExplicit_);
|
||||
mode = channelMode_;
|
||||
}
|
||||
if (std::optional<SampleData> decoded =
|
||||
buildFromRef(*sel, params, projectDir, mode)) {
|
||||
sample = std::move(*decoded);
|
||||
BuiltCapture decoded = buildFromRef(*sel, params, projectDir, mode, hostRenderRate());
|
||||
rateRefused = decoded.rateRefused;
|
||||
if (decoded.sample) {
|
||||
sample = std::move(*decoded.sample);
|
||||
havePlayable = true;
|
||||
// Point the built snapshot at the instance's ONE engine-facing block and seed the
|
||||
// MODEL block from the very PlayParams the voices latch, so an untouched knob folds
|
||||
@@ -157,12 +168,13 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// BEFORE the publish: the publish is what makes the audio thread re-merge, and the
|
||||
// merge resolves every held automation stage time against this rate. Stored after,
|
||||
// a merge in the window between them would resolve them against the previous
|
||||
// capture's rate — or, on the first-ever build, against 0, where secondsToFrames
|
||||
// build's rate — or, on the first-ever build, against 0, where secondsToFrames
|
||||
// collapses every automated envelope stage to zero frames.
|
||||
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
||||
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed);
|
||||
{
|
||||
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
|
||||
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
|
||||
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here, the
|
||||
// same nesting restampHostRateLocked takes under its own reloadMutex_-held
|
||||
// callers; publishLiveParams never holds reloadMutex_.
|
||||
std::lock_guard<std::mutex> lp(livePublishMutex_);
|
||||
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
|
||||
// The model's contours, so a reload-tier contour edit (a point added, deleted or
|
||||
@@ -174,6 +186,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
}
|
||||
}
|
||||
|
||||
sourceRateRefused_.store(rateRefused, std::memory_order_relaxed);
|
||||
if (havePlayable) built = buildInstrumentLocked(std::move(sample));
|
||||
|
||||
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
|
||||
@@ -194,9 +207,9 @@ std::string ReaSamplerProcessor::usageInstanceGuid() {
|
||||
return instanceGuid_;
|
||||
}
|
||||
|
||||
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
|
||||
BuiltCapture ReaSamplerProcessor::bakeSnapshot(int renderRate) {
|
||||
const std::string selId = selectedSampleId();
|
||||
if (selId.empty()) return std::nullopt;
|
||||
if (selId.empty()) return {};
|
||||
const InstrumentParams params = instrumentParams();
|
||||
SampleRefs refs;
|
||||
{
|
||||
@@ -204,10 +217,10 @@ std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
|
||||
refs = sampleRefs_;
|
||||
}
|
||||
const SelectedSample* sel = findRef(refs, selId);
|
||||
if (!sel) return std::nullopt;
|
||||
if (!sel) return {};
|
||||
// No live block is attached: the bake renders the stored parameter set, which is what
|
||||
// every knob has already written, rather than whatever the audio thread is observing.
|
||||
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode());
|
||||
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode(), renderRate);
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
|
||||
@@ -333,13 +346,8 @@ ReaSamplerProcessor::buildInstrumentLocked(SampleData sample) {
|
||||
builtVoiceMode = voiceMode_;
|
||||
builtMonoTrigger = monoTrigger_;
|
||||
}
|
||||
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs), pre-sized
|
||||
// here so process()-time note-on never allocates. Floored at 2 so a valid window is always
|
||||
// a real ring, covering a pathological host rate <= 0 too. Re-derived per build, so a
|
||||
// reactivation after the host changed its rate gets a ring sized for the new one.
|
||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
// Pre-sized here so process()-time note-on never allocates.
|
||||
const std::int64_t preserveWindow = preserveWindowFrames(sample);
|
||||
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||
return std::make_unique<LoadedInstrument>(
|
||||
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
@@ -363,10 +371,24 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
|
||||
// Off the audio thread (setActive only). The reactivation half of the lifetime split: the
|
||||
// voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
// Every frame count in a built sample — and in the live block published beside it — was
|
||||
// resolved for the host rate of its build, and setupProcessing may have changed that rate
|
||||
// while inactive (or a setState load ran before the host reported one). Only the resolve
|
||||
// depends on it, never the decode, so both paths below re-stamp rather than re-read a file
|
||||
// that may since have gone missing.
|
||||
const int renderRate = hostRenderRate();
|
||||
// A publish that landed while inactive (setState's reload, a bake's adopt) IS the
|
||||
// activation state — its voices have never rendered, and it has already superseded the
|
||||
// park. Rebuilding here would displace a correct instrument into the drain slot.
|
||||
if (live_.load(std::memory_order_acquire)) return true;
|
||||
// park. Rebuilding here at an unchanged rate would displace a correct instrument into the
|
||||
// drain slot; at a changed one the rebuild is the re-stamp, and there is nothing sounding
|
||||
// to displace.
|
||||
if (const LoadedInstrument* cur = live_.load(std::memory_order_acquire)) {
|
||||
if (cur->sample.renderRate == renderRate) return true;
|
||||
SampleData restamped = cur->sample;
|
||||
if (!restampHostRateLocked(restamped, renderRate)) return false;
|
||||
publishBuiltLocked(buildInstrumentLocked(std::move(restamped)));
|
||||
return true;
|
||||
}
|
||||
if (!dormantSample_) return false;
|
||||
|
||||
// The activation is still where a bank change made with NO EDITOR OPEN is picked up:
|
||||
@@ -396,6 +418,7 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
|
||||
// and refreshing the refs without re-decoding would leave the table naming one file while
|
||||
// the voices play another. Hand back to the full reload, which decodes the new one.
|
||||
if (sourceMoved) return false;
|
||||
if (!restampHostRateLocked(*dormantSample_, renderRate)) return false;
|
||||
|
||||
// MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
|
||||
// second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a
|
||||
@@ -410,6 +433,23 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerProcessor::restampHostRateLocked(SampleData& sample, int renderRate) {
|
||||
if (sample.renderRate == renderRate) return true;
|
||||
if (!sourceRateSupported(sample.sampleRate, renderRate)) return false;
|
||||
sample.renderRate = renderRate;
|
||||
// The model's stored seconds, which is exactly what reloadInstrument resolves (via
|
||||
// resolveCapture) — so the re-stamp and a disk reload land on the same frames.
|
||||
sample.play = resolvePlay(instrumentParams().play, sample.outputRate());
|
||||
// BEFORE the caller's publish, for the reason reloadInstrument gives at the same store.
|
||||
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed);
|
||||
std::lock_guard<std::mutex> lp(livePublishMutex_);
|
||||
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
|
||||
// Symmetric with reloadInstrument's own publish pair: a re-stamp is a rebuild too, so the
|
||||
// drain's sounding voices need the model's contours refreshed beside the live params.
|
||||
liveContours_.publish(instrument::engine::foldContours(sample.play));
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::retireIdleDrain() {
|
||||
// Cheap early-out before the lock: 0 means "no drain, or it still sounds".
|
||||
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
|
||||
|
||||
@@ -7,11 +7,27 @@
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// What the editor and the bake print for a refused file rate (sourceRateSupported), so the
|
||||
// limit is spelled in one place.
|
||||
std::string sourceRateRefusalText();
|
||||
|
||||
// The dialed sound as plain data for the offline bake (ReaSamplerProcessor::bakeSnapshot):
|
||||
// empty when nothing is loaded or the WAV is unreadable; `rateRefused` distinguishes a
|
||||
// decoded file whose rate is refused against the host's from that plain no-play.
|
||||
struct BuiltCapture {
|
||||
std::optional<SampleData> sample;
|
||||
// The file decoded, but sourceRateSupported (play_params.h) refuses its rate against
|
||||
// the host's — a distinct failure the user is told about, not an unreadable file.
|
||||
bool rateRefused = false;
|
||||
};
|
||||
|
||||
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
|
||||
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
|
||||
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
|
||||
|
||||
@@ -415,8 +415,13 @@ private:
|
||||
|
||||
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
|
||||
int effectiveRoot() const;
|
||||
// The live sample rate from the bridge, or 0 when unavailable (caller guards).
|
||||
double liveSampleRate() const;
|
||||
// The selected capture's OWN rate — the time base that maps its waveform frames to the
|
||||
// seconds a staged envelope stores — or 0 when nothing is decoded (caller guards). Not the
|
||||
// host's: the voice plays a source frame at the source's rate whatever the host runs at.
|
||||
// Read from the same decode the frames came from (monoPcmFor, which every caller has already
|
||||
// run for its frame count), so the two cannot disagree — the processor's own build can
|
||||
// fail while the waveform still draws.
|
||||
double selectedSourceRate() const;
|
||||
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
||||
double previewVelocity01() const;
|
||||
// The bake Hold division as a 0..1 knob position, via the pure bake_hold map.
|
||||
@@ -595,8 +600,13 @@ private:
|
||||
std::unordered_map<std::string, Envelope> thumbCache_;
|
||||
|
||||
// Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope
|
||||
// binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh.
|
||||
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
|
||||
// binning + zero-crossing snap, and carries the file's own rate beside the frames it is the
|
||||
// time base of (selectedSourceRate). Cleared alongside thumbCache_ on refresh.
|
||||
struct MonoPcm {
|
||||
std::vector<AudioSample> frames;
|
||||
int sampleRate = 0; // 0 = nothing decoded
|
||||
};
|
||||
std::unordered_map<std::string, MonoPcm> pcmCache_;
|
||||
|
||||
// The single-slot interleaved-PCM cache behind channelPcmFor (see its note on why this
|
||||
// is not keyed into pcmCache_). Cleared alongside pcmCache_ on refresh.
|
||||
|
||||
@@ -148,11 +148,14 @@ public:
|
||||
std::string reloadInstrument();
|
||||
|
||||
// The dialed sound as plain data for the offline bake: the SAME refs resolve + decode +
|
||||
// build reloadInstrument runs, with no live block attached. Rebuilt rather than copied
|
||||
// off the live snapshot because a tier-3 live edit leaves that snapshot's own play
|
||||
// params stale on purpose — copying it would bake the pre-drag values. nullopt when
|
||||
// nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O).
|
||||
std::optional<SampleData> bakeSnapshot();
|
||||
// build reloadInstrument runs, with no live block attached, resolved for `renderRate` —
|
||||
// the rate the bake's plan renders at, passed in so the two cannot disagree. Rebuilt
|
||||
// rather than copied off the live snapshot because a tier-3 live edit leaves that
|
||||
// snapshot's own play params stale on purpose — copying it would bake the pre-drag values.
|
||||
// Empty when nothing is loaded, the WAV is unreadable, or its rate is refused (then
|
||||
// `rateRefused` says so). Off the audio thread (file I/O). See BuiltCapture
|
||||
// (processor_snapshot.h).
|
||||
BuiltCapture bakeSnapshot(int renderRate);
|
||||
|
||||
// Adopt a landed bake in ONE act: the new capture becomes this instance's ref and
|
||||
// selection, the parameter set and master gain go neutral, and a single reload
|
||||
@@ -180,9 +183,17 @@ public:
|
||||
// editor borrows it (outlives the editor).
|
||||
ReaperBridge& bridge() { return bridge_; }
|
||||
|
||||
// The live host sample rate latched from setupProcessing; the editor's envelope overlay
|
||||
// shares this time base. 0.0 before setupProcessing runs.
|
||||
// The live host sample rate latched from setupProcessing. 0.0 before setupProcessing runs.
|
||||
double sampleRate() const { return sampleRate_; }
|
||||
// The same rate as the whole-Hz int every SampleData build and bake plan takes, so the
|
||||
// reload and the bake round it one way. 0 before setupProcessing runs.
|
||||
int hostRenderRate() const {
|
||||
return sampleRate_ > 0.0 ? static_cast<int>(sampleRate_ + 0.5) : 0;
|
||||
}
|
||||
// True while the selected capture is silent because the last reload refused its file's
|
||||
// rate (BuiltCapture::rateRefused). The editor's title band names it
|
||||
// (sourceRateRefusalText).
|
||||
bool sourceRateRefused() const { return sourceRateRefused_.load(std::memory_order_relaxed); }
|
||||
// The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
|
||||
// Empty id -> silence, no first-sample fallback.
|
||||
std::string selectedSampleId();
|
||||
@@ -336,12 +347,20 @@ private:
|
||||
// The reactivation half of the activation/decode lifetime split (see dormantSample_):
|
||||
// folds the live bank blob into the refs, rebuilds the voice state around the parked
|
||||
// sample, publishes it through the same drain-slot swap, and republishes usage. True also
|
||||
// when a publish landed while inactive, which needs no rebuild. False when there is nothing
|
||||
// to activate from, or when that fold moved what the park was decoded from — the caller
|
||||
// then falls back to a full reload, which decodes the new file and is also where the pre-v10
|
||||
// legacy lift lives. Off the audio thread only.
|
||||
// when a publish landed while inactive, which needs no rebuild. A park or publish resolved
|
||||
// for a host rate that has since changed is re-stamped in place (restampHostRateLocked)
|
||||
// rather than re-decoded. False when there is nothing to activate from, when the new host
|
||||
// rate refuses the file's, or when that fold moved what the park was decoded from — the
|
||||
// caller then falls back to a full reload, which decodes the new file and is also where the
|
||||
// pre-v10 legacy lift lives. Off the audio thread only.
|
||||
bool resumeDormantInstrument();
|
||||
|
||||
// Re-resolves `sample` for a new host rate without touching its PCM: the render rate, the
|
||||
// stage times (from the model's stored seconds, exactly as a reload resolves them) and the
|
||||
// live seed published beside it. False, leaving `sample` untouched, when the new rate
|
||||
// refuses the file's. Requires reloadMutex_ held.
|
||||
bool restampHostRateLocked(SampleData& sample, int renderRate);
|
||||
|
||||
// Builds a playable snapshot around `sample` at the current voice-system parameters and
|
||||
// host rate, stamped with a fresh generation. Requires reloadMutex_ held; the ONE
|
||||
// construction site shared by the reload, the voice-param rebuild and the reactivation.
|
||||
@@ -414,22 +433,23 @@ private:
|
||||
// The host lane's state and its release protocol; automation_channel.h owns the mechanism and
|
||||
// this directory's CLAUDE.md the authority model it enforces.
|
||||
AutomationChannel automation_;
|
||||
// Serializes the writer sites of liveParams_ (reloadInstrument, publishLiveParams) and of
|
||||
// liveContours_ (reloadInstrument, publishLiveContours) only — separate from reloadMutex_ so
|
||||
// a drag's publish never blocks behind a reload's WAV decode. The audio thread never takes
|
||||
// Serializes the writer sites of liveParams_ (reloadInstrument, publishLiveParams,
|
||||
// restampHostRateLocked) and of liveContours_ (reloadInstrument, publishLiveContours,
|
||||
// restampHostRateLocked) only — separate from reloadMutex_ so a drag's publish never blocks
|
||||
// behind a reload's WAV decode. The audio thread never takes
|
||||
// this; it only reads via the seqlock's lock-free retry.
|
||||
std::mutex livePublishMutex_;
|
||||
// The rate the loaded capture was decoded/built at, so a live republish resolves the
|
||||
// stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing
|
||||
// built yet.
|
||||
// The output rate the loaded capture's play params were resolved at (SampleData::
|
||||
// outputRate — the host's), so a live republish resolves the stored wall-clock seconds to
|
||||
// exactly the frames the built SampleData carries. 0 = nothing built yet.
|
||||
//
|
||||
// ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload
|
||||
// publishes the new block before installing the new instrument. Swapping to a capture at a
|
||||
// different rate therefore hands drain voices still ringing from the old-rate capture
|
||||
// envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap).
|
||||
// Unavoidable while one block sits above every snapshot, and it touches a release tail
|
||||
// only.
|
||||
// ONE BLOCK, ONE RATE: one block sits above every snapshot, so a drain voice reads frame
|
||||
// counts resolved at whatever this last became — harmless while it is the HOST rate for
|
||||
// every capture. The one residue is a build made before the host reported a rate, which
|
||||
// resolves at the capture's own and is re-stamped by the activation.
|
||||
std::atomic<int> builtSampleRate_{0};
|
||||
// See sourceRateRefused(). Written by reloadInstrument only.
|
||||
std::atomic<bool> sourceRateRefused_{false};
|
||||
|
||||
// --- The audio-thread handoff (drain slot) ---
|
||||
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
|
||||
@@ -563,7 +583,8 @@ private:
|
||||
std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired
|
||||
std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired
|
||||
|
||||
// Latched from setupProcessing; 0.0 is explicitly invalid (reloadInstrument guards on it).
|
||||
// Latched from setupProcessing; 0.0 = not reported yet (a build then renders at the
|
||||
// capture's own rate until resumeDormantInstrument re-stamps it for the reported one).
|
||||
double sampleRate_ = 0.0;
|
||||
Steinberg::int32 maxBlockSize_ = 4096;
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "tone_metrics.h"
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::instrument::bake;
|
||||
@@ -488,6 +491,54 @@ int main() {
|
||||
CHECK(hardPeak > softPeak * 2.0);
|
||||
}
|
||||
|
||||
// ============================ SOURCE RATE =======================================
|
||||
|
||||
// --- A capture at another rate bakes at its own pitch and its own wall-clock length --
|
||||
// 441 Hz for one second of a 44.1k file, baked at 48k: the window must hold 48000 frames
|
||||
// of sound (not the source's 44100), and the print must be 441 Hz at the render rate.
|
||||
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
constexpr int kFileRate = 44100;
|
||||
constexpr double kHz = 441.0;
|
||||
SampleData s;
|
||||
s.frames.resize(static_cast<std::size_t>(kFileRate));
|
||||
for (std::size_t i = 0; i < s.frames.size(); ++i) {
|
||||
s.frames[i] = static_cast<float>(
|
||||
0.5 * std::sin(2.0 * 3.14159265358979323846 * kHz * static_cast<double>(i) /
|
||||
kFileRate));
|
||||
}
|
||||
s.sampleRate = kFileRate;
|
||||
s.renderRate = kRate;
|
||||
s.rootNote = 60;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = engine;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
|
||||
CHECK(derivedFrames(s) == kRate + kPad);
|
||||
const BakeAudio audio = bakeWith(s, 0.0);
|
||||
CHECK(peakAt(audio, kRate - 1000, kRate - 10) > 0.4); // still sounding near 1 s
|
||||
CHECK(lastSoundingFrame(audio) < audio.frameCount() - 1); // and done inside the window
|
||||
|
||||
std::vector<double> mono(static_cast<std::size_t>(audio.frameCount()));
|
||||
for (std::size_t f = 0; f < mono.size(); ++f) {
|
||||
mono[f] = audio.interleaved[f * static_cast<std::size_t>(audio.channelCount)];
|
||||
}
|
||||
const double want = static_cast<double>(kRate) / kHz;
|
||||
const double got = reasampler::test_support::autocorrelationPeriod(
|
||||
mono, 10000, 8000, static_cast<std::int64_t>(want * 0.5),
|
||||
static_cast<std::int64_t>(want * 1.7));
|
||||
CHECK(std::fabs(got - want) < 0.005 * want);
|
||||
}
|
||||
|
||||
// --- A snapshot resolved for another output rate is refused, not printed off-pitch ---
|
||||
{
|
||||
SampleData s = dcSample(24000);
|
||||
s.renderRate = 44100; // resolved for 44.1k; the plan renders at 48k
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0));
|
||||
CHECK(plan.has_value());
|
||||
if (plan) CHECK(renderBake(s, *plan, kUnity, kNoLimiter).empty());
|
||||
}
|
||||
|
||||
// ============================== REFUSALS ========================================
|
||||
|
||||
// --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so --------
|
||||
|
||||
@@ -79,7 +79,7 @@ static void testDetectedPeriodReachesAndMovesThePreserveRender() {
|
||||
ref.relativePath = "b/a.wav";
|
||||
ref.rootNote = 60;
|
||||
SampleData on = buildSampleData(resolveCapture(ref, InstrumentParams{}),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(std::fabs(on.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz, real detection
|
||||
|
||||
on.play.adsr = flatAdsr();
|
||||
|
||||
+82
-19
@@ -748,6 +748,65 @@ static void testStretchAndShiftComposeSafely() {
|
||||
}
|
||||
}
|
||||
|
||||
// A down-splice's crossfade walks the OUTGOING tap away from the writer at (feedRate - shift)
|
||||
// source frames per output frame, toward the ring's far end — a quarter window past the
|
||||
// trigger. The live fade must end before that walk laps the ring, both at a matched rate (a fast
|
||||
// feed under a down-shift) and at a high source ratio, where the fade is counted in output
|
||||
// frames while the tap moves in source frames. The first case per ratio is also checked for
|
||||
// its nominal length: window/4 source frames' worth of output time.
|
||||
static void testDownSpliceFadeNeverLapsTheRingsFarEnd() {
|
||||
const std::int64_t w = 2205;
|
||||
const double ringLen = 2.0 * static_cast<double>(w);
|
||||
const double dHigh = ringLen - static_cast<double>(w / 4);
|
||||
struct Case { double feed, shift, sourceRatio; std::int64_t nominalFade; };
|
||||
const Case cases[] = {
|
||||
{1.0, 0.5, 1.0, w / 4}, // -12 st, matched: room to spare, the full fade
|
||||
{2.0, 1.0, 1.0, -1}, // Rate 200 %, no shift: recedes one frame per frame
|
||||
{2.0, 0.25, 1.0, -1}, // Rate 200 %, -24 st
|
||||
{4.0, 2.0, 4.0, 138}, // -12 st at a 4x source ratio: round(551 / 4)
|
||||
{4.0, 1.0, 4.0, -1}, // -24 st at a 4x source ratio
|
||||
};
|
||||
std::vector<AudioSample> src(400000);
|
||||
for (std::size_t i = 0; i < src.size(); ++i) {
|
||||
src[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * static_cast<double>(i) / 196.37));
|
||||
}
|
||||
for (const Case& c : cases) {
|
||||
PitchShifter ps;
|
||||
ps.configure(w);
|
||||
ps.prime(src.data(), w);
|
||||
ps.setShiftRatio(c.shift);
|
||||
ps.setFeedRate(c.feed);
|
||||
ps.setSourceRateRatio(c.sourceRatio);
|
||||
std::size_t pos = static_cast<std::size_t>(w);
|
||||
double debt = 0.0;
|
||||
SpliceEvent first;
|
||||
for (int i = 0; i < 40000 && !first.fired; ++i) {
|
||||
debt += c.feed;
|
||||
const int due = static_cast<int>(debt);
|
||||
debt -= static_cast<double>(due);
|
||||
for (int k = 0; k + 1 < due; ++k) ps.writeFrame(src[pos++]);
|
||||
if (due > 0) ps.process(src[pos++]);
|
||||
else ps.processNoInput();
|
||||
first = ps.lastSplice();
|
||||
}
|
||||
CHECK(first.fired);
|
||||
CHECK(first.jump < 0); // a DOWN-splice: the tap jumped toward the writer
|
||||
const double recede = c.feed - c.shift;
|
||||
// Worst case: the trigger overshot dHigh by one frame's drift, and the integer feed runs
|
||||
// up to one frame ahead of its average. One frame short of the ring is the interpolator's
|
||||
// read-ahead.
|
||||
const double worstDelay =
|
||||
dHigh + recede + recede * static_cast<double>(first.fadeLen) + 1.0;
|
||||
if (!(worstDelay < ringLen)) {
|
||||
std::printf(" feed %.2f shift %.2f ratio %.1f: fade %lld reaches delay %.1f of %.0f\n",
|
||||
c.feed, c.shift, c.sourceRatio, static_cast<long long>(first.fadeLen),
|
||||
worstDelay, ringLen);
|
||||
}
|
||||
CHECK(worstDelay < ringLen);
|
||||
if (c.nominalFade > 0) CHECK(first.fadeLen == c.nominalFade);
|
||||
}
|
||||
}
|
||||
|
||||
// The [0.5, 2.0] rate bound (time_stretch.h) narrows the splice-cadence failure onto the
|
||||
// source fundamental rather than eliminating it. At rate 2.0, shift 0.25 (-24 st) — both
|
||||
// inside the shipped range — the header's own derivation puts the safe-source floor at a
|
||||
@@ -757,18 +816,18 @@ static void testStretchAndShiftComposeSafely() {
|
||||
// render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike,
|
||||
// while the zero-crossing estimator read 23% flat — splice debris adds spurious crossings
|
||||
// the count cannot tell from a real detune. Energy outside the fundamental is measured here
|
||||
// (same rate/shift/source, this file's own metric parameters) at 7.31% / 14.41% / 21.22% for
|
||||
// P=500/600/700, against 0.10% on an alignable control (P=200, below the safe floor) at the
|
||||
// (same rate/shift/source, this file's own metric parameters) at 7.08% / 13.90% / 20.92% for
|
||||
// P=500/600/700, against 0.04% on an alignable control (P=200, below the safe floor) at the
|
||||
// same rate and shift. Those readings are stable and are what the bounds below hold.
|
||||
//
|
||||
// **CORRECTED — what those three readings MEAN.** They were once read as the corner's damage.
|
||||
// They are almost entirely the metric's own floor: an ideal tone at the same want-period,
|
||||
// measured identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because
|
||||
// a long period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6%
|
||||
// band. The corner's real EXCESS over that floor is 0.23% / 0.51% / 0.30% — small, real, and
|
||||
// nothing like the headline numbers. The alignable control's 0.10% is genuinely near-zero only
|
||||
// because its want-period is short enough to have almost no floor. The bounds below are kept
|
||||
// as a stable regression tripwire on the raw readings; read the excess, not the reading.
|
||||
// They are the metric's own floor: an ideal tone at the same want-period, measured
|
||||
// identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because a long
|
||||
// period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6% band.
|
||||
// The corner's EXCESS over that floor once measured 0.23% / 0.51% / 0.30% (control 0.10%); that
|
||||
// residue was the down-shift crossfade (recede 1.75 frames/frame) lapping the ring's far end,
|
||||
// and splice()'s far-end fade cap took it to 0.00%. The bounds below are kept as a stable
|
||||
// regression tripwire on the raw readings; read the excess, not the reading.
|
||||
//
|
||||
// This measures the FIXED-WINDOW path — no source period is set, which is what a capture with
|
||||
// no single period (percussive, polyphonic, noise) gets. What the same corner does once the
|
||||
@@ -782,8 +841,8 @@ static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() {
|
||||
const std::size_t from = 20000, len = 32768;
|
||||
|
||||
// Below the safe floor (P > 315 frames): per the header's CORRECTED note, this raw reading
|
||||
// is mostly the metric's own mainlobe-leakage floor, not cadence damage (the real excess is
|
||||
// ~0.2-0.5%, asserted in testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
|
||||
// is the metric's own mainlobe-leakage floor, not cadence damage (the excess is asserted in
|
||||
// testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
|
||||
// below are a RAW-READING STABILITY TRIPWIRE, not a discriminator — they catch a large swing
|
||||
// in the raw measurement; the ceiling (30%) is a generous margin above the highest measured
|
||||
// value, there to catch a much worse regression rather than to chase today's exact number.
|
||||
@@ -1001,13 +1060,15 @@ static void testTwentyNineHertzAtRateTwoKeepsItsPitch() {
|
||||
|
||||
// The cadence corner (rate 2.0, -24 st, source periods above the 315-frame safe floor) is the
|
||||
// OTHER mechanism — a splice landing inside a single perceived cycle. Measured against the
|
||||
// metric's own floor, PSOLA moves it by nothing: 0.23/0.51/0.30% excess becomes 0.24/0.49/0.30%.
|
||||
// metric's own floor, PSOLA moves it by nothing: 0.00% excess either way (it was 0.23/0.51/0.30%
|
||||
// both ways until the far-end fade cap removed the lapped crossfade behind it).
|
||||
//
|
||||
// That is not a shortfall, it is what the corner turned out to be. Correcting the previous
|
||||
// test's reading (see its comment) shrank the corner from a 7-21% headline to a sub-1% excess,
|
||||
// which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each splice
|
||||
// land in phase, and these splices already did; what it cannot do is make them less frequent.
|
||||
// So this asserts NO REGRESSION, not an improvement, and says so rather than claiming one.
|
||||
// test's reading (see its comment) shrank the corner from a 7-21% headline to no measurable
|
||||
// excess, which leaves PSOLA nothing to recover there — a pitch-synchronous jump makes each
|
||||
// splice land in phase, and these splices already did; what it cannot do is make them less
|
||||
// frequent. So this asserts NO REGRESSION, not an improvement, and says so rather than
|
||||
// claiming one.
|
||||
static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
|
||||
using reasampler::test_support::energyOutsideFundamentalPercent;
|
||||
const std::int64_t w = 2205;
|
||||
@@ -1031,7 +1092,8 @@ static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
|
||||
std::printf(" [cadence corner, PSOLA] period %.0f (want %.0f, metric floor %.2f%%): "
|
||||
"excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor,
|
||||
pctOn - floor);
|
||||
CHECK(pctOn - floor < 1.0); // the corner's real excess, PSOLA or not
|
||||
CHECK(pctOff - floor < 0.1); // the corner's real excess, PSOLA or not
|
||||
CHECK(pctOn - floor < 0.1); // measured 0.00% either way
|
||||
CHECK(pctOn < pctOff + 0.05); // and PSOLA costs it nothing
|
||||
}
|
||||
}
|
||||
@@ -1045,7 +1107,7 @@ static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
|
||||
//
|
||||
// MEASURED (Debug, this machine): metric floor 55.86% (P=1470's want-period of 5880 fr under a
|
||||
// 32768-frame segment leaks a lot of mainlobe, same effect as the P=500-700 rows, just larger),
|
||||
// fixed-window excess 18.52%, pitch-synchronous excess 0.00%. The faster cadence does NOT
|
||||
// fixed-window excess 18.61%, pitch-synchronous excess 0.00%. The faster cadence does NOT
|
||||
// regress this corner — every splice at n=1 lands exactly one source period away, so despite
|
||||
// firing 1.5x as often each one is phase-perfect rather than merely aligned-on-average, and the
|
||||
// corner clears rather than worsens. Recorded as read, not tuned: if a future change moves
|
||||
@@ -1073,7 +1135,7 @@ static void testCadenceCollapseBandAtThirtyHertzUnderPitchSynchronousSplice() {
|
||||
"excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor, pctOn - floor);
|
||||
CHECK(pctOn - floor < 1.0); // measured 0.00%: phase-perfect at n=1, not merely aligned
|
||||
// Vacuity guard (shape of testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown's :961): the
|
||||
// FIXED-WINDOW arm is asserted too (measured 18.52% excess), so a setSourcePeriod that
|
||||
// FIXED-WINDOW arm is asserted too (measured 18.61% excess), so a setSourcePeriod that
|
||||
// silently did nothing would render both arms identically and pass the bound above by luck.
|
||||
CHECK(pctOff - floor > pctOn - floor + 1.0);
|
||||
}
|
||||
@@ -1099,6 +1161,7 @@ int main() {
|
||||
testStereoLinkedLagSharedSchedule();
|
||||
testStretchMovesDurationNotPitch();
|
||||
testStretchAndShiftComposeSafely();
|
||||
testDownSpliceFadeNeverLapsTheRingsFarEnd();
|
||||
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
|
||||
testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound();
|
||||
testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry();
|
||||
|
||||
@@ -270,7 +270,7 @@ static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int6
|
||||
ps.setFeedRate(rate);
|
||||
ps.setSourcePeriod(sourcePeriod);
|
||||
StretchCursor cur;
|
||||
cur.start(window);
|
||||
cur.start(window, 1.0);
|
||||
loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through
|
||||
|
||||
SpliceStats st;
|
||||
|
||||
+65
-20
@@ -891,38 +891,81 @@ static void testBuildSampleDataThreadsEverything() {
|
||||
p.keyTrack = 0.25;
|
||||
p.play.adsr.attackSeconds = 0.01;
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}});
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}}, 48000);
|
||||
CHECK(sd.playable());
|
||||
CHECK(sd.frames.size() == 3);
|
||||
CHECK(sd.sampleRate == 48000);
|
||||
CHECK(sd.renderRate == 48000);
|
||||
CHECK(sd.rootNote == 40); // the override, not the capture's 60
|
||||
CHECK(sd.loop.hasLoop && sd.loop.start == 10 && sd.loop.end == 90);
|
||||
CHECK(sd.startFrame == 7);
|
||||
CHECK(sd.keyTrack == 0.25);
|
||||
CHECK(sd.channelCount() == 1);
|
||||
// Wall-clock seconds resolve to frames at THIS decode's rate.
|
||||
// Wall-clock seconds resolve to frames at the render rate.
|
||||
CHECK(sd.play.adsr.attackFrames == 480);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataResolvesSecondsAtTheDecodeRate() {
|
||||
// The rate that governs the conversion is the DECODE's, not a baked constant: the same
|
||||
// parameter set built against two decodes yields two different frame counts.
|
||||
static void testBuildSampleDataResolvesSecondsAtTheRenderRate() {
|
||||
// The voice counts every stage in the frames it EMITS, so the host's rate governs the
|
||||
// conversion, never the decode's: two decodes rendered at one host rate resolve alike, and
|
||||
// one decode rendered at two host rates does not.
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.1;
|
||||
p.play.adsr.releaseSeconds = 0.25;
|
||||
p.play.trigAhd.attackSeconds = 0.02;
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), p);
|
||||
const SampleData at44 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}});
|
||||
const SampleData at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}});
|
||||
CHECK(at44.play.adsr.attackFrames == 4410);
|
||||
CHECK(at44.play.adsr.releaseFrames == 11025);
|
||||
CHECK(at96.play.adsr.attackFrames == 9600);
|
||||
CHECK(at96.play.adsr.releaseFrames == 24000);
|
||||
const SampleData file44at48 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}, 48000);
|
||||
const SampleData file96at48 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}}, 48000);
|
||||
const SampleData file44at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}, 96000);
|
||||
CHECK(file44at48.play.adsr.attackFrames == 4800);
|
||||
CHECK(file44at48.play.adsr.releaseFrames == 12000);
|
||||
CHECK(file44at48.play.trigAhd.attackFrames == 960);
|
||||
CHECK(file96at48.play.adsr.attackFrames == 4800);
|
||||
CHECK(file96at48.play.adsr.releaseFrames == 12000);
|
||||
CHECK(file44at96.play.adsr.attackFrames == 9600);
|
||||
CHECK(file44at96.play.adsr.releaseFrames == 24000);
|
||||
// The decode's rate stays the SOURCE's, and the conversion between the two is the ratio.
|
||||
CHECK(file44at48.sampleRate == 44100 && file44at48.renderRate == 48000);
|
||||
CHECK(file44at48.sourceRateRatio() == 44100.0 / 48000.0);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataWithNoHostRateRendersAtTheDecodeRate() {
|
||||
// Before the host reports a rate there is nothing to convert to: the capture renders at its
|
||||
// own rate, exactly as a hand-built SampleData does, until a rebuild supplies one.
|
||||
InstrumentParams p;
|
||||
p.play.adsr.attackSeconds = 0.1;
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f}, 44100, {}}, 0);
|
||||
CHECK(sd.playable());
|
||||
CHECK(sd.renderRate == 0);
|
||||
CHECK(sd.outputRate() == 44100);
|
||||
CHECK(sd.sourceRateRatio() == 1.0);
|
||||
CHECK(sd.play.adsr.attackFrames == 4410);
|
||||
}
|
||||
|
||||
static void testBuildSampleDataRefusesARateNoCaptureCouldHave() {
|
||||
// The ceiling is a bound on audio-thread work, so the refusal sits exactly at it.
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{});
|
||||
const int host = 48000;
|
||||
const auto atCeiling = static_cast<int>(host * kMaxSourceRateRatio);
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling, {}}, host).playable());
|
||||
const SampleData corrupt = buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling + 1, {}}, host);
|
||||
CHECK(!corrupt.playable());
|
||||
// Far BELOW the host costs nothing per frame, so it plays.
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, 1000, {}}, host).playable());
|
||||
// The shell tells the user WHY through the predicate the build applies, so the message and
|
||||
// the silence share one boundary.
|
||||
CHECK(sourceRateSupported(atCeiling, host));
|
||||
CHECK(!sourceRateSupported(atCeiling + 1, host));
|
||||
// No host rate reported yet: the file plays at its own rate, whatever that is.
|
||||
CHECK(sourceRateSupported(atCeiling + 1, 0));
|
||||
CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling + 1, {}}, 0).playable());
|
||||
}
|
||||
|
||||
static void testBuildSampleDataCarriesTheSecondChannel() {
|
||||
const SampleData sd = buildSampleData(
|
||||
resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}});
|
||||
DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}}, 44100);
|
||||
CHECK(sd.channelCount() == 2);
|
||||
CHECK(sd.framesR.size() == 2 && approx(sd.framesR[0], 0.9) && approx(sd.framesR[1], 0.8));
|
||||
}
|
||||
@@ -931,7 +974,7 @@ static void testBuildSampleDataDropsMismatchedSecondChannel() {
|
||||
// A malformed pair must fall back to MONO rather than half-playing.
|
||||
const SampleData sd = buildSampleData(
|
||||
resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}});
|
||||
DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}}, 44100);
|
||||
CHECK(sd.channelCount() == 1);
|
||||
CHECK(sd.framesR.empty());
|
||||
}
|
||||
@@ -941,7 +984,7 @@ static void testBuildSampleDataEmptyPcmIsUnplayable() {
|
||||
// SampleData (silence), never a voice started on an empty read span. (A non-positive
|
||||
// rate is a programming error the build asserts on, so it is not exercised here.)
|
||||
const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{});
|
||||
const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}});
|
||||
const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}}, 44100);
|
||||
CHECK(!sd.playable());
|
||||
CHECK(sd.frames.empty());
|
||||
}
|
||||
@@ -959,7 +1002,7 @@ static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() {
|
||||
std::sin(2.0 * 3.14159265358979323846 * 30.0 * static_cast<double>(i) / rate));
|
||||
}
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), InstrumentParams{}),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz
|
||||
}
|
||||
|
||||
@@ -984,13 +1027,13 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
|
||||
|
||||
InstrumentParams noLoop;
|
||||
const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(bare.sourcePeriodFrames == 0.0); // no loop -> whole source -> no ONE period
|
||||
|
||||
InstrumentParams looped;
|
||||
looped.loopOverride = SampleLoop{true, loopStart, static_cast<std::int64_t>(frames)};
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), looped),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(std::fabs(sd.sourcePeriodFrames - loopPeriod) < 2.0);
|
||||
|
||||
// A loop too short to host the full search band falls back to the whole source rather than
|
||||
@@ -1000,7 +1043,7 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
|
||||
InstrumentParams shortLoop;
|
||||
shortLoop.loopOverride = SampleLoop{true, 118000, static_cast<std::int64_t>(frames)};
|
||||
const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop),
|
||||
DecodedPcm{pcm, rate, {}});
|
||||
DecodedPcm{pcm, rate, {}}, rate);
|
||||
CHECK(shortSd.sourcePeriodFrames == 0.0);
|
||||
}
|
||||
|
||||
@@ -1008,7 +1051,7 @@ static void testBuildSampleDataCarriesTheVelocityCurve() {
|
||||
InstrumentParams p;
|
||||
p.velocityCurve = VelocityCurve::linear();
|
||||
const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p),
|
||||
DecodedPcm{{0.1f}, 44100, {}});
|
||||
DecodedPcm{{0.1f}, 44100, {}}, 44100);
|
||||
// The curve reaches the engine's own copy: a mid velocity maps to ~half gain, which the
|
||||
// flat default would not do.
|
||||
CHECK(std::fabs(sd.velocityCurve.eval(64.0) - 64.0 / 127.0) < 1e-6);
|
||||
@@ -1067,7 +1110,9 @@ int main() {
|
||||
testResolveNoPickAndStaleIdAreSilence();
|
||||
testResolveFromRefsNeedsNoBankAtAll();
|
||||
testBuildSampleDataThreadsEverything();
|
||||
testBuildSampleDataResolvesSecondsAtTheDecodeRate();
|
||||
testBuildSampleDataResolvesSecondsAtTheRenderRate();
|
||||
testBuildSampleDataWithNoHostRateRendersAtTheDecodeRate();
|
||||
testBuildSampleDataRefusesARateNoCaptureCouldHave();
|
||||
testBuildSampleDataCarriesTheSecondChannel();
|
||||
testBuildSampleDataDropsMismatchedSecondChannel();
|
||||
testBuildSampleDataEmptyPcmIsUnplayable();
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
// A capture whose file rate differs from the host's must play at its own pitch and for its own
|
||||
// wall-clock duration, with every envelope, loop and filter quantity landing where it would at a
|
||||
// matched rate. Every case renders through a real Voice at (file rate, host rate) pairs in both
|
||||
// directions and at a large ratio, and asserts in wall-clock / Hz terms — output frames at the
|
||||
// host rate — never in source frames.
|
||||
|
||||
#include "../src/core/instrument/engine/voice_engine.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "tone_metrics.h"
|
||||
|
||||
using namespace reasampler;
|
||||
using instrument::engine::LiveParams;
|
||||
using instrument::engine::LiveValues;
|
||||
using instrument::engine::foldLive;
|
||||
namespace flt = reasampler::instrument::engine::filter;
|
||||
using reasampler::test_support::autocorrelationPeriod;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
struct RateCase {
|
||||
int file;
|
||||
int host;
|
||||
};
|
||||
// Up, down, and a ratio past 2 — the Preserve feed then takes more than two source frames per
|
||||
// output frame, which is the case the stretcher's own [0.5, 2] bound must not clip.
|
||||
constexpr RateCase kCases[] = {{44100, 48000}, {48000, 44100}, {96000, 44100}};
|
||||
|
||||
double ratioOf(const RateCase& c) {
|
||||
return static_cast<double>(c.file) / static_cast<double>(c.host);
|
||||
}
|
||||
|
||||
SampleData sineSample(const RateCase& c, double hz, double seconds) {
|
||||
SampleData s;
|
||||
const auto n = static_cast<std::size_t>(std::llround(seconds * c.file));
|
||||
s.frames.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
s.frames[i] = static_cast<float>(
|
||||
0.5 * std::sin(2.0 * kPi * hz * static_cast<double>(i) / c.file));
|
||||
}
|
||||
s.sampleRate = c.file;
|
||||
s.renderRate = c.host;
|
||||
s.rootNote = 60;
|
||||
return s;
|
||||
}
|
||||
|
||||
SampleData dcSample(const RateCase& c, std::size_t frames) {
|
||||
SampleData s;
|
||||
s.frames.assign(frames, 1.0f);
|
||||
s.sampleRate = c.file;
|
||||
s.renderRate = c.host;
|
||||
return s;
|
||||
}
|
||||
|
||||
// `stretch` is the Rate a VoiceEngine would hand start() from the published block.
|
||||
void startVoice(Voice& v, const SampleData& s, int note, double stretch = 1.0) {
|
||||
if (s.play.pitchEngine == PitchEngine::Preserve) v.presizePreserveShifters(preserveWindowFrames(s));
|
||||
v.start(note, 100, s, /*declickTakeover=*/false, stretch);
|
||||
}
|
||||
|
||||
std::vector<double> render(const SampleData& s, int note, int frames, double stretch = 1.0) {
|
||||
Voice v;
|
||||
startVoice(v, s, note, stretch);
|
||||
std::vector<double> out(static_cast<std::size_t>(frames), 0.0);
|
||||
for (auto& x : out) x = v.renderFrame();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Output frames the voice renders before it goes idle, capped.
|
||||
std::int64_t activeFrames(const SampleData& s, int note, std::int64_t cap, double stretch = 1.0) {
|
||||
Voice v;
|
||||
startVoice(v, s, note, stretch);
|
||||
std::int64_t n = 0;
|
||||
while (v.active() && n < cap) {
|
||||
v.renderFrame();
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
double measuredPeriod(const std::vector<double>& out, std::size_t from, std::size_t len,
|
||||
double expected) {
|
||||
return autocorrelationPeriod(out, from, len, static_cast<std::int64_t>(expected * 0.5),
|
||||
static_cast<std::int64_t>(expected * 1.7));
|
||||
}
|
||||
|
||||
bool within(double got, double want, double relTol) {
|
||||
return std::fabs(got - want) <= relTol * std::fabs(want);
|
||||
}
|
||||
|
||||
double rms(const std::vector<double>& x, std::size_t from, std::size_t to) {
|
||||
double acc = 0.0;
|
||||
for (std::size_t i = from; i < to; ++i) acc += x[i] * x[i];
|
||||
return std::sqrt(acc / static_cast<double>(to - from));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pitch and duration.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testVarispeedRootNotePlaysAtItsOwnPitchAndDuration() {
|
||||
constexpr double kHz = 441.0;
|
||||
for (const RateCase& c : kCases) {
|
||||
const SampleData s = sineSample(c, kHz, 0.5);
|
||||
const double wantPeriod = static_cast<double>(c.host) / kHz;
|
||||
const std::vector<double> out = render(s, 60, 8000);
|
||||
CHECK(within(measuredPeriod(out, 1000, 4000, wantPeriod), wantPeriod, 0.002));
|
||||
|
||||
// Half a second of source is half a second at the host rate, plus the one frame that
|
||||
// detects the run-off; the slack is the read head's accumulated rounding at the edge.
|
||||
const double wantFrames = static_cast<double>(s.frames.size()) / ratioOf(c) + 1.0;
|
||||
const auto got = static_cast<double>(activeFrames(s, 60, 200000));
|
||||
CHECK(std::fabs(got - wantFrames) <= 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
static void testPreserveRootAndOctaveHoldPitchAndWallClockDuration() {
|
||||
constexpr double kHz = 441.0;
|
||||
for (const RateCase& c : kCases) {
|
||||
SampleData s = sineSample(c, kHz, 0.5);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
const double rootPeriod = static_cast<double>(c.host) / kHz;
|
||||
const double sourceSpan = static_cast<double>(s.frames.size()) / ratioOf(c);
|
||||
|
||||
const std::vector<double> root = render(s, 60, 12000);
|
||||
CHECK(within(measuredPeriod(root, 2000, 6000, rootPeriod), rootPeriod, 0.005));
|
||||
const std::vector<double> octave = render(s, 72, 12000);
|
||||
CHECK(within(measuredPeriod(octave, 2000, 6000, rootPeriod / 2.0), rootPeriod / 2.0,
|
||||
0.005));
|
||||
|
||||
// Duration is the source's own under Preserve at ANY note: the read runs to the end of
|
||||
// the source and then rings the terminal declick out.
|
||||
for (int note : {60, 72}) {
|
||||
const auto got = static_cast<double>(activeFrames(s, note, 200000));
|
||||
CHECK(got >= sourceSpan - 1.0);
|
||||
CHECK(got <= sourceSpan + static_cast<double>(kDeclickFrames) + 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testTriggerPlayPercentageIsAFractionOfTheWallClockLength() {
|
||||
for (const RateCase& c : kCases) {
|
||||
SampleData s = dcSample(c, static_cast<std::size_t>(c.file / 2)); // 0.5 s
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigger.lengthFraction = 0.5;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
// A quarter second at the host rate, plus the frame that detects the span's end.
|
||||
const double want = static_cast<double>(c.host) * 0.25 + 1.0;
|
||||
const auto got = static_cast<double>(activeFrames(s, 60, 200000));
|
||||
CHECK(std::fabs(got - want) <= 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
// A down-shift's splice crossfade walks the outgoing tap AWAY from the writer, toward the ring's
|
||||
// far end. The fade is counted in output frames while the tap moves in source frames, so at a
|
||||
// high ratio a fade sized in source frames outlasts the ring and reads audio the writer has
|
||||
// already replaced — splice debris a matched-rate render of the same note does not have.
|
||||
static void testPreserveDownShiftAtAHighRatioSplicesAsCleanlyAsAMatchedRate() {
|
||||
using reasampler::test_support::energyOutsideFundamentalPercent;
|
||||
constexpr double kHz = 441.0;
|
||||
constexpr std::size_t kFrom = 6000, kLen = 16384;
|
||||
for (const RateCase& c : {RateCase{192000, 48000}, RateCase{96000, 44100}}) {
|
||||
for (int note : {48, 36}) {
|
||||
const double wantPeriod =
|
||||
static_cast<double>(c.host) / (kHz * std::pow(2.0, (note - 60) / 12.0));
|
||||
const auto debris = [&](const RateCase& rc) {
|
||||
SampleData s = sineSample(rc, kHz, 1.0);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
return energyOutsideFundamentalPercent(render(s, note, 24000), kFrom, kLen,
|
||||
wantPeriod);
|
||||
};
|
||||
const double got = debris(c);
|
||||
const double ref = debris(RateCase{c.host, c.host});
|
||||
// Measured with a source-frame fade and no far-end cap: 0.398 / 2.631 / 0.227 %
|
||||
// against 0.001 / 0.031 / 0.012 % matched (the 96k -12 st case recedes too little to
|
||||
// show). After, every case reads its matched reference to the third decimal.
|
||||
if (!(got < ref + 0.05)) {
|
||||
std::printf(" debris %d/%d note %d: %.3f%% vs matched %.3f%%\n", c.file,
|
||||
c.host, note, got, ref);
|
||||
}
|
||||
CHECK(got < ref + 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Envelope stage times are wall-clock: counted in OUTPUT frames at the host rate.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines() {
|
||||
for (const RateCase& c : kCases) {
|
||||
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
SampleData s = dcSample(c, static_cast<std::size_t>(c.file)); // 1 s
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = engine;
|
||||
// 20 ms, resolved at the host rate as resolvePlay does.
|
||||
const std::int64_t attack = c.host / 50;
|
||||
s.play.trigAhd.attackFrames = attack;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
const std::vector<double> out = render(s, 60, static_cast<int>(attack * 2));
|
||||
// Linear attack: half way at half the attack, full just past it (the fitted attack is
|
||||
// rounded to whole SOURCE frames, so "just past" carries a few frames of slack).
|
||||
CHECK(std::fabs(out[static_cast<std::size_t>(attack / 2)] - 0.5) < 0.01);
|
||||
CHECK(out[static_cast<std::size_t>(attack + 4)] > 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void testGateAdsrAttackTakesItsWallClockTime() {
|
||||
for (const RateCase& c : kCases) {
|
||||
SampleData s = dcSample(c, static_cast<std::size_t>(c.file));
|
||||
const std::int64_t attack = c.host / 50;
|
||||
s.play.adsr.attackFrames = attack;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
const std::vector<double> out = render(s, 60, static_cast<int>(attack * 2));
|
||||
CHECK(std::fabs(out[static_cast<std::size_t>(attack / 2)] - 0.5) < 0.01);
|
||||
CHECK(out[static_cast<std::size_t>(attack + 1)] > 0.99);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate and the Pitch offset are the two factors stageFitRate compensates, and the source/host
|
||||
// ratio rides inside the same product. All three at once, under both engines: the Trigger AHD's
|
||||
// attack still takes 20 ms, the note lasts the source's wall-clock length over the read's own
|
||||
// rate, and the pitch is the offset's (Preserve) or the offset's times Rate's (Varispeed).
|
||||
static void testRateAndPitchOffsetAtAMismatchedRateKeepWallClockAndPitch() {
|
||||
const RateCase c{96000, 44100};
|
||||
constexpr double kStretch = 1.5, kOffset = -5.0, kHz = 441.0;
|
||||
const double offsetRatio = std::pow(2.0, kOffset / 12.0);
|
||||
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
const bool vari = engine == PitchEngine::Varispeed;
|
||||
const auto dial = [&](SampleData& s) {
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = engine;
|
||||
s.play.pitchOffsetSemitones = kOffset;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
};
|
||||
|
||||
SampleData dc = dcSample(c, static_cast<std::size_t>(c.file));
|
||||
dial(dc);
|
||||
const std::int64_t attack = c.host / 50; // 20 ms, resolved at the host rate
|
||||
dc.play.trigAhd.attackFrames = attack;
|
||||
const std::vector<double> ramp = render(dc, 60, static_cast<int>(attack * 2), kStretch);
|
||||
CHECK(std::fabs(ramp[static_cast<std::size_t>(attack / 2)] - 0.5) < 0.01);
|
||||
CHECK(ramp[static_cast<std::size_t>(attack + 4)] > 0.99);
|
||||
|
||||
SampleData tone = sineSample(c, kHz, 1.0);
|
||||
dial(tone);
|
||||
const double wantPeriod =
|
||||
static_cast<double>(c.host) / (kHz * offsetRatio * (vari ? kStretch : 1.0));
|
||||
const std::vector<double> out = render(tone, 60, 12000, kStretch);
|
||||
CHECK(within(measuredPeriod(out, 2000, 6000, wantPeriod), wantPeriod, 0.005));
|
||||
|
||||
const double readRate = kStretch * (vari ? offsetRatio : 1.0);
|
||||
const double span = static_cast<double>(tone.frames.size()) / ratioOf(c) / readRate;
|
||||
const auto got = static_cast<double>(activeFrames(tone, 60, 400000, kStretch));
|
||||
if (vari) {
|
||||
CHECK(std::fabs(got - (span + 1.0)) <= 1.5); // + the frame that detects the end
|
||||
} else {
|
||||
CHECK(got >= span - 1.0);
|
||||
CHECK(got <= span + static_cast<double>(kDeclickFrames) + 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A live block reaches a fresh voice through snapLive and a sounding one through applyLive, and
|
||||
// both convert the new stage frames through stageFitRate — so both must land in wall-clock time
|
||||
// at a mismatched rate too.
|
||||
static void testLiveStageTimesLandInWallClockAtAMismatchedRate() {
|
||||
const RateCase c{96000, 44100};
|
||||
constexpr std::size_t kBlock = 441; // 10 ms at the host rate
|
||||
for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
||||
const auto oneSecondAttack = [&]() {
|
||||
SampleData s = dcSample(c, static_cast<std::size_t>(c.file) * 2);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = engine;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
s.play.trigAhd.holdFraction = 1.0;
|
||||
s.play.trigAhd.attackFrames = c.host;
|
||||
return s;
|
||||
};
|
||||
|
||||
// Fresh: the snapshot says 1 s, a block published before the note says 20 ms.
|
||||
{
|
||||
SampleData s = oneSecondAttack();
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
LiveValues v = foldLive(s.play, s.keyTrack);
|
||||
const std::int64_t attack = c.host / 50;
|
||||
v.ampAhd.attackFrames = attack;
|
||||
block.publish(v);
|
||||
VoiceEngine eng(1, s, 0, preserveWindowFrames(s));
|
||||
eng.noteOn(60, 100);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, static_cast<std::size_t>(attack * 2));
|
||||
CHECK(std::fabs(out[static_cast<std::size_t>(attack / 2)] - 0.5) < 0.01);
|
||||
CHECK(out[static_cast<std::size_t>(attack + 4)] > 0.99);
|
||||
}
|
||||
|
||||
// Sounding: the 1 s attack redialled to 200 ms, 100 ms in. The AHD is positional, so the
|
||||
// level re-lands on the new shape at the current read position (the step smoothed out
|
||||
// within ~5 ms): three quarters up at 150 ms, full by 200 ms.
|
||||
{
|
||||
SampleData s = oneSecondAttack();
|
||||
LiveParams block;
|
||||
s.live = █
|
||||
block.publish(foldLive(s.play, s.keyTrack));
|
||||
VoiceEngine eng(1, s, 0, preserveWindowFrames(s));
|
||||
eng.noteOn(60, 100);
|
||||
std::vector<AudioSample> out;
|
||||
for (int b = 0; b < 10; ++b) eng.render(out, kBlock);
|
||||
LiveValues v = foldLive(s.play, s.keyTrack);
|
||||
v.ampAhd.attackFrames = c.host / 5;
|
||||
block.publish(v);
|
||||
for (int b = 0; b < 12; ++b) eng.render(out, kBlock);
|
||||
const auto at = [&](double seconds) {
|
||||
return static_cast<double>(
|
||||
out[static_cast<std::size_t>(seconds * static_cast<double>(c.host))]);
|
||||
};
|
||||
CHECK(std::fabs(at(0.05) - 0.05) < 0.01); // the 1 s attack, before the redial
|
||||
CHECK(std::fabs(at(0.15) - 0.75) < 0.01);
|
||||
CHECK(at(0.205) > 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pitch AHD's Hold fraction is taken against the playable span in OUTPUT frames. Under
|
||||
// Preserve the read runs at the source rate exactly, so a half-span hold must end half way
|
||||
// through the sample's wall-clock length — not half way through its SOURCE frame count.
|
||||
static void testPitchEnvelopeSpanIsTheSourcesWallClockLength() {
|
||||
constexpr double kHz = 441.0;
|
||||
for (const RateCase& c : kCases) {
|
||||
SampleData s = sineSample(c, kHz, 1.0);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.pitchEnv.enabled = true;
|
||||
s.play.pitchEnv.peakSemitones = 12.0;
|
||||
s.play.pitchEnv.shape.holdFraction = 0.5; // A = D = 0: peak for exactly half the span
|
||||
const double rootPeriod = static_cast<double>(c.host) / kHz;
|
||||
const auto mid = static_cast<std::size_t>(static_cast<double>(c.host) * 0.5);
|
||||
const std::vector<double> out = render(s, 60, static_cast<int>(c.host));
|
||||
constexpr std::size_t kLen = 3000;
|
||||
constexpr std::size_t kMargin = 1500;
|
||||
CHECK(within(measuredPeriod(out, mid - kMargin - kLen, kLen, rootPeriod / 2.0),
|
||||
rootPeriod / 2.0, 0.01));
|
||||
CHECK(within(measuredPeriod(out, mid + kMargin, kLen, rootPeriod), rootPeriod, 0.01));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The sustain loop and its crossfade are source-frame facts that play in wall-clock time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testSingleCycleLoopSustainsAtThePitchOfItsCycle() {
|
||||
for (const RateCase& c : kCases) {
|
||||
// One cycle of 441 Hz, looped forever: the held pitch must stay 441 Hz at the host rate.
|
||||
const double cycle = static_cast<double>(c.file) / 441.0;
|
||||
SampleData s = sineSample(c, 441.0, 0.1);
|
||||
const auto loopStart = static_cast<std::int64_t>(std::llround(cycle * 10.0));
|
||||
s.loop = SampleLoop{true, loopStart, loopStart + static_cast<std::int64_t>(std::llround(cycle))};
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
const double wantPeriod = static_cast<double>(c.host) / (static_cast<double>(c.file) /
|
||||
static_cast<double>(s.loop.end - s.loop.start));
|
||||
const int frames = c.host; // one second — ten times the source length
|
||||
const std::vector<double> out = render(s, 60, frames);
|
||||
CHECK(within(measuredPeriod(out, static_cast<std::size_t>(frames) - 5000, 4000,
|
||||
wantPeriod),
|
||||
wantPeriod, 0.002));
|
||||
}
|
||||
}
|
||||
|
||||
static void testLoopCrossfadeTakesItsWallClockTime() {
|
||||
for (const RateCase& c : kCases) {
|
||||
// Silence ahead of the loop, level inside it: the pre-seam fade blends the loop's tail
|
||||
// toward the silence one loop length back, so each pass dips for exactly the fade.
|
||||
const std::int64_t loopStart = c.file / 10; // 100 ms of lead-in
|
||||
const std::int64_t loopLen = c.file / 5; // 200 ms loop
|
||||
const std::int64_t fade = c.file / 100; // 10 ms crossfade, in SOURCE frames
|
||||
SampleData s;
|
||||
s.sampleRate = c.file;
|
||||
s.renderRate = c.host;
|
||||
s.frames.assign(static_cast<std::size_t>(loopStart + loopLen), 1.0f);
|
||||
for (std::int64_t i = 0; i < loopStart; ++i) s.frames[static_cast<std::size_t>(i)] = 0.0f;
|
||||
s.loop = SampleLoop{true, loopStart, loopStart + loopLen};
|
||||
s.loopCrossfadeFrames = fade;
|
||||
s.startFrame = loopStart;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
// Exactly one loop pass at the host rate holds exactly one fade.
|
||||
const auto pass = static_cast<int>(static_cast<double>(c.host) / 5.0);
|
||||
const std::vector<double> out = render(s, 60, pass);
|
||||
int dipped = 0;
|
||||
for (double v : out) if (v < 0.9999) ++dipped;
|
||||
const double want = static_cast<double>(c.host) / 100.0; // 10 ms at the host rate
|
||||
CHECK(std::fabs(static_cast<double>(dipped) - want) <= 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The filter corner is solved against the rate the voice EMITS at.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testFilterCornerLandsAtItsHertzAtAMismatchedRate() {
|
||||
const float cutoffNorm = flt::filterNormFromCutoffHz(1000.0f);
|
||||
const double toneHz = 2.0 * static_cast<double>(flt::filterCutoffHzFromNorm(cutoffNorm));
|
||||
const auto filtered = [&](const RateCase& c) {
|
||||
SampleData s = sineSample(c, toneHz, 0.5);
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.filter.enabled = true;
|
||||
s.play.filter.settings.cutoffNorm = cutoffNorm;
|
||||
s.play.filter.settings.resonanceNorm = 0.0f;
|
||||
s.play.filter.settings.morphNorm = 1.0f; // low-pass
|
||||
s.play.filter.env.sustainLevel = 1.0;
|
||||
return render(s, 60, 16000);
|
||||
};
|
||||
for (const RateCase& c : kCases) {
|
||||
// The reference is the same tone in Hz through the same knob position at a MATCHED rate
|
||||
// equal to this case's host rate. An octave above the corner a 2-pole low-pass is on its
|
||||
// 12 dB/oct skirt, so a corner off by the rate ratio moves the level by over a dB.
|
||||
const RateCase matched{c.host, c.host};
|
||||
const std::vector<double> got = filtered(c);
|
||||
const std::vector<double> ref = filtered(matched);
|
||||
const double gotRms = rms(got, 4000, 16000);
|
||||
const double refRms = rms(ref, 4000, 16000);
|
||||
CHECK(refRms < 0.5 * 0.5 / std::sqrt(2.0)); // the corner actually attenuates
|
||||
CHECK(std::fabs(20.0 * std::log10(gotRms / refRms)) < 0.2);
|
||||
const double wantPeriod = static_cast<double>(c.host) / toneHz;
|
||||
CHECK(within(measuredPeriod(got, 4000, 4000, wantPeriod), wantPeriod, 0.002));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Same rate: bit-identical to the engine before the factor existed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Everything that touches a rate at once: both engines, stereo, the filter under an envelope,
|
||||
// the pitch envelope, a Trigger AHD, and a crossfaded Gate loop.
|
||||
static SampleData richSample(PitchEngine engine, PlayMode mode) {
|
||||
constexpr int kRate = 44100;
|
||||
SampleData s;
|
||||
const std::size_t n = 30000;
|
||||
s.frames.resize(n);
|
||||
s.framesR.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const double t = static_cast<double>(i) / kRate;
|
||||
s.frames[i] = static_cast<float>(0.4 * std::sin(2.0 * kPi * 220.0 * t) +
|
||||
0.2 * std::sin(2.0 * kPi * 1330.0 * t));
|
||||
s.framesR[i] = static_cast<float>(0.4 * std::sin(2.0 * kPi * 330.0 * t));
|
||||
}
|
||||
s.sampleRate = kRate;
|
||||
s.rootNote = 57;
|
||||
s.loop = SampleLoop{true, 12000, 24000};
|
||||
s.loopCrossfadeFrames = 800;
|
||||
s.sourcePeriodFrames = kRate / 220.0;
|
||||
s.play.playMode = mode;
|
||||
s.play.pitchEngine = engine;
|
||||
s.play.adsr = AdsrParams{300, 100, 900, 0.7, 2000, 1.5, 0.7, 1.2};
|
||||
s.play.trigger.lengthFraction = 0.8;
|
||||
s.play.trigAhd = AhdParams{400, 3000, 0.5, 1.3, 0.8};
|
||||
s.play.pitchEnv.enabled = true;
|
||||
s.play.pitchEnv.peakSemitones = 5.0;
|
||||
s.play.pitchEnv.shape = AhdParams{200, 1500, 0.3, 1.0, 1.0};
|
||||
s.play.filter.enabled = true;
|
||||
s.play.filter.settings.cutoffNorm = 0.4f;
|
||||
s.play.filter.settings.resonanceNorm = 0.5f;
|
||||
s.play.filter.settings.morphNorm = 0.8f;
|
||||
s.play.filter.modAmount = 0.3;
|
||||
s.play.filter.keyTrack = 0.5;
|
||||
s.play.filter.env = AdsrParams{500, 0, 1500, 0.4, 1000, 1.0, 1.0, 1.0};
|
||||
s.play.filter.trigEnv = AhdParams{300, 2000, 0.4, 1.0, 1.0};
|
||||
return s;
|
||||
}
|
||||
|
||||
static std::vector<float> renderStereoNote(const SampleData& s, int note) {
|
||||
Voice v;
|
||||
startVoice(v, s, note);
|
||||
std::vector<float> out;
|
||||
for (int i = 0; i < 40000; ++i) {
|
||||
if (i == 30000) v.release();
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
v.renderFrameStereo(l, r);
|
||||
out.push_back(l);
|
||||
out.push_back(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// FNV-1a 64 over the float bit patterns, in render order.
|
||||
static std::uint64_t bitHash(const std::vector<float>& v) {
|
||||
std::uint64_t h = 14695981039346656037ull;
|
||||
for (float x : v) {
|
||||
std::uint32_t bits;
|
||||
std::memcpy(&bits, &x, sizeof bits);
|
||||
for (int b = 0; b < 4; ++b) {
|
||||
h ^= (bits >> (8 * b)) & 0xFFu;
|
||||
h *= 1099511628211ull;
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// These exact renders on the engine BEFORE the source/host factor existed (9ff34b0), hashed by
|
||||
// building richSample + renderStereoNote against that tree — MSVC x64, identical in Debug and
|
||||
// Release. A matched rate must reproduce them bit for bit whether the host rate is stated (as
|
||||
// the loader always builds) or left 0 (as every other core suite does). The inputs come from
|
||||
// libm sin/pow, so another toolchain's libm can move them with no engine change; the pin is
|
||||
// therefore MSVC-only, and stated == unstated is checked everywhere.
|
||||
static void testAMatchedRateRendersBitIdenticalToThePreFactorEngine() {
|
||||
struct Golden { PitchEngine engine; PlayMode mode; int note; std::uint64_t hash; };
|
||||
const Golden golden[] = {
|
||||
{PitchEngine::Varispeed, PlayMode::Gate, 57, 0x3e684c7e820ad514ull},
|
||||
{PitchEngine::Varispeed, PlayMode::Gate, 64, 0xcdd495f5475d63adull},
|
||||
{PitchEngine::Varispeed, PlayMode::Gate, 45, 0x9dda14cb03b2b989ull},
|
||||
{PitchEngine::Varispeed, PlayMode::Trigger, 57, 0x270baad4faaba382ull},
|
||||
{PitchEngine::Varispeed, PlayMode::Trigger, 64, 0xa636d603e07b39daull},
|
||||
{PitchEngine::Varispeed, PlayMode::Trigger, 45, 0xbc1444e4548cf811ull},
|
||||
{PitchEngine::Preserve, PlayMode::Gate, 57, 0x8b56731872c91234ull},
|
||||
{PitchEngine::Preserve, PlayMode::Gate, 64, 0x51b52f2f814d684bull},
|
||||
{PitchEngine::Preserve, PlayMode::Gate, 45, 0x8b62423be1525216ull},
|
||||
{PitchEngine::Preserve, PlayMode::Trigger, 57, 0xf0d55633547c325full},
|
||||
{PitchEngine::Preserve, PlayMode::Trigger, 64, 0x73ddee69c67183c3ull},
|
||||
{PitchEngine::Preserve, PlayMode::Trigger, 45, 0xaa9f43fa7c64c2dfull},
|
||||
};
|
||||
for (const Golden& g : golden) {
|
||||
const SampleData unstated = richSample(g.engine, g.mode); // renderRate 0
|
||||
SampleData stated = unstated;
|
||||
stated.renderRate = stated.sampleRate;
|
||||
const std::uint64_t a = bitHash(renderStereoNote(unstated, g.note));
|
||||
const std::uint64_t b = bitHash(renderStereoNote(stated, g.note));
|
||||
CHECK(a == b);
|
||||
#if defined(_MSC_VER) && defined(_M_X64)
|
||||
if (b != g.hash) {
|
||||
std::printf(" pre-factor mismatch: engine %d mode %d note %d: 0x%016llx\n",
|
||||
static_cast<int>(g.engine), static_cast<int>(g.mode), g.note,
|
||||
static_cast<unsigned long long>(b));
|
||||
}
|
||||
CHECK(b == g.hash);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testVarispeedRootNotePlaysAtItsOwnPitchAndDuration();
|
||||
testPreserveRootAndOctaveHoldPitchAndWallClockDuration();
|
||||
testTriggerPlayPercentageIsAFractionOfTheWallClockLength();
|
||||
testPreserveDownShiftAtAHighRatioSplicesAsCleanlyAsAMatchedRate();
|
||||
testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines();
|
||||
testGateAdsrAttackTakesItsWallClockTime();
|
||||
testRateAndPitchOffsetAtAMismatchedRateKeepWallClockAndPitch();
|
||||
testLiveStageTimesLandInWallClockAtAMismatchedRate();
|
||||
testPitchEnvelopeSpanIsTheSourcesWallClockLength();
|
||||
testSingleCycleLoopSustainsAtThePitchOfItsCycle();
|
||||
testLoopCrossfadeTakesItsWallClockTime();
|
||||
testFilterCornerLandsAtItsHertzAtAMismatchedRate();
|
||||
testAMatchedRateRendersBitIdenticalToThePreFactorEngine();
|
||||
if (g_fail == 0) std::printf("source_rate: all tests passed\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
+45
-11
@@ -6,10 +6,11 @@
|
||||
// the mechanism behind the "unity is bit-identical to the shipped Preserve read" gate.
|
||||
// 2. the schedule tracks the rate: over N output frames the cursor consumes N*rate source
|
||||
// frames to within one, at rates either side of unity and at irrational ones.
|
||||
// 3. the per-output-frame feed count never exceeds kMaxFeedPerFrame — the bound that makes
|
||||
// a variable-length feed loop RT-safe.
|
||||
// 3. the per-output-frame feed count never exceeds ceil(kStretchRateMax) at a matched rate —
|
||||
// the bound that makes a variable-length feed loop RT-safe.
|
||||
// 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a
|
||||
// silent stall or a quarter-speed surprise).
|
||||
// 4b. the source/host rate ratio multiplies the CLAMPED rate, unclipped by the clamp itself.
|
||||
// 5. the sustain loop wraps the cursor and never lets it leave [start, end) — "loop the
|
||||
// source" holds at every rate, including one that steps over the loop end.
|
||||
|
||||
@@ -28,6 +29,10 @@ static int g_fail = 0;
|
||||
|
||||
static ResolvedLoop noLoop() { return ResolvedLoop{}; }
|
||||
|
||||
// The most source frames one output frame can make due at a matched rate.
|
||||
static const std::int64_t kMatchedFeedBound =
|
||||
static_cast<std::int64_t>(std::ceil(kStretchRateMax));
|
||||
|
||||
static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) {
|
||||
ResolvedLoop lp;
|
||||
lp.active = true;
|
||||
@@ -40,7 +45,7 @@ static ResolvedLoop loopSpan(std::int64_t start, std::int64_t end) {
|
||||
// --- 1. Unity is exactly one frame per output frame, with no drifting residue. ---
|
||||
static void testUnityRateFeedsExactlyOneFramePerOutputFrame() {
|
||||
StretchCursor c;
|
||||
c.start(100);
|
||||
c.start(100, 1.0);
|
||||
const ResolvedLoop lp = noLoop();
|
||||
for (std::int64_t i = 0; i < 200000; ++i) {
|
||||
CHECK(c.due(1.0) == 1);
|
||||
@@ -59,7 +64,7 @@ static void testTotalConsumedTracksTheRate() {
|
||||
// would drift without bound rather than carrying the residue.
|
||||
for (double rate : {0.5, 0.75, 1.0, 1.3333333333333333, 2.0, 1.0 / 3.0 + 1.0}) {
|
||||
StretchCursor c;
|
||||
c.start(0);
|
||||
c.start(0, 1.0);
|
||||
const std::int64_t outFrames = 100000;
|
||||
for (std::int64_t i = 0; i < outFrames; ++i) {
|
||||
const std::int64_t due = c.due(rate);
|
||||
@@ -77,14 +82,14 @@ static void testFeedPerOutputFrameIsBounded() {
|
||||
// for as many iterations as the rate names.
|
||||
for (double rate : {kStretchRateMax, kStretchRateMax * 100.0, 3.99, 2.5}) {
|
||||
StretchCursor c;
|
||||
c.start(0);
|
||||
c.start(0, 1.0);
|
||||
std::int64_t worst = 0;
|
||||
for (std::int64_t i = 0; i < 20000; ++i) {
|
||||
const std::int64_t due = c.due(rate);
|
||||
if (due > worst) worst = due;
|
||||
for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp);
|
||||
}
|
||||
CHECK(worst <= kMaxFeedPerFrame);
|
||||
CHECK(worst <= kMatchedFeedBound);
|
||||
CHECK(worst >= 1); // and the bound is not vacuous — frames genuinely fell due
|
||||
}
|
||||
}
|
||||
@@ -102,11 +107,39 @@ static void testRateClamp() {
|
||||
CHECK(clampStretchRate(std::nan("")) == 1.0);
|
||||
// ...and the cursor honours it rather than looping on the raw value.
|
||||
StretchCursor c;
|
||||
c.start(0);
|
||||
c.start(0, 1.0);
|
||||
CHECK(c.due(-5.0) == 1); // folded to unity
|
||||
StretchCursor d;
|
||||
d.start(0);
|
||||
CHECK(d.due(50.0) <= kMaxFeedPerFrame);
|
||||
d.start(0, 1.0);
|
||||
CHECK(d.due(50.0) <= kMatchedFeedBound);
|
||||
}
|
||||
|
||||
// --- 4b. The source/host rate ratio scales the feed AFTER the musical clamp. ---
|
||||
static void testSourceRateRatioScalesTheClampedRate() {
|
||||
const ResolvedLoop lp = noLoop();
|
||||
// 96k source in a 44.1k host: past the musical ceiling on its own, and not clipped by it.
|
||||
const double ratio = 96000.0 / 44100.0;
|
||||
for (double rate : {0.5, 1.0, 2.0, 50.0}) {
|
||||
StretchCursor c;
|
||||
c.start(0, ratio);
|
||||
const std::int64_t outFrames = 100000;
|
||||
std::int64_t worst = 0;
|
||||
for (std::int64_t i = 0; i < outFrames; ++i) {
|
||||
const std::int64_t due = c.due(rate);
|
||||
if (due > worst) worst = due;
|
||||
for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp);
|
||||
}
|
||||
const double expected = static_cast<double>(outFrames) * clampStretchRate(rate) * ratio;
|
||||
CHECK(std::fabs(static_cast<double>(c.frame()) - expected) <= 1.0);
|
||||
CHECK(static_cast<double>(worst) <= std::ceil(kStretchRateMax * ratio));
|
||||
}
|
||||
// A ratio of exactly 1.0 is the unity schedule, frame for frame.
|
||||
StretchCursor u;
|
||||
u.start(7, 1.0);
|
||||
for (std::int64_t i = 0; i < 1000; ++i) {
|
||||
CHECK(u.due(1.0) == 1);
|
||||
CHECK(u.next(lp) == 7 + i);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5. The loop wraps the SOURCE cursor, at every rate. ---
|
||||
@@ -114,7 +147,7 @@ static void testCursorStaysInsideTheLoopSpan() {
|
||||
const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it
|
||||
for (double rate : {0.5, 1.0, 2.0, 4.0}) {
|
||||
StretchCursor c;
|
||||
c.start(1000);
|
||||
c.start(1000, 1.0);
|
||||
std::int64_t lowest = 1 << 30, highest = -1;
|
||||
for (std::int64_t i = 0; i < 50000; ++i) {
|
||||
const std::int64_t due = c.due(rate);
|
||||
@@ -134,7 +167,7 @@ static void testCursorStaysInsideTheLoopSpan() {
|
||||
// A cursor started BEYOND the loop end (the start-point-past-the-loop case) is pulled in on
|
||||
// its first take rather than reading off the end.
|
||||
StretchCursor c;
|
||||
c.start(5000);
|
||||
c.start(5000, 1.0);
|
||||
const std::int64_t q = c.next(lp);
|
||||
CHECK(q >= lp.start && q < lp.end);
|
||||
}
|
||||
@@ -144,6 +177,7 @@ int main() {
|
||||
testTotalConsumedTracksTheRate();
|
||||
testFeedPerOutputFrameIsBounded();
|
||||
testRateClamp();
|
||||
testSourceRateRatioScalesTheClampedRate();
|
||||
testCursorStaysInsideTheLoopSpan();
|
||||
|
||||
if (g_fail == 0) {
|
||||
|
||||
Reference in New Issue
Block a user