Merge Γ-W1-T7: Preserve's splices go pitch-synchronous — the period is detected once at load, over the loop the voice actually plays

This commit is contained in:
2026-08-02 03:21:16 -04:00
18 changed files with 1421 additions and 141 deletions
+17
View File
@@ -289,6 +289,23 @@ anything for a trigger shape.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter AND time-stretcher for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher** — `writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids.
- **Splices are PITCH-SYNCHRONOUS when the source's period is known** (`setSourcePeriod`, fed from `period_detect` via the loader): the nominal jump becomes the multiple of that period nearest the window that still fits the ring's jump bound (~1.25 windows), so an aligned landing point sits at the CENTRE of the correlation search instead of possibly not existing inside it at all. The search is unchanged and still earns its keep — it absorbs the jump's rounding to whole frames and tracks a source whose period drifts. **An unknown period restores the fixed-window geometry byte for byte**; do not "simplify" that fallback into an approximation of it.
- `period_detect` — the source's own fundamental period, estimated ONCE per load (two-pass YIN:
a decimated cumulative-mean-normalized difference picks the period, the full-rate difference
function refines it to a fraction of a frame), so `pitch_shift`'s splice jump can be a whole
number of it. **It runs off the audio thread BY LINK GRAPH: `sampler_core` does not link it**,
so no TU on the render path can name `detectPeriod` — the same shape as the extension's link
graph not gaining the voice engine. Its one caller is the loader (`map/sample_map`'s
`buildSampleData`), which hands the answer down on `SampleData::sourcePeriodFrames`. A period
is DERIVED from the audio, so it is cache and not state: nothing persists it, and it takes no
rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony,
percussion and a source whose period changes mid-sample all return it, and the shifter's
fixed-window geometry is the documented fallback. **Detection analyses the SUSTAIN LOOP when
the capture carries one long enough to host the full search band** (`periodAnalysisSpan`),
otherwise the whole source: the loop is what a Gate voice asymptotically plays, and a phrase
whose head is pitched differently from its sustain would otherwise disagree its way to none.
A shorter loop analyses the whole source rather than a narrowed band — a narrower span may
never buy itself a higher lowest-findable fundamental.
- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header.
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
+10 -1
View File
@@ -5,6 +5,13 @@ reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks)
# specifically the compile-time proof it does not drag in the WDL <windows.h> chain.
reasampler_test(pitch_shift LINK pitch_shift)
# Deliberately NOT linked by sampler_core, and that omission is the structural proof the
# detector cannot run on the audio thread: no TU on the render path can name detectPeriod
# without failing to link in sampler_core_tests, which links sampler_core and nothing else.
# Its one caller is the loader (map/sample_map), which runs off-thread by construction.
reasampler_pure_library(period_detect SOURCES period_detect.cpp LINK PUBLIC peaks)
reasampler_test(period_detect LINK period_detect)
reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp)
# Links only velocity_curve, deliberately not editor_geometry: the proof the engine can
# depend on the curve without inheriting the editor's layout types.
@@ -58,7 +65,9 @@ reasampler_test(staged_envelopes LINK sampler_core)
# Release, when the question is what Preserve does to a given frequency.
add_executable(preserve_low_frequency_tests
${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp)
target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core)
# period_detect beside sampler_core, not through it: the harness plays the role the loader
# does, which is exactly the seam under measurement.
target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect)
# The Preserve read's source-feed schedule the TIME half beside pitch_shift's PITCH half.
# Header-only (it sits on the per-sample feed), hence INTERFACE.
@@ -0,0 +1,244 @@
// period_detect — pure implementation. See period_detect.h for the contract.
//
// YIN (de Cheveigne & Kawahara 2002), two-pass: a cumulative-mean-normalized difference
// function on a 4x box-decimated copy picks the period, then the raw difference function at
// full rate refines it to a fraction of a frame. The decimated pass is what makes the cost
// bounded; the full-rate pass is what makes the estimate precise enough to multiply — the
// splice jump is n periods, so an error of e frames lands as n*e frames of misalignment.
//
// Hand-rolled rather than autocorrelation-with-an-FFT: no third-party dependency, and the
// difference function's absolute threshold is what lets "no period here" be a real answer.
#include "core/instrument/engine/period_detect.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
namespace reasampler::instrument::engine {
namespace {
constexpr int kDecimate = 4;
// Below this RMS a block carries no signal to find a period in; its difference function is
// numerically degenerate rather than merely inconclusive.
constexpr double kSilenceRms = 1e-5;
// Box-decimate `src[from, from+count)` by kDecimate. The averaging is the anti-alias filter:
// a plain stride would fold high partials onto the low lags the coarse pass searches.
std::vector<double> decimate(const std::vector<AudioSample>& src, std::size_t from,
std::size_t count) {
std::vector<double> out(count / kDecimate);
for (std::size_t i = 0; i < out.size(); ++i) {
double s = 0.0;
for (int k = 0; k < kDecimate; ++k) {
s += static_cast<double>(src[from + i * kDecimate + static_cast<std::size_t>(k)]);
}
out[i] = s / kDecimate;
}
return out;
}
// The cumulative-mean-normalized difference d'(tau) over lags [1, lagHi], analysis window W:
// d(tau) = sum_{j<W} (x[j] - x[j+tau])^2
// d'(tau) = d(tau) / ((1/tau) * sum_{t=1..tau} d(t))
// Index 0 is unused (set to 1.0, YIN's convention). The normalization is what makes the
// threshold below an absolute one rather than a signal-dependent one.
std::vector<double> cmndf(const std::vector<double>& x, std::size_t W, std::size_t lagHi) {
std::vector<double> dp(lagHi + 1, 1.0);
double running = 0.0;
for (std::size_t tau = 1; tau <= lagHi; ++tau) {
double d = 0.0;
for (std::size_t j = 0; j < W; ++j) {
const double diff = x[j] - x[j + tau];
d += diff * diff;
}
running += d;
dp[tau] = running > 0.0 ? d * static_cast<double>(tau) / running : 1.0;
}
return dp;
}
// Parabolic vertex through (i-1, i, i+1) as an offset in [-0.5, 0.5] from i. Zero at an end
// point or a non-minimum, which leaves the integer lag — benign, and the full-rate pass
// refines it again anyway.
double parabolicOffset(const std::vector<double>& y, std::size_t i) {
if (i == 0 || i + 1 >= y.size()) return 0.0;
const double den = y[i - 1] - 2.0 * y[i] + y[i + 1];
if (!(den > 0.0)) return 0.0; // a minimum has positive curvature
double f = 0.5 * (y[i - 1] - y[i + 1]) / den;
if (f > 0.5) f = 0.5;
if (f < -0.5) f = -0.5;
return f;
}
// YIN's absolute-threshold rule: take the FIRST dip below the threshold, walked down to its
// local bottom — not the global minimum. A periodic signal dips at every multiple of its
// period, so the global minimum is as likely to be 2P or 3P; taking the first dip is what
// makes the answer the fundamental period rather than some harmonic of it.
bool pickPeriod(const std::vector<double>& dp, std::size_t lagLo, double& tauOut,
double& dissimilarity) {
for (std::size_t tau = lagLo; tau + 1 < dp.size(); ++tau) {
if (dp[tau] >= kPeriodDetectThreshold) continue;
std::size_t t = tau;
while (t + 1 < dp.size() && dp[t + 1] < dp[t]) ++t;
tauOut = static_cast<double>(t) + parabolicOffset(dp, t);
dissimilarity = dp[t];
return true;
}
return false;
}
// The raw difference function over [lo, hi] at FULL rate, minimized parabolically. The coarse
// pass already chose which dip; this only says exactly where its bottom is. Amplitude drift
// over the few frames spanned here is negligible, so the unnormalized d() suffices.
double refineFullRate(const std::vector<AudioSample>& pcm, std::size_t from, std::size_t W,
std::size_t lo, std::size_t hi) {
std::vector<double> d(hi - lo + 1, 0.0);
for (std::size_t tau = lo; tau <= hi; ++tau) {
double s = 0.0;
for (std::size_t j = 0; j < W; ++j) {
const double diff = static_cast<double>(pcm[from + j]) -
static_cast<double>(pcm[from + j + tau]);
s += diff * diff;
}
d[tau - lo] = s;
}
const std::size_t best =
static_cast<std::size_t>(std::min_element(d.begin(), d.end()) - d.begin());
return static_cast<double>(lo + best) + parabolicOffset(d, best);
}
double blockRms(const std::vector<AudioSample>& pcm, std::size_t from, std::size_t count) {
double e = 0.0;
for (std::size_t i = 0; i < count; ++i) {
const double x = static_cast<double>(pcm[from + i]);
e += x * x;
}
return std::sqrt(e / static_cast<double>(count));
}
} // namespace
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
std::size_t spanFrom, std::size_t spanCount) {
if (sampleRate <= 0 || spanCount == 0) return {};
if (spanFrom > pcm.size() || spanCount > pcm.size() - spanFrom) return {};
const double rate = static_cast<double>(sampleRate);
std::size_t lagHi = static_cast<std::size_t>(rate / kPeriodDetectMinHz);
const std::size_t lagLo = static_cast<std::size_t>(rate / kPeriodDetectMaxHz);
if (lagLo < 2) return {}; // a rate so low the whole search band collapses
// One probe block is W + lagHi frames with W == lagHi (YIN's usual sizing: the analysis
// window must cover the longest lag being tested). A short span shortens the search
// rather than refusing outright — a 200 ms one-shot still has a period worth finding.
if (spanCount < 2 * lagHi) lagHi = spanCount / 2;
if (lagHi <= lagLo + 2) return {};
const std::size_t block = 2 * lagHi;
// Probe POSITIONS, not disjoint blocks — see kPeriodDetectProbes in the header for why
// lagHi is the separation that makes two overlapping probes independent evidence.
const std::size_t room = spanCount - block;
const std::size_t probes = std::min<std::size_t>(kPeriodDetectProbes, 1 + room / lagHi);
// Room to spare after the last probe's block is spread between them, so the probes sample
// the whole span rather than only its opening.
const std::size_t stride = probes > 1 ? room / (probes - 1) : 0;
std::vector<double> periods;
std::vector<double> confidences;
for (std::size_t p = 0; p < probes; ++p) {
const std::size_t from = spanFrom + p * stride;
if (from + block > spanFrom + spanCount) break;
if (blockRms(pcm, from, block) < kSilenceRms) continue;
const std::vector<double> small = decimate(pcm, from, block);
const std::size_t smallHi = lagHi / kDecimate;
const std::size_t smallW = small.size() - smallHi;
if (smallHi <= lagLo / kDecimate + 2 || smallW == 0) continue;
const std::vector<double> dp = cmndf(small, smallW, smallHi);
double coarseTau = 0.0, dissimilarity = 1.0;
if (!pickPeriod(dp, std::max<std::size_t>(2, lagLo / kDecimate), coarseTau,
dissimilarity)) {
continue; // no dip below threshold: this block has no single period
}
// Bracket the full-rate refinement at +/- 2 decimated samples around the coarse pick:
// the decimated parabola is already sub-decimated-sample accurate, so this is margin,
// not a second search.
const double centre = coarseTau * kDecimate;
const std::size_t lo = static_cast<std::size_t>(
std::max(static_cast<double>(lagLo), centre - 2.0 * kDecimate));
const std::size_t hi = static_cast<std::size_t>(
std::min(static_cast<double>(lagHi), centre + 2.0 * kDecimate));
if (hi <= lo) continue;
periods.push_back(refineFullRate(pcm, from, block - hi, lo, hi));
confidences.push_back(1.0 - dissimilarity);
}
if (periods.empty()) return {};
// ONE surviving probe: the span could not host a second probe position, so there is no
// second estimate for the majority rule below to rule on — it would be deciding on an
// empty comparison. The accept rests on pickPeriod's absolute threshold, which is a real
// test and not an absence of one: the block genuinely repeats at this lag across its whole
// analysis window. Refusing instead would deny every short one-shot a period, and a period
// that turns out wrong costs a mis-centred correlation search at the splice, not an
// unrefined one (pitch_shift.cpp's splice searches +/- maxLag around whichever jump it is
// handed). Do not "unify" this back into the majority test — at size 1 that test accepts
// unconditionally, which is the same behaviour with none of the reasoning.
if (periods.size() == 1) {
PeriodEstimate lone;
lone.frames = periods[0];
lone.confidence = confidences[0];
return lone;
}
std::vector<double> sorted = periods;
std::sort(sorted.begin(), sorted.end());
const double median = sorted[sorted.size() / 2];
// Average the probes that agree with the median rather than taking the median outright:
// averaging cancels each probe's own estimation jitter, and the jump multiplies whatever
// error survives by n.
double sum = 0.0, confSum = 0.0;
std::size_t agree = 0;
for (std::size_t i = 0; i < periods.size(); ++i) {
if (std::fabs(periods[i] - median) > kPeriodDetectAgreeTolerance * median) continue;
sum += periods[i];
confSum += confidences[i];
++agree;
}
// A STRICT MAJORITY of the valid probes must agree, not merely two of them: a source whose
// first half is one period and second half another gives two probes each way, and taking
// either as "the" period would misalign every splice in the other half. Refusing is the
// right answer there — the fixed-window fallback is what a source with no ONE period gets.
// Reached only with two or more probes; the lone-probe case returned above.
if (agree * 2 <= periods.size()) return {};
PeriodEstimate est;
est.frames = sum / static_cast<double>(agree);
est.confidence = confSum / static_cast<double>(agree);
return est;
}
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate) {
return detectPeriod(pcm, sampleRate, 0, pcm.size());
}
AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart,
std::int64_t loopEnd, bool hasLoop, int sampleRate) {
const AnalysisSpan whole{0, frameCount};
if (!hasLoop || sampleRate <= 0) return whole;
if (loopStart < 0 || loopEnd <= loopStart) return whole;
if (static_cast<std::uint64_t>(loopEnd) > frameCount) return whole;
const std::size_t length = static_cast<std::size_t>(loopEnd - loopStart);
// One full probe block. Below it detectPeriod shortens lagHi to fit, which raises the
// lowest findable fundamental — the one thing the narrower span may never cost.
const std::size_t minimum =
2 * static_cast<std::size_t>(static_cast<double>(sampleRate) / kPeriodDetectMinHz);
if (length < minimum) return whole;
return AnalysisSpan{static_cast<std::size_t>(loopStart), length};
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,97 @@
#pragma once
// period_detect — the source's own fundamental period, estimated ONCE per load from decoded
// PCM, for the Preserve splice's pitch-synchronous jump (pitch_shift.h's periodAlignedJump).
//
// Runs off the audio thread BY LINK GRAPH: sampler_core does not link this module, so no
// translation unit on the render path can name detectPeriod. A sampler's source is fixed and
// fully known at load, which is the whole reason a detector is affordable here at all.
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::instrument::engine {
using audio::AudioSample;
// The period the source repeats at, in SOURCE frames, or none. Derived from the audio, never
// authored and never persisted — this is a cache, not state.
struct PeriodEstimate {
double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise)
// 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic: the accept decision
// is `valid()` alone and the loader takes `.frames` without reading this — its consumers are
// the tests and the measurement harness. It is deliberately NOT a second accept gate: every
// accepted probe already cleared kPeriodDetectThreshold, so confidence > 0.88 holds by
// construction and any gate below that is a no-op while any gate above it is a tuned number
// with nothing to derive it from.
double confidence = 0.0;
bool valid() const { return frames > 0.0; }
};
// Fundamental bounds the search runs over. The LOW bound is the load-bearing one: a period
// only buys anything while it fits the splice's reachable jump (~1.25 windows, i.e. ~16 Hz at
// the product's 50 ms window), so searching below it would return periods the shifter must
// reject anyway. The high bound is generous — a period that short already has dozens of
// aligned landing points inside the search interval, so alignment was never in question there.
inline constexpr double kPeriodDetectMinHz = 15.0;
inline constexpr double kPeriodDetectMaxHz = 2000.0;
// YIN's absolute threshold: the first dissimilarity dip below this IS the period. A source
// that never dips below it has no single period, and detection returns none rather than the
// global minimum — the difference between "quiet but real" and "the least bad of nothing".
inline constexpr double kPeriodDetectThreshold = 0.12;
// How many blocks across the sample are estimated independently, and how far apart two of them
// may land and still be called the same period. Agreement is what separates a genuinely
// periodic source from one whose opening happens to look periodic.
//
// Probes are placed by POSITION and may overlap: what the rule needs is estimates from
// different places in the source, and two blocks a full longest-lag apart already differ by a
// whole cycle of the lowest frequency in the band, so neither can be a trivially shifted copy
// of the other at any period searched. Requiring DISJOINT blocks instead left every source
// under ~4x the longest lag with a single probe and so with no agreement to check at all.
// One probe survives as an irreducible case below `block + longest lag` frames and is accepted
// on the absolute threshold alone — see detectPeriod's contract.
inline constexpr int kPeriodDetectProbes = 4;
inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median
// Estimates the fundamental period of `pcm[from, from+count)` at `sampleRate`. Cost is bounded
// by the constants above, not by the span length: at most kPeriodDetectProbes blocks of ~2 x
// the longest searched lag are analysed however long the span is. Allocates; never call from
// process(). An out-of-range span estimates nothing and returns none.
//
// Returns an invalid estimate (frames == 0) for silence, noise, and anything whose probes
// disagree — the caller's documented fallback is the fixed-window splice geometry.
//
// Two probes or more must reach a STRICT MAJORITY agreement. A lone probe — which only happens
// on a span too short to host a second probe position — is accepted on the absolute threshold
// alone, because there is no second estimate for a majority rule to rule on and refusing would
// deny every short one-shot a period.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
std::size_t from, std::size_t count);
// The whole source.
PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate);
// The frames detection should analyse for a capture that carries a sustain loop, and the reason
// the answer is not simply "all of them": under Gate the loop region is asymptotically ALL the
// splicer plays, so a phrase whose head is pitched differently from its sustain would otherwise
// disagree its way to none over the whole source. `[loopStart, loopEnd)` is used only when it
// is at least `2 * (sampleRate / kPeriodDetectMinHz)` frames — the span below which detectPeriod
// starts shortening its own search band — so choosing the narrower span never costs search-band
// width and so can never lose a low fundamental that the whole source would have found.
// Anything else (no loop, an out-of-range span, a short one) yields the whole source.
//
// The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is
// on a cache input, not a second validity rule, and it refuses rather than repairs the same way.
struct AnalysisSpan {
std::size_t from = 0;
std::size_t count = 0;
};
AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart,
std::int64_t loopEnd, bool hasLoop, int sampleRate);
} // namespace reasampler::instrument::engine
+40 -7
View File
@@ -4,8 +4,9 @@
// frame the caller feeds; the active read tap advances by the shift `ratio_` per OUTPUT frame,
// so its delay behind the writer drifts at (feedRate - ratio) per frame — one frame in, one
// frame out (`feedRate == 1`) preserves duration, and any other feed cadence stretches it. When
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
// one window — clamped to the filled span so it never lands in unwritten silence — refined
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump (one
// window, or the nearest whole number of source periods to it once setSourcePeriod names one)
// — clamped to the filled span so it never lands in unwritten silence — refined
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
@@ -26,6 +27,23 @@ constexpr double kPi = 3.14159265358979323846;
} // namespace
std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames,
double periodFrames) {
if (windowFrames <= 1 || maxJumpFrames < 1) return windowFrames;
if (!(periodFrames > 0.0)) return windowFrames;
if (periodFrames > static_cast<double>(maxJumpFrames)) return windowFrames;
std::int64_t n = static_cast<std::int64_t>(
static_cast<double>(windowFrames) / periodFrames + 0.5);
if (n < 1) n = 1;
std::int64_t jump = static_cast<std::int64_t>(periodFrames * static_cast<double>(n) + 0.5);
while (jump > maxJumpFrames && n > 1) {
--n;
jump = static_cast<std::int64_t>(periodFrames * static_cast<double>(n) + 0.5);
}
if (jump < 1 || jump > maxJumpFrames) return windowFrames;
return jump;
}
void PitchShifter::configure(std::int64_t windowFrames) {
window_ = windowFrames;
if (window_ <= 1) {
@@ -37,6 +55,8 @@ void PitchShifter::configure(std::int64_t windowFrames) {
fading_ = false;
fadePos_ = 0;
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
period_ = 0.0;
jump_ = jumpMax_ = 0;
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
@@ -62,6 +82,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
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();
}
@@ -88,10 +111,17 @@ void PitchShifter::reset() {
filled_ = 0;
ratio_ = 1.0;
feedRate_ = 1.0;
period_ = 0.0;
jump_ = window_ > 1 ? window_ : 0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::setSourcePeriod(double periodFrames) {
period_ = periodFrames > 0.0 ? periodFrames : 0.0;
jump_ = window_ > 1 ? periodAlignedJump(window_, jumpMax_, period_) : 0;
}
void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true;
@@ -199,7 +229,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
std::int64_t jump = nominalJump;
if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
if (jump > maxJump) jump = maxJump;
// Shortening a period-aligned jump to fit must land on a SHORTER MULTIPLE, not on the
// raw bound — a clamped jump is an unaligned one, which is the whole failure this
// module now avoids. With no period known (or none fitting) this is the bare clamp.
if (jump > maxJump) jump = periodAlignedJump(maxJump, maxJump, period_);
if (jump < 1) jump = 1;
}
// The correlation reference reads FORWARD from the tap; keep it strictly behind the
@@ -379,9 +412,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked,
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
splice(+jump_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
splice(-jump_, d);
}
}
} else {
@@ -394,9 +427,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked,
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
splice(+jump_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
splice(-jump_, d);
}
}
+36 -2
View File
@@ -2,8 +2,10 @@
// pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core).
// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
// chases the write head at the shift ratio; when it drifts out of its safe delay band it is
// relocated by a nominal window jump, refined by a cross-correlation search so the new read
// point is waveform-aligned, then old/new taps crossfade (raised-cosine).
// relocated by a nominal jump, refined by a cross-correlation search so the new read point is
// waveform-aligned, then old/new taps crossfade (raised-cosine). The nominal jump is a whole
// number of the SOURCE's own periods when setSourcePeriod names one (pitch-synchronous OLA),
// and the fixed window otherwise.
//
// The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio =
// pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via
@@ -61,6 +63,21 @@ struct SpliceEvent {
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
};
// The nominal splice jump for a source whose period is known: the multiple of `periodFrames`
// nearest `windowFrames` that still fits `maxJumpFrames`. Falls back to `windowFrames` — the
// pre-PSOLA geometry, exactly — whenever the period is unknown (<= 0) or too long for even one
// whole period to fit, which is the documented degradation for inharmonic, polyphonic,
// percussive and noise sources.
//
// Why this is the whole fix: a splice can only phase-align on a landing point that is a whole
// number of source periods away, and the correlation search only reaches [0.75, 1.25] windows.
// Periods with no multiple in that one interval — f < ~16 Hz, and 26.7-32 Hz at a 50 ms
// window — could never align, however good the search was. Making the NOMINAL a multiple puts
// an aligned point at the centre of the search rather than hoping one falls inside it. The
// jump is rounded to whole frames; the search's own sub-sample refinement absorbs the residue.
std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames,
double periodFrames);
// A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
// channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
// two rings stay sample-aligned.
@@ -101,6 +118,17 @@ public:
// Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
void setFeedRate(double rate);
// The period of the source being fed, in SOURCE frames, making every splice jump a whole
// number of it (see periodAlignedJump). <= 0 means "unknown" and restores the fixed-window
// geometry byte for byte — the default, so a caller that never calls this sees no change.
// Detection itself is off-thread and elsewhere (period_detect, which the engine deliberately
// does not link); this is a couple of divisions and is safe to call at note-on.
// Cleared by configure()/reset(); NOT by prime()/warm(), which do not change the source.
void setSourcePeriod(double periodFrames);
// The nominal jump splices currently use — window() unless a source period narrowed it.
std::int64_t spliceJump() const { return jump_; }
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
@@ -180,6 +208,12 @@ private:
// 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)
double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window)
std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ narrows it
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY
// inside [dLow_, dHigh_] at the worst search lag, so a
// period-sized jump can never land back on a trigger and
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_)
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)
+8
View File
@@ -264,6 +264,14 @@ struct SampleData {
// (never per frame). Default flat y=1 — every velocity plays at unity.
VelocityCurve velocityCurve = VelocityCurve::flat();
// The source's own fundamental period in SOURCE frames, which makes Preserve's splices
// pitch-synchronous (pitch_shift.h). DERIVED from the PCM at load, not authored and never
// persisted — a cache, not state, so it takes no rung of the payload ladder. 0 means
// unknown (nothing detected it, or the source has no single period) and restores the
// fixed-window splice geometry byte for byte, which is why a hand-built SampleData is
// still exactly the bare engine.
double sourcePeriodFrames = 0.0;
PlayParams play;
// The live-parameter block a sounding voice tracks, or null for the bare latched engine
+25 -14
View File
@@ -32,20 +32,31 @@ namespace reasampler::instrument::engine {
// actually fine. (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.)
//
// A SECOND, INDEPENDENT limit binds the same material, and no rate bound touches it. A splice
// relocates the tap by the nominal window refined by a search over +/- window/4, so the
// reachable relocation distances are exactly [0.75, 1.25] * window; a phase-aligned splice
// needs a WHOLE NUMBER of source periods inside that one interval. The interval is 0.5*window
// wide, so any period <= window/2 always has a multiple in it — but above that, coverage
// breaks into disjoint bands (n=1 covers periods [0.75, 1.25]*window, n=2 covers
// [0.375, 0.625]*window) and the gap between them is reachable by nothing. Because both the
// interval and the period scale with the sample rate, the unalignable set is fixed in Hz by
// the window's MILLISECONDS: at 50 ms that is f < 16 Hz and 26.7 Hz < f < 32 Hz. Measured
// (Release, 44.1k and 48k) at 30 Hz: the rendered pitch stays correct, but energy outside the
// fundamental is 3.6% at +2 st / rate 1.0 and 15.5% at rate 2.0, against 0.00% at 34 Hz under
// identical conditions; at 29 Hz / rate 2.0 the tone itself lands 7.4% flat. Unlike the
// cadence inequality above, this one is not about how OFTEN a splice fires — a window of at
// least two source periods removes it outright, and nothing else does.
// A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now
// CLOSED for any source whose period is detected, but the geometry is worth keeping because it
// is what the fixed-window fallback still lives under. A splice relocated the tap by the
// nominal window refined by a search over +/- window/4, so the reachable relocation distances
// were exactly [0.75, 1.25] * window; a phase-aligned splice needs a WHOLE NUMBER of source
// periods inside that interval. The interval is 0.5*window wide, so any period <= window/2
// always has a multiple in it — but above that, coverage breaks into disjoint bands (n=1 covers
// periods [0.75, 1.25]*window, n=2 covers [0.375, 0.625]*window) and the gap between them was
// reachable by nothing. Because both the interval and the period scale with the sample rate,
// that unalignable set is fixed in Hz by the window's MILLISECONDS: at 50 ms, f < 16 Hz and
// 26.7 Hz < f < 32 Hz. Measured there (Release, 44.1k and 48k) at 30 Hz: the rendered pitch
// stayed correct, but energy outside the fundamental was 3.6% at +2 st / rate 1.0 and 15.5% at
// rate 2.0, against 0.00% at 34 Hz under identical conditions; at 29 Hz / rate 2.0 the tone
// itself landed 7.4% flat (-133 cents).
//
// The fix is not a wider window: it is a nominal jump that is a whole number of the source's
// own periods, so an aligned landing point exists by construction (pitch_shift.h's
// periodAlignedJump, fed by period_detect at load). The same measurements then read 0.00% and
// 0.00%, and 29 Hz renders at +0.0 cents — all from `preserve_low_frequency_tests` (Release,
// hand-run; it is not in the gated ctest set), the same harness/config as the 3.6%/15.5%/-133
// cents readings above. The gated suite's own number for this is the floor-relative excess in
// pitch_shift_tests' testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown, a different
// 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 this fixed-window geometry by design.
inline constexpr double kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0;
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax)
+6
View File
@@ -241,6 +241,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
stretch_.start(p);
shiftL_.setFeedRate(stretchRate_);
shiftR_.setFeedRate(stretchRate_);
// Pitch-synchronous splices: the period was detected once at load (period_detect,
// which this library deliberately does not link — the loader hands the answer down on
// SampleData). 0 restores the fixed-window geometry, so a capture with no single
// period plays exactly as it always did.
shiftL_.setSourcePeriod(sample.sourcePeriodFrames);
shiftR_.setSourcePeriod(sample.sourcePeriodFrames);
if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is already exhausted at prime time.
shiftL_.freezeTail();
+2 -1
View File
@@ -40,7 +40,8 @@ target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law)
reasampler_pure_library(sample_map
SOURCES sample_map.cpp
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law
musical_division)
musical_division
PRIVATE period_detect)
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
# both halves of the mapping/codec split where the frozen-format assertions live.
reasampler_test(sample_map LINK sample_map component_state_io)
+12
View File
@@ -3,6 +3,8 @@
#include "core/instrument/map/sample_map.h"
#include "core/instrument/engine/period_detect.h" // the load-time Preserve source period
#include <algorithm> // std::remove_if
#include <cassert> // assert
#include <utility> // std::move
@@ -325,6 +327,16 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded)
// 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);
// 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
// enough (periodAnalysisSpan owns that rule) — every input to it commits through a full
// reload, so the cache is re-derived whenever the span it was chosen from moves.
const instrument::engine::AnalysisSpan span = instrument::engine::periodAnalysisSpan(
data.frames.size(), data.loop.start, data.loop.end, data.loop.hasLoop, data.sampleRate);
data.sourcePeriodFrames =
instrument::engine::detectPeriod(data.frames, data.sampleRate, span.from, span.count)
.frames;
return data;
}