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. - `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()`. - `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. - **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. - `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`. - `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. - `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. # specifically the compile-time proof it does not drag in the WDL <windows.h> chain.
reasampler_test(pitch_shift LINK pitch_shift) 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) reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp)
# Links only velocity_curve, deliberately not editor_geometry: the proof the engine can # Links only velocity_curve, deliberately not editor_geometry: the proof the engine can
# depend on the curve without inheriting the editor's layout types. # 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. # Release, when the question is what Preserve does to a given frequency.
add_executable(preserve_low_frequency_tests add_executable(preserve_low_frequency_tests
${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp) ${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. # 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. # 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, // 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 // 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 // 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 // that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump (one
// one window — clamped to the filled span so it never lands in unwritten silence — refined // 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 // 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 // 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 // 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 } // 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) { void PitchShifter::configure(std::int64_t windowFrames) {
window_ = windowFrames; window_ = windowFrames;
if (window_ <= 1) { if (window_ <= 1) {
@@ -37,6 +55,8 @@ void PitchShifter::configure(std::int64_t windowFrames) {
fading_ = false; fading_ = false;
fadePos_ = 0; fadePos_ = 0;
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0; fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
period_ = 0.0;
jump_ = jumpMax_ = 0;
filled_ = 0; filled_ = 0;
ratio_ = 1.0; ratio_ = 1.0;
feedRate_ = 1.0; feedRate_ = 1.0;
@@ -62,6 +82,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
dLow_ = window_ / 4; dLow_ = window_ / 4;
dHigh_ = ringLen_ - window_ / 4; dHigh_ = ringLen_ - window_ / 4;
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512)); 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; fadeLen_ = 0;
reset(); reset();
} }
@@ -88,10 +111,17 @@ void PitchShifter::reset() {
filled_ = 0; filled_ = 0;
ratio_ = 1.0; ratio_ = 1.0;
feedRate_ = 1.0; feedRate_ = 1.0;
period_ = 0.0;
jump_ = window_ > 1 ? window_ : 0;
tailFrozen_ = false; tailFrozen_ = false;
lastSplice_ = SpliceEvent{}; 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() { void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return; if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true; tailFrozen_ = true;
@@ -199,7 +229,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
std::int64_t jump = nominalJump; std::int64_t jump = nominalJump;
if (jump > 0) { if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1; 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; if (jump < 1) jump = 1;
} }
// The correlation reference reads FORWARD from the tap; keep it strictly behind the // 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 < 0.0) d += len;
while (d >= len) d -= len; while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) { if (d <= static_cast<double>(dLow_)) {
splice(+window_, d); splice(+jump_, d);
} else if (d >= static_cast<double>(dHigh_)) { } else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d); splice(-jump_, d);
} }
} }
} else { } else {
@@ -394,9 +427,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked,
while (d < 0.0) d += len; while (d < 0.0) d += len;
while (d >= len) d -= len; while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) { if (d <= static_cast<double>(dLow_)) {
splice(+window_, d); splice(+jump_, d);
} else if (d >= static_cast<double>(dHigh_)) { } 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). // 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 // 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 // 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 // relocated by a nominal jump, refined by a cross-correlation search so the new read point is
// point is waveform-aligned, then old/new taps crossfade (raised-cosine). // 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 = // 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 // 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 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: // 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 // channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
// two rings stay sample-aligned. // 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. // Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit.
void setFeedRate(double rate); 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 // 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 // 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 // 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 // ratio-scaled at splice time so an up-shift's outgoing
// tap can never drain into the writer mid-fade // tap can never drain into the writer mid-fade
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4) 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 std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind // the reference read forward from the tap stays behind
// the writer by construction at an up-splice) // 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. // (never per frame). Default flat y=1 — every velocity plays at unity.
VelocityCurve velocityCurve = VelocityCurve::flat(); 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; PlayParams play;
// The live-parameter block a sounding voice tracks, or null for the bare latched engine // 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, // 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.) // ~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 // A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now
// relocates the tap by the nominal window refined by a search over +/- window/4, so the // CLOSED for any source whose period is detected, but the geometry is worth keeping because it
// reachable relocation distances are exactly [0.75, 1.25] * window; a phase-aligned splice // is what the fixed-window fallback still lives under. A splice relocated the tap by the
// needs a WHOLE NUMBER of source periods inside that one interval. The interval is 0.5*window // nominal window refined by a search over +/- window/4, so the reachable relocation distances
// wide, so any period <= window/2 always has a multiple in it — but above that, coverage // were exactly [0.75, 1.25] * window; a phase-aligned splice needs a WHOLE NUMBER of source
// breaks into disjoint bands (n=1 covers periods [0.75, 1.25]*window, n=2 covers // periods inside that interval. The interval is 0.5*window wide, so any period <= window/2
// [0.375, 0.625]*window) and the gap between them is reachable by nothing. Because both the // always has a multiple in it — but above that, coverage breaks into disjoint bands (n=1 covers
// interval and the period scale with the sample rate, the unalignable set is fixed in Hz by // periods [0.75, 1.25]*window, n=2 covers [0.375, 0.625]*window) and the gap between them was
// the window's MILLISECONDS: at 50 ms that is f < 16 Hz and 26.7 Hz < f < 32 Hz. Measured // reachable by nothing. Because both the interval and the period scale with the sample rate,
// (Release, 44.1k and 48k) at 30 Hz: the rendered pitch stays correct, but energy outside the // that unalignable set is fixed in Hz by the window's MILLISECONDS: at 50 ms, f < 16 Hz and
// fundamental is 3.6% at +2 st / rate 1.0 and 15.5% at rate 2.0, against 0.00% at 34 Hz under // 26.7 Hz < f < 32 Hz. Measured there (Release, 44.1k and 48k) at 30 Hz: the rendered pitch
// identical conditions; at 29 Hz / rate 2.0 the tone itself lands 7.4% flat. Unlike the // stayed correct, but energy outside the fundamental was 3.6% at +2 st / rate 1.0 and 15.5% at
// cadence inequality above, this one is not about how OFTEN a splice fires — a window of at // rate 2.0, against 0.00% at 34 Hz under identical conditions; at 29 Hz / rate 2.0 the tone
// least two source periods removes it outright, and nothing else does. // 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 kStretchRateMin = 0.5;
inline constexpr double kStretchRateMax = 2.0; inline constexpr double kStretchRateMax = 2.0;
inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) 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); stretch_.start(p);
shiftL_.setFeedRate(stretchRate_); shiftL_.setFeedRate(stretchRate_);
shiftR_.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) { if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is already exhausted at prime time. // Sub-window playable span: the source is already exhausted at prime time.
shiftL_.freezeTail(); 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 reasampler_pure_library(sample_map
SOURCES sample_map.cpp SOURCES sample_map.cpp
LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law 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 # 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. # both halves of the mapping/codec split where the frozen-format assertions live.
reasampler_test(sample_map LINK sample_map component_state_io) 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/map/sample_map.h"
#include "core/instrument/engine/period_detect.h" // the load-time Preserve source period
#include <algorithm> // std::remove_if #include <algorithm> // std::remove_if
#include <cassert> // assert #include <cassert> // assert
#include <utility> // std::move #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 // 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. // actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(resolved.play, data.sampleRate); 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; return data;
} }
-50
View File
@@ -1,50 +0,0 @@
#pragma once
// Out-of-band spectral energy metric: the same period-grid, Hann-windowed direct-evaluation
// approach as test_preserve_low_frequency.cpp's reportSpectrum. Chosen over zero-crossing
// counting because splice debris adds spurious crossings that make that estimator
// anti-correlated with severity (a render can read a badly wrong PERIOD while this metric
// shows it is mostly clean, or vice versa). Grid/segment sizes are smaller than the hand-run
// harness's — this one runs inside the gated suite.
#include <cmath>
#include <cstddef>
#include <vector>
namespace reasampler::test_support {
// Percentage (0..100) of the segment [from, from+len)'s spectral energy that falls outside
// +/- 6% of `wantPeriod` (frames). 0 = a clean single tone at that period; higher values mean
// harmonics, splice-cadence sidebands, or crossfade cancellation debris are present.
inline double energyOutsideFundamentalPercent(const std::vector<double>& v, std::size_t from,
std::size_t len, double wantPeriod) {
constexpr double kPi = 3.14159265358979323846;
constexpr int kGrid = 400;
const double pLo = 30.0, pHi = 8000.0;
std::vector<double> mag(static_cast<std::size_t>(kGrid));
std::vector<double> per(static_cast<std::size_t>(kGrid));
for (int g = 0; g < kGrid; ++g) {
// Geometric grid: constant relative resolution across the swept period range.
const double p = pLo * std::pow(pHi / pLo, static_cast<double>(g) / (kGrid - 1));
per[static_cast<std::size_t>(g)] = p;
double re = 0.0, im = 0.0;
const double w = 2.0 * kPi / p;
for (std::size_t k = 0; k < len && from + k < v.size(); ++k) {
const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast<double>(k) /
static_cast<double>(len)));
const double x = v[from + k] * hann;
re += x * std::cos(w * static_cast<double>(k));
im += x * std::sin(w * static_cast<double>(k));
}
mag[static_cast<std::size_t>(g)] = std::sqrt(re * re + im * im);
}
double eTotal = 0.0, eFund = 0.0;
for (int g = 0; g < kGrid; ++g) {
const std::size_t i = static_cast<std::size_t>(g);
const double e = mag[i] * mag[i];
eTotal += e;
if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e;
}
return eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0;
}
} // namespace reasampler::test_support
+412
View File
@@ -0,0 +1,412 @@
// Standalone tests for reasampler::instrument::engine::detectPeriod — the offline source-period
// estimate behind Preserve's pitch-synchronous splices. No VST3, no REAPER, no test framework.
//
// Covers:
// 1. accuracy on pure tones across the searched band, at 44.1k and 48k, including the
// non-integer periods every real capture actually has — the splice jump is n periods, so
// a fractional-frame error lands multiplied by n.
// 2. the fundamental, not a harmonic: a sawtooth and a missing-fundamental stack must both
// report the repeat period, which is what a splice has to align on.
// 3. graceful degradation — noise, silence, and a source whose period changes mid-sample all
// return NONE. That is the contract the shifter's fixed-window fallback rests on: an
// estimate that is merely wrong would misalign every splice, which is worse than none.
// 4. the band edges and the short-sample path, including the lone-probe accept.
// 5. the analysis span: a sustain loop stands in for the whole source, but never at the cost
// of search-band width.
// 6. what the load pays, and that it does not grow with the sample length.
#include "../src/core/instrument/engine/period_detect.h"
#include <chrono>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <tuple>
#include <vector>
using namespace reasampler;
using namespace reasampler::instrument::engine;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
constexpr double kPi = 3.14159265358979323846;
static std::vector<AudioSample> sineOfPeriod(std::size_t frames, double period,
double phase = 0.0) {
std::vector<AudioSample> s(frames);
for (std::size_t i = 0; i < frames; ++i) {
s[i] = static_cast<AudioSample>(
std::sin(2.0 * kPi * static_cast<double>(i) / period + phase));
}
return s;
}
// --- 1. Accuracy on pure tones -------------------------------------------------------------
static void testPureTonePeriodIsFoundToBetterThanATenthOfAFrame() {
// Deliberately non-integer periods: an integer-only estimator passes an integer-period
// sweep and still misaligns every real capture.
const double periods[] = {23.7, 50.0, 100.25, 200.45, 441.0, 999.9, 1470.0, 2000.3, 2756.0};
for (double p : periods) {
const std::vector<AudioSample> src = sineOfPeriod(120000, p);
const PeriodEstimate est = detectPeriod(src, 44100);
CHECK(est.valid());
if (!est.valid()) {
std::printf(" period %.2f: NOT DETECTED\n", p);
continue;
}
const double errFrames = std::fabs(est.frames - p);
std::printf(" period %8.2f -> %8.4f (err %.4f fr, conf %.3f)\n", p, est.frames,
errFrames, est.confidence);
CHECK(errFrames < 0.1);
CHECK(est.confidence > 0.8);
}
}
static void testTheEstimateIsInSourceFramesSoTheRateOnlyMovesTheBand() {
// The same 30 Hz tone at two rates: the answer is frames, so it must track the rate. This
// is what lets the shifter compare it against a window that is also in frames.
for (int rate : {44100, 48000}) {
const double p = static_cast<double>(rate) / 30.0;
const std::vector<AudioSample> src = sineOfPeriod(160000, p);
const PeriodEstimate est = detectPeriod(src, rate);
CHECK(est.valid());
if (est.valid()) {
std::printf(" 30 Hz @ %d: %.3f fr (want %.3f)\n", rate, est.frames, p);
CHECK(std::fabs(est.frames - p) < 0.5);
}
}
}
// --- 2. The fundamental, not a harmonic ----------------------------------------------------
static void testHarmonicRichSourceReportsTheRepeatPeriodNotAPartial() {
// A sawtooth's strongest correlation dips at EVERY multiple of its period; a global-minimum
// estimator picks 2P or 3P about as often as P. YIN's first-dip rule is what makes this
// pass, and a jump quantized to 2P would splice a whole cycle out of phase half the time.
const double p = 512.0;
std::vector<AudioSample> src(120000);
for (std::size_t i = 0; i < src.size(); ++i) {
double v = 0.0;
for (int h = 1; h <= 12; ++h) {
v += std::sin(2.0 * kPi * h * static_cast<double>(i) / p) / h;
}
src[i] = static_cast<AudioSample>(0.5 * v);
}
const PeriodEstimate est = detectPeriod(src, 44100);
CHECK(est.valid());
if (est.valid()) {
std::printf(" sawtooth P=512 -> %.3f\n", est.frames);
CHECK(std::fabs(est.frames - p) < 1.0);
}
}
static void testMissingFundamentalStillReportsTheRepeatPeriod() {
// Partials 2..6 of a 700-frame period: there is no energy AT the fundamental, but the
// waveform still repeats every 700 frames — and repetition, not spectral content, is what
// a splice has to land on.
const double p = 700.0;
std::vector<AudioSample> src(120000);
for (std::size_t i = 0; i < src.size(); ++i) {
double v = 0.0;
for (int h = 2; h <= 6; ++h) {
v += std::sin(2.0 * kPi * h * static_cast<double>(i) / p);
}
src[i] = static_cast<AudioSample>(0.2 * v);
}
const PeriodEstimate est = detectPeriod(src, 44100);
CHECK(est.valid());
if (est.valid()) {
std::printf(" missing fundamental P=700 -> %.3f\n", est.frames);
CHECK(std::fabs(est.frames - p) < 2.0);
}
}
// --- 3. Graceful degradation ---------------------------------------------------------------
static void testNoiseSilenceAndAPeriodChangeAllReportNone() {
// White noise: no dip below the absolute threshold anywhere.
{
std::vector<AudioSample> src(120000);
std::uint32_t rng = 22222u;
for (auto& x : src) {
rng = rng * 1664525u + 1013904223u;
x = static_cast<AudioSample>((static_cast<double>(rng >> 8) / 8388608.0) - 1.0);
}
const PeriodEstimate est = detectPeriod(src, 44100);
std::printf(" white noise -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none",
est.frames);
CHECK(!est.valid());
}
// Digital silence: the difference function is degenerate, not merely inconclusive.
{
const std::vector<AudioSample> src(120000, 0.0f);
CHECK(!detectPeriod(src, 44100).valid());
}
// Two halves at genuinely different periods: the probes disagree, so there is no ONE
// period, and reporting either half's would misalign every splice in the other half.
{
std::vector<AudioSample> src(160000);
double phase = 0.0;
for (std::size_t i = 0; i < src.size(); ++i) {
phase += 2.0 * kPi / (i < 80000 ? 300.0 : 700.0);
src[i] = static_cast<AudioSample>(std::sin(phase));
}
const PeriodEstimate est = detectPeriod(src, 44100);
std::printf(" period change 300->700 -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none",
est.frames);
CHECK(!est.valid());
}
// Degenerate inputs.
CHECK(!detectPeriod({}, 44100).valid());
CHECK(!detectPeriod(sineOfPeriod(120000, 441.0), 0).valid());
}
static void testAPercussiveDecayIsNotForcedIntoAPeriod() {
// Filtered noise with a fast decay — the shape of a one-shot drum hit. Nothing repeats, so
// the answer must be none rather than whatever the envelope's own length looks like.
std::vector<AudioSample> src(120000);
std::uint32_t rng = 909090u;
double lp = 0.0;
for (std::size_t i = 0; i < src.size(); ++i) {
rng = rng * 1664525u + 1013904223u;
const double n = (static_cast<double>(rng >> 8) / 8388608.0) - 1.0;
lp += 0.25 * (n - lp);
const double env = std::exp(-static_cast<double>(i % 22050) / 2000.0);
src[i] = static_cast<AudioSample>(lp * env);
}
const PeriodEstimate est = detectPeriod(src, 44100);
std::printf(" percussive decay -> %s (%.3f)\n", est.valid() ? "DETECTED" : "none",
est.frames);
CHECK(!est.valid());
}
// --- 4. Band edges and short sources -------------------------------------------------------
static void testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple() {
// Below kPeriodDetectMinHz: none. This is the load-bearing edge — such a period cannot fit
// the splice jump anyway, so an answer here would only be one the shifter must reject.
const std::vector<AudioSample> low = sineOfPeriod(200000, 44100.0 / 8.0); // 8 Hz
CHECK(!detectPeriod(low, 44100).valid());
// Above kPeriodDetectMaxHz the search floor sits well above the true period, so what comes
// back is a WHOLE MULTIPLE of it — which is still an exactly aligned splice target, since
// every multiple of a period is a period. That is why the high edge needs no special
// handling: being outside the band costs nothing, because alignment was never in question
// for a tone this short-period.
const double p = 44100.0 / 6000.0; // 7.35 frames
const PeriodEstimate high = detectPeriod(sineOfPeriod(120000, p), 44100);
std::printf(" 6 kHz (P=%.3f) -> %s (%.3f, = %.3f periods)\n", p,
high.valid() ? "detected" : "none", high.frames, high.frames / p);
if (high.valid()) {
const double n = high.frames / p;
CHECK(std::fabs(n - std::floor(n + 0.5)) < 0.02);
}
}
static void testAShortSourceShortensTheSearchRatherThanRefusing() {
// A 12000-frame one-shot cannot host a full-band probe; the search band shortens to fit and
// a 200-frame period is still found. Below that the answer is none, not a guess.
const std::vector<AudioSample> shortSrc = sineOfPeriod(12000, 200.0);
const PeriodEstimate est = detectPeriod(shortSrc, 44100);
std::printf(" 12000-frame source, P=200 -> %s (%.3f)\n", est.valid() ? "detected" : "none",
est.frames);
CHECK(est.valid());
if (est.valid()) CHECK(std::fabs(est.frames - 200.0) < 0.5);
// Too short for even the minimum lag: none.
CHECK(!detectPeriod(sineOfPeriod(40, 20.0), 44100).valid());
}
// A lone probe is the one case the strict-majority rule cannot rule on, so pin BOTH halves of
// the carve-out: which sources land in it, and that they are accepted rather than refused.
// 30 Hz is first-class material here, and a short low-frequency source is exactly where the
// blunt "require two probes" fix would have silently stopped detecting.
static void testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe() {
const int rate = 44100;
const std::size_t lagHi = static_cast<std::size_t>(rate / kPeriodDetectMinHz);
const std::size_t block = 2 * lagHi;
// Derive the frame count from the public constants rather than hardcoding one, so this test
// keeps naming the lone-probe case if the geometry ever moves. One probe fits while the
// span leaves less than lagHi of room after the first block.
const std::size_t frames = block + lagHi - 1; // 8819 at 44.1k -> exactly one probe
CHECK(1 + (frames - block) / lagHi == 1);
const double p = static_cast<double>(rate) / 30.0; // 1470 frames
const PeriodEstimate est = detectPeriod(sineOfPeriod(frames, p), rate);
std::printf(" lone probe, %zu frames @ 30 Hz -> %s (%.3f, want %.3f)\n", frames,
est.valid() ? "detected" : "NONE", est.frames, p);
CHECK(est.valid());
if (est.valid()) CHECK(std::fabs(est.frames - p) < 1.0);
// One frame more buys a second probe position; the answer must not change character.
const PeriodEstimate two = detectPeriod(sineOfPeriod(frames + 1, p), rate);
CHECK(1 + (frames + 1 - block) / lagHi == 2);
CHECK(two.valid());
if (two.valid()) CHECK(std::fabs(two.frames - p) < 1.0);
}
static void testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected() {
// 30 Hz and 29 Hz — the pair the Preserve geometry work is measured against. 29 Hz is the
// sharper case: its period does not divide the splice window, so the shifter needs the
// detected value to be right rather than merely present.
for (double hz : {30.0, 29.0}) {
const double p = 44100.0 / hz;
const PeriodEstimate est = detectPeriod(sineOfPeriod(160000, p), 44100);
std::printf(" %.0f Hz -> %s (%.3f, want %.3f)\n", hz, est.valid() ? "detected" : "NONE",
est.frames, p);
CHECK(est.valid());
if (est.valid()) CHECK(std::fabs(est.frames - p) < 0.5);
}
}
// --- 5. The analysis span -------------------------------------------------------------------
static void testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand() {
const int rate = 44100;
const std::size_t frames = 120000;
// One full probe block — the span below which detectPeriod starts shortening its own
// longest lag, which is the only thing the narrower span may never cost.
const std::size_t minimum = 2 * static_cast<std::size_t>(rate / kPeriodDetectMinHz);
// No loop, an inverted span, and a span reaching past the PCM all yield the whole source.
for (const auto& [lo, hi, has] : {std::tuple<std::int64_t, std::int64_t, bool>{0, 0, false},
{60000, 120000, false},
{90000, 90000, true},
{90000, 80000, true},
{-1, 90000, true},
{60000, 130000, true}}) {
const AnalysisSpan s = periodAnalysisSpan(frames, lo, hi, has, rate);
CHECK(s.from == 0 && s.count == frames);
}
// A loop one frame under the minimum falls back to the WIDER span, not to none.
const AnalysisSpan shortLoop =
periodAnalysisSpan(frames, 60000, 60000 + static_cast<std::int64_t>(minimum) - 1, true,
rate);
CHECK(shortLoop.from == 0 && shortLoop.count == frames);
// At the minimum exactly, the loop is taken.
const AnalysisSpan atMinimum =
periodAnalysisSpan(frames, 60000, 60000 + static_cast<std::int64_t>(minimum), true, rate);
CHECK(atMinimum.from == 60000 && atMinimum.count == minimum);
// And the too-short loop still DETECTS through the wider span — refusing there would be a
// regression against analysing the whole source, and a short sustain loop is common.
const double p = static_cast<double>(rate) / 30.0;
const std::vector<AudioSample> src = sineOfPeriod(frames, p);
const PeriodEstimate est = detectPeriod(src, rate, shortLoop.from, shortLoop.count);
std::printf(" short loop -> whole source: %s (%.3f)\n", est.valid() ? "detected" : "NONE",
est.frames);
CHECK(est.valid());
if (est.valid()) CHECK(std::fabs(est.frames - p) < 0.5);
}
static void testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop() {
// The case the whole-source analysis cannot answer: the head sustains one pitch, the looped
// tail another. Analysed whole, two probes land each side and the strict-majority rule
// correctly refuses — there is no ONE period over the whole source. But under Gate the
// splicer lives in the loop, whose period is perfectly well defined.
const int rate = 44100;
const std::size_t frames = 120000;
const std::int64_t loopStart = 60000;
const double headPeriod = 300.0;
const double loopPeriod = static_cast<double>(rate) / 30.0; // 1470 frames
std::vector<AudioSample> src(frames);
double phase = 0.0;
for (std::size_t i = 0; i < frames; ++i) {
phase += 2.0 * kPi /
(i < static_cast<std::size_t>(loopStart) ? headPeriod : loopPeriod);
src[i] = static_cast<AudioSample>(std::sin(phase));
}
// BEFORE this rule: the whole source is what was analysed, and it reports none.
const PeriodEstimate whole = detectPeriod(src, rate);
std::printf(" phrase analysed whole -> %s (%.3f)\n", whole.valid() ? "DETECTED" : "none",
whole.frames);
CHECK(!whole.valid());
// AFTER: the loop is long enough to host the full band, so it is the analysed span.
const AnalysisSpan span = periodAnalysisSpan(frames, loopStart,
static_cast<std::int64_t>(frames), true, rate);
CHECK(span.from == static_cast<std::size_t>(loopStart));
const PeriodEstimate looped = detectPeriod(src, rate, span.from, span.count);
std::printf(" phrase analysed over its loop -> %s (%.3f, want %.3f)\n",
looped.valid() ? "detected" : "NONE", looped.frames, loopPeriod);
CHECK(looped.valid());
if (looped.valid()) CHECK(std::fabs(looped.frames - loopPeriod) < 2.0);
// The narrowed span must not turn a genuinely aperiodic loop into a period: same geometry,
// noise in the loop region.
std::vector<AudioSample> noisyLoop = src;
std::uint32_t rng = 777u;
for (std::size_t i = static_cast<std::size_t>(loopStart); i < frames; ++i) {
rng = rng * 1664525u + 1013904223u;
noisyLoop[i] = static_cast<AudioSample>((static_cast<double>(rng >> 8) / 8388608.0) - 1.0);
}
CHECK(!detectPeriod(noisyLoop, rate, span.from, span.count).valid());
}
static void testAnOutOfRangeSpanEstimatesNothing() {
const std::vector<AudioSample> src = sineOfPeriod(120000, 441.0);
CHECK(!detectPeriod(src, 44100, 120001, 10).valid());
CHECK(!detectPeriod(src, 44100, 119000, 5000).valid());
CHECK(!detectPeriod(src, 44100, 0, 0).valid());
}
// --- 6. What the load pays ------------------------------------------------------------------
// The whole reason a detector is affordable in a sampler is that it runs ONCE, off the audio
// thread, on a source that is already fully known. This prints what that once costs, and
// asserts the property that makes it safe: the cost does NOT grow with the sample length —
// a fixed number of fixed-size probes is analysed however long the capture is. Meaningful
// only in a Release build; asserted as a RATIO so it holds at either optimization level.
static void testDetectionCostIsBoundedRegardlessOfSampleLength() {
double shortMs = 0.0, longMs = 0.0;
for (std::size_t frames : {std::size_t{220500}, std::size_t{4410000}}) { // 5 s and 100 s
const std::vector<AudioSample> src = sineOfPeriod(frames, 441.0);
const int reps = 5;
const auto t0 = std::chrono::steady_clock::now();
double guard = 0.0;
for (int r = 0; r < reps; ++r) guard += detectPeriod(src, 44100).frames;
const double ms =
1000.0 * std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count()
/ reps;
CHECK(guard > 0.0);
std::printf(" [measure] detectPeriod over %7.1f s of source: %.3f ms\n",
static_cast<double>(frames) / 44100.0, ms);
(frames == 220500 ? shortMs : longMs) = ms;
}
// 20x the source for well under 2x the cost — the probes are fixed-size and fixed in
// number, so the only length dependence left is the cache behaviour of reaching further
// into the buffer.
CHECK(longMs < shortMs * 2.0 + 0.5);
}
int main() {
testPureTonePeriodIsFoundToBetterThanATenthOfAFrame();
testTheEstimateIsInSourceFramesSoTheRateOnlyMovesTheBand();
testHarmonicRichSourceReportsTheRepeatPeriodNotAPartial();
testMissingFundamentalStillReportsTheRepeatPeriod();
testNoiseSilenceAndAPeriodChangeAllReportNone();
testAPercussiveDecayIsNotForcedIntoAPeriod();
testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple();
testAShortSourceShortensTheSearchRatherThanRefusing();
testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe();
testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected();
testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand();
testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop();
testAnOutOfRangeSpanEstimatesNothing();
testDetectionCostIsBoundedRegardlessOfSampleLength();
if (g_fail == 0) {
std::printf("all period_detect tests passed\n");
return 0;
}
std::printf("%d period_detect check(s) failed\n", g_fail);
return 1;
}
+242 -12
View File
@@ -30,11 +30,17 @@
// 8. stereo linked lag (Q-W0 T1-01) — a follower channel driven via processLinked() mirrors // 8. stereo linked lag (Q-W0 T1-01) — a follower channel driven via processLinked() mirrors
// the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on // the master's splice decision (jump/lag/frac/fadeLen AND firing frame) exactly, on
// decorrelated stereo content where an independent per-channel search provably diverges. // decorrelated stereo content where an independent per-channel search provably diverges.
// 10. pitch-synchronous splices — the nominal jump snapped to a whole number of source
// periods: the jump law itself, the bit-identical unknown-period fallback, 30 Hz and
// 29 Hz (the two symptoms of the unalignable gap), and the cadence corner, which this
// leaves where it found it.
#include "../src/core/instrument/engine/pitch_shift.h" #include "../src/core/instrument/engine/pitch_shift.h"
#include "energy_outside_fundamental.h" #include "tone_metrics.h"
#include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <vector> #include <vector>
@@ -595,14 +601,17 @@ static void testStereoLinkedLagSharedSchedule() {
// source frame due on an output frame go through writeFrame (no output), the last through // source frame due on an output frame go through writeFrame (no output), the last through
// process(); an output frame with none due takes processNoInput(). Returns the output plus, // process(); an output frame with none due takes processNoInput(). Returns the output plus,
// via `consumed`, how much source it ate. // via `consumed`, how much source it ate.
// `sourcePeriod` > 0 puts the shifter on the pitch-synchronous jump the loader would have
// given it; 0 (the default) is the fixed-window fallback every pre-PSOLA call here exercises.
static std::vector<double> runStretch(const std::vector<AudioSample>& src, std::int64_t w, static std::vector<double> runStretch(const std::vector<AudioSample>& src, std::int64_t w,
double feedRate, double shift, std::size_t outFrames, double feedRate, double shift, std::size_t outFrames,
std::size_t* consumed) { std::size_t* consumed, double sourcePeriod = 0.0) {
PitchShifter ps; PitchShifter ps;
ps.configure(w); ps.configure(w);
ps.prime(src.data(), w); ps.prime(src.data(), w);
ps.setShiftRatio(shift); ps.setShiftRatio(shift);
ps.setFeedRate(feedRate); ps.setFeedRate(feedRate);
ps.setSourcePeriod(sourcePeriod);
std::size_t pos = static_cast<std::size_t>(w); std::size_t pos = static_cast<std::size_t>(w);
double debt = 0.0; double debt = 0.0;
std::vector<double> out(outFrames); std::vector<double> out(outFrames);
@@ -746,12 +755,23 @@ static void testStretchAndShiftComposeSafely() {
// observable here: an investigation (test_preserve_low_frequency.cpp) found the P=500 // observable here: an investigation (test_preserve_low_frequency.cpp) found the P=500
// render's FUNDAMENTAL within 0.03% of target by autocorrelation and spectral peak alike, // 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 // 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 tracks the actual // the count cannot tell from a real detune. Energy outside the fundamental is measured here
// damage instead: measured here (same rate/shift/source, this file's own metric parameters) // (same rate/shift/source, this file's own metric parameters) at 7.31% / 14.41% / 21.22% for
// at 7.31% / 14.41% / 21.22% for P=500/600/700, against 0.10% on an alignable control (P=200, // P=500/600/700, against 0.10% on an alignable control (P=200, below the safe floor) at the
// below the safe floor) at the same rate and shift — so that is what this asserts: a known, // same rate and shift. Those readings are stable and are what the bounds below hold.
// characterised property of the range, not a pass/fail on a period estimate. A failure on //
// either bound below is a finding — report it, don't retune the thresholds to hide it. // **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.
//
// 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
// splice is pitch-synchronous is the test immediately after this one.
static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() { static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() {
using reasampler::test_support::energyOutsideFundamentalPercent; using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205; const std::int64_t w = 2205;
@@ -760,10 +780,11 @@ static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() {
const std::size_t outFrames = 60000; const std::size_t outFrames = 60000;
const std::size_t from = 20000, len = 32768; const std::size_t from = 20000, len = 32768;
// Below the safe floor (P > 315 frames): the cadence inequality predicts real damage, // Below the safe floor (P > 315 frames): per the header's CORRECTED note, this raw reading
// measured at 7-21% (see above). The threshold (5%) sits above the alignable control's // is mostly the metric's own mainlobe-leakage floor, not cadence damage (the real excess is
// near-zero floor and under the observed range, so it discriminates a genuine cadence hit // ~0.2-0.5%, asserted in testCadenceCornerIsUnmovedByAPitchSynchronousSplice). The bounds
// from a clean render; the ceiling (30%) is a generous margin above the highest measured // 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. // value, there to catch a much worse regression rather than to chase today's exact number.
for (double period : {500.0, 600.0, 700.0}) { for (double period : {500.0, 600.0, 700.0}) {
const double f0 = 1.0 / period; const double f0 = 1.0 / period;
@@ -804,6 +825,210 @@ static void testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter() {
} }
} }
// --- 10. Pitch-synchronous splices: the nominal jump is a whole number of SOURCE periods. ---
// The out-of-band metric's OWN floor at a given period: a mathematically perfect tone, measured
// with exactly the parameters a render is. A long period under a fixed segment leaks part of
// its own mainlobe outside the +/-6% band, and that leakage grows steeply with the period — so
// a raw reading at period 2800 is not comparable with one at period 800, and neither is
// comparable with zero. The EXCESS over this floor is the honest "how much of this render is
// not the tone" number.
static double idealToneFloorPercent(double wantPeriod, std::size_t from, std::size_t len) {
std::vector<double> v(from + len + 2);
for (std::size_t i = 0; i < v.size(); ++i) {
v[i] = std::sin(2.0 * kPi * static_cast<double>(i) / wantPeriod);
}
return reasampler::test_support::energyOutsideFundamentalPercent(v, from, len, wantPeriod);
}
// A splice can only phase-align on a landing point a whole number of source periods away, and
// the search only reaches [0.75, 1.25] windows. periodAlignedJump is what puts an aligned point
// at the CENTRE of that interval instead of hoping one falls inside it.
static void testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound() {
const std::int64_t w = 2205; // the product window at 44.1k
const std::int64_t maxJump = 2756; // 1.25 * w, the shifter's own jumpMax_
// Unknown period, and a period too long for even ONE whole one to fit: the fixed window,
// unchanged. Both are the documented fallback, and both must be EXACTLY today's geometry.
CHECK(periodAlignedJump(w, maxJump, 0.0) == w);
CHECK(periodAlignedJump(w, maxJump, -5.0) == w);
CHECK(periodAlignedJump(w, maxJump, 3000.0) == w);
// 30 Hz at 44.1k (P = 1470): two periods overshoot the bound, so it takes ONE — which is
// the case the whole track exists for. The pre-PSOLA geometry could reach neither 1470 nor
// 2940 from a 2205 nominal, since the search only spans [1654, 2756].
CHECK(periodAlignedJump(w, maxJump, 1470.0) == 1470);
CHECK(2 * 1470 > maxJump); // the witness that one period is forced, not merely chosen
// 220 Hz (P = 200.4545): eleven periods land within a frame of the window itself, so the
// splice cadence is essentially untouched while every landing is aligned.
CHECK(periodAlignedJump(w, maxJump, 44100.0 / 220.0) == 2205);
// A period just under the bound is taken whole; the result is never over the bound, at any
// period in the band. Sweeping is what proves the shrink loop terminates correctly rather
// than one hand-picked value doing so.
for (double p = 20.0; p < 3200.0; p += 0.37) {
const std::int64_t j = periodAlignedJump(w, maxJump, p);
CHECK(j >= 1);
if (p > static_cast<double>(maxJump)) {
CHECK(j == w); // out of reach -> fallback
} else {
CHECK(j <= maxJump);
// Aligned: the jump is a whole number of periods, to within the rounding to frames.
const double n = static_cast<double>(j) / p;
CHECK(std::fabs(n - std::floor(n + 0.5)) * p < 0.51);
}
}
}
// setSourcePeriod(0) and never calling it are the same state, not merely similar ones — the
// cheap half of the fallback claim. The EXPENSIVE half, that the fallback still matches the
// engine as it shipped, is testPreserveUnityRateIsBitIdenticalToTheShippedRead in
// test_sampler_core.cpp: it hashes four rendered streams (including transposed ones that
// really splice) against a baseline captured from commit 0a7778b, and it passes unmodified.
static void testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry() {
const std::int64_t w = 2205;
const std::vector<AudioSample> src = sine(400000, 400000.0 / 196.37);
const std::vector<double> never = runStretch(src, w, 1.0, 1.5, 40000, nullptr);
const std::vector<double> zeroed = runStretch(src, w, 1.0, 1.5, 40000, nullptr, 0.0);
bool same = true;
for (std::size_t i = 0; i < never.size(); ++i) if (never[i] != zeroed[i]) same = false;
CHECK(same);
}
// THE case this track exists for. 30 Hz sits in the only unalignable gap above 16 Hz at the
// product's 50 ms window: its nearest whole multiple misses the reachable interval by 184
// frames (45 degrees of phase), and the investigation measured the resulting sidebands at
// 3.57% out-of-band at +2 st / rate 1.0 and 15.45% at rate 2.0, against 0.00% on alignable
// controls. Here the same two conditions run with and without the source period, against a
// 34 Hz control that was alignable all along.
//
// The absolute numbers are NOT the harness's: a 1470-frame period under a 32768-frame segment
// leaks part of its own mainlobe outside the +/-6% band, so every reading here carries the same
// floor. That is exactly why the control is measured at the same length — the assertion is that
// 30 Hz reaches the control's floor, not that it reaches zero.
static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() {
using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205;
const std::size_t srcLen = 400000, outFrames = 60000, from = 20000, len = 32768;
struct Row { const char* label; double freq; double rate; double semis; };
const Row rows[] = {
{"30 Hz +2 st, rate 1.0", 30.0, 1.0, 2.0},
{"30 Hz rate 2.0", 30.0, 2.0, 0.0},
{"34 Hz +2 st, rate 1.0", 34.0, 1.0, 2.0}, // control: alignable without a period
{"34 Hz rate 2.0", 34.0, 2.0, 0.0},
};
double controlWorst = 0.0, subjectWorst = 0.0, subjectOffWorst = 0.0;
for (const Row& r : rows) {
const double period = 44100.0 / r.freq;
std::vector<AudioSample> src(srcLen);
for (std::size_t i = 0; i < srcLen; ++i) {
src[i] = static_cast<AudioSample>(
std::sin(2.0 * kPi * static_cast<double>(i) / period));
}
const double shift = std::pow(2.0, r.semis / 12.0);
const double want = period / shift;
const std::vector<double> off = runStretch(src, w, r.rate, shift, outFrames, nullptr);
const std::vector<double> on =
runStretch(src, w, r.rate, shift, outFrames, nullptr, period);
for (double v : on) CHECK(std::isfinite(v));
const double floor = idealToneFloorPercent(want, from, len);
const double pctOff = energyOutsideFundamentalPercent(off, from, len, want) - floor;
const double pctOn = energyOutsideFundamentalPercent(on, from, len, want) - floor;
std::printf(" [30 Hz] %-24s (want %6.1f fr, metric floor %.2f%%) excess energy: "
"fixed window %6.2f%% -> pitch-synchronous %6.2f%%\n", r.label, want, floor,
pctOff, pctOn);
if (r.freq == 34.0) {
controlWorst = std::max(controlWorst, pctOn);
} else {
subjectWorst = std::max(subjectWorst, pctOn);
subjectOffWorst = std::max(subjectOffWorst, pctOff);
}
}
// 30 Hz stops being a special case: with the period known its excess over the metric's own
// floor is no worse than the alignable neighbour's, measured identically. Against the
// control rather than against a fixed number, so the assertion cannot be satisfied by a
// change that merely raised the floor everywhere.
std::printf(" [30 Hz] worst subject excess %.2f%% vs worst control excess %.2f%%\n",
subjectWorst, controlWorst);
CHECK(subjectWorst < 0.10);
CHECK(subjectWorst <= controlWorst + 0.05); // 0.05 absorbs the floor subtraction's sign noise
// Vacuity guard, matching testTwentyNineHertzAtRateTwoKeepsItsPitch's sibling check: the
// FIXED-WINDOW (no period set) arm is asserted too, so a setSourcePeriod that silently did
// nothing would render both arms identically and fail here rather than passing on the
// absolute bound above by luck.
CHECK(subjectOffWorst > subjectWorst + 1.0);
}
// The sharpest single symptom of the geometry: at 29 Hz the nearest multiple misses the
// reachable interval ONE-SIDED rather than straddling, so the per-splice phase steps stop
// cancelling and accumulate into a real detune — the investigation measured -133 cents at
// rate 2.0 with NO transposition at all. Rate moves duration; it must not move pitch.
static void testTwentyNineHertzAtRateTwoKeepsItsPitch() {
using reasampler::test_support::autocorrelationPeriod;
const std::int64_t w = 2205;
const double period = 44100.0 / 29.0; // 1520.7 frames
const std::size_t srcLen = 400000;
std::vector<AudioSample> src(srcLen);
for (std::size_t i = 0; i < srcLen; ++i) {
src[i] = static_cast<AudioSample>(std::sin(2.0 * kPi * static_cast<double>(i) / period));
}
auto centsOf = [&](double sourcePeriod) {
const std::vector<double> out =
runStretch(src, w, /*rate=*/2.0, /*shift=*/1.0, 60000, nullptr, sourcePeriod);
const double got = autocorrelationPeriod(out, 20000, 20000,
static_cast<std::int64_t>(period * 0.5),
static_cast<std::int64_t>(period * 1.7));
return 1200.0 * std::log2(got / period);
};
const double centsOff = centsOf(0.0);
const double centsOn = centsOf(period);
std::printf(" [29 Hz] rate 2.0, no transposition: fixed window %+.1f cents -> "
"pitch-synchronous %+.1f cents\n", centsOff, centsOn);
CHECK(std::fabs(centsOn) < 10.0);
// The fixed-window reading is asserted too, and that is what makes the pair non-vacuous: a
// setSourcePeriod that silently did nothing would render both identically and fail here.
CHECK(std::fabs(centsOff) > 50.0);
}
// 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%.
//
// 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.
static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
using reasampler::test_support::energyOutsideFundamentalPercent;
const std::int64_t w = 2205;
const double shift = std::pow(2.0, -24.0 / 12.0);
const std::size_t outFrames = 60000, from = 20000, len = 32768;
for (double period : {500.0, 600.0, 700.0}) {
const std::size_t srcLen = 400000;
std::vector<AudioSample> src(srcLen);
for (std::size_t i = 0; i < srcLen; ++i) {
src[i] = static_cast<AudioSample>(
std::sin(2.0 * kPi * static_cast<double>(i) / period));
}
const std::vector<double> off = runStretch(src, w, 2.0, shift, outFrames, nullptr);
const std::vector<double> on =
runStretch(src, w, 2.0, shift, outFrames, nullptr, period);
for (double v : on) CHECK(std::isfinite(v));
const double want = period / shift;
const double floor = idealToneFloorPercent(want, from, len);
const double pctOff = energyOutsideFundamentalPercent(off, from, len, want);
const double pctOn = energyOutsideFundamentalPercent(on, from, len, want);
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(pctOn < pctOff + 0.05); // and PSOLA costs it nothing
}
}
// The two new entry points on a shifter that was never configured (a Varispeed voice's) — // The two new entry points on a shifter that was never configured (a Varispeed voice's) —
// neither may touch the empty ring. // neither may touch the empty ring.
static void testStretchEntryPointsOnPassThrough() { static void testStretchEntryPointsOnPassThrough() {
@@ -826,6 +1051,11 @@ int main() {
testStretchMovesDurationNotPitch(); testStretchMovesDurationNotPitch();
testStretchAndShiftComposeSafely(); testStretchAndShiftComposeSafely();
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter(); testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound();
testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry();
testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown();
testTwentyNineHertzAtRateTwoKeepsItsPitch();
testCadenceCornerIsUnmovedByAPitchSynchronousSplice();
testStretchEntryPointsOnPassThrough(); testStretchEntryPointsOnPassThrough();
if (g_fail == 0) { if (g_fail == 0) {
+72 -48
View File
@@ -14,6 +14,11 @@
// the load-bearing metric is energy outside it. Zero-crossing counting in particular reports a // the load-bearing metric is energy outside it. Zero-crossing counting in particular reports a
// wrong period on renders whose fundamental is provably correct, which is section C. // wrong period on renders whose fundamental is provably correct, which is section C.
// //
// It now runs every frequency-dependent section TWICE — once with splices falling back to the
// fixed window (the behaviour every number above was measured on) and once pitch-synchronous,
// with the period detected from the PCM exactly as the loader would. The two columns differ in
// that one thing, so the comparison needs no second binary and no remembered baseline.
//
// Measures, at both 44.1k and 48k geometry: // Measures, at both 44.1k and 48k geometry:
// A. the reachable relocation interval, observed rather than derived (jump/lag/frac off // A. the reachable relocation interval, observed rather than derived (jump/lag/frac off
// every SpliceEvent), and the alignment-reachability predicate over frequency. // every SpliceEvent), and the alignment-reachability predicate over frequency.
@@ -24,9 +29,11 @@
// D. a window sweep at 30 Hz — what a larger window would buy, and what it would cost. // D. a window sweep at 30 Hz — what a larger window would buy, and what it would cost.
// E. alignable frequencies under identical conditions, without which D and B have no scale. // E. alignable frequencies under identical conditions, without which D and B have no scale.
#include "../src/core/instrument/engine/period_detect.h"
#include "../src/core/instrument/engine/pitch_shift.h" #include "../src/core/instrument/engine/pitch_shift.h"
#include "../src/core/instrument/engine/time_stretch.h" #include "../src/core/instrument/engine/time_stretch.h"
#include "../src/core/instrument/engine/voice.h" #include "../src/core/instrument/engine/voice.h"
#include "tone_metrics.h"
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
@@ -44,12 +51,20 @@ static int g_fail = 0;
constexpr double kPi = 3.14159265358979323846; constexpr double kPi = 3.14159265358979323846;
// Whether a source carries its detected period into the shifter — i.e. whether splices are
// pitch-synchronous or fall back to the fixed window. Every section below runs under whichever
// is set, so main() can drive the SAME measurements both ways from one binary and the two
// columns are comparable by construction.
static bool g_pitchSynchronous = true;
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
// Source + render helpers // Source + render helpers
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
// A pure sine at `freqHz`, phase-continuous, long enough that a rate-2.0 render never // A pure sine at `freqHz`, phase-continuous, long enough that a rate-2.0 render never
// exhausts it (the caller sizes `frames`). // exhausts it (the caller sizes `frames`). The period is DETECTED rather than computed from
// freqHz on purpose: that is the number the loader would actually hand the engine, so the
// measurement includes any detector error rather than assuming it away.
static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames, static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames,
PitchEngine engine, double phase = 0.0) { PitchEngine engine, double phase = 0.0) {
SampleData s; SampleData s;
@@ -61,6 +76,7 @@ static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames,
s.sampleRate = sampleRate; s.sampleRate = sampleRate;
s.rootNote = 60; s.rootNote = 60;
s.play.pitchEngine = engine; // Gate, no loop, default (fully open) AHDSR s.play.pitchEngine = engine; // Gate, no loop, default (fully open) AHDSR
if (g_pitchSynchronous) s.sourcePeriodFrames = detectPeriod(s.frames, sampleRate).frames;
return s; return s;
} }
@@ -162,37 +178,10 @@ static double medianResidual(const std::vector<double>& v, std::size_t from, std
return r[mid]; return r[mid];
} }
// Period of the highest normalized-autocorrelation peak over [minLag, maxLag] — a pitch // A pitch estimator that, unlike zero-crossing counting, is not fooled by a low-level fast
// estimator that, unlike zero-crossing counting, is not fooled by a low-level fast component // component adding spurious crossings. The two disagreeing is itself the diagnosis. Shared with
// adding spurious crossings. The two disagreeing is itself the diagnosis. // the gated tests (tone_metrics.h) so there is one estimator and not two.
static double autocorrPeriod(const std::vector<double>& v, std::size_t from, std::size_t len, using reasampler::test_support::autocorrelationPeriod;
std::int64_t minLag, std::int64_t maxLag) {
double e0 = 0.0;
for (std::size_t k = 0; k < len && from + k < v.size(); ++k) e0 += v[from + k] * v[from + k];
if (e0 <= 0.0) return 0.0;
double best = -1e18; std::int64_t bestLag = 0;
std::vector<double> score(static_cast<std::size_t>(maxLag - minLag + 1), 0.0);
for (std::int64_t lag = minLag; lag <= maxLag; ++lag) {
double s = 0.0, e = 0.0;
for (std::size_t k = 0; k < len && from + k + static_cast<std::size_t>(lag) < v.size();
++k) {
const double b = v[from + k + static_cast<std::size_t>(lag)];
s += v[from + k] * b;
e += b * b;
}
const double r = e > 0.0 ? s / std::sqrt(e0 * e) : 0.0;
score[static_cast<std::size_t>(lag - minLag)] = r;
if (r > best) { best = r; bestLag = lag; }
}
// Parabolic refinement so the estimate isn't quantized to whole frames.
const std::size_t i = static_cast<std::size_t>(bestLag - minLag);
double frac = 0.0;
if (i > 0 && i + 1 < score.size()) {
const double den = score[i - 1] - 2.0 * score[i] + score[i + 1];
if (den < 0.0) frac = 0.5 * (score[i - 1] - score[i + 1]) / den;
}
return static_cast<double>(bestLag) + frac;
}
// The five strongest spectral peaks over a Hann-windowed segment, scanned on a fine period // The five strongest spectral peaks over a Hann-windowed segment, scanned on a fine period
// grid (Goertzel-style direct evaluation, no FFT-bin quantization). Prints period in frames // grid (Goertzel-style direct evaluation, no FFT-bin quantization). Prints period in frames
@@ -264,24 +253,28 @@ struct SpliceStats {
double minReloc = 1e18, maxReloc = -1e18; double minReloc = 1e18, maxReloc = -1e18;
std::int64_t minLag = 1LL << 40, maxLag = -(1LL << 40); std::int64_t minLag = 1LL << 40, maxLag = -(1LL << 40);
double meanInterval = 0.0; double meanInterval = 0.0;
bool jumpAlwaysNominal = true; // |jump| == window on every splice (steady state) bool jumpAlwaysNominal = true; // |jump| == the nominal on every splice (steady state)
std::int64_t nominalJump = 0; // what the shifter itself resolved the nominal to
}; };
// Drives a bare PitchShifter over the same feed schedule Voice uses, recording every splice. // Drives a bare PitchShifter over the same feed schedule Voice uses, recording every splice.
// The audio is not kept — this measures the DECISIONS, not the sound. // The audio is not kept — this measures the DECISIONS, not the sound.
static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int64_t window, static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int64_t window,
double rate, double shift, std::size_t outFrames, double rate, double shift, std::size_t outFrames,
std::vector<double>* audio = nullptr) { std::vector<double>* audio = nullptr,
double sourcePeriod = 0.0) {
PitchShifter ps; PitchShifter ps;
ps.configure(window); ps.configure(window);
ps.prime(src.data(), window); ps.prime(src.data(), window);
ps.setShiftRatio(shift); ps.setShiftRatio(shift);
ps.setFeedRate(rate); ps.setFeedRate(rate);
ps.setSourcePeriod(sourcePeriod);
StretchCursor cur; StretchCursor cur;
cur.start(window); cur.start(window);
loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through
SpliceStats st; SpliceStats st;
st.nominalJump = ps.spliceJump();
if (audio != nullptr) audio->assign(outFrames, 0.0); if (audio != nullptr) audio->assign(outFrames, 0.0);
std::size_t lastSpliceAt = 0; std::size_t lastSpliceAt = 0;
double intervalSum = 0.0; double intervalSum = 0.0;
@@ -308,7 +301,7 @@ static SpliceStats spliceGeometry(const std::vector<AudioSample>& src, std::int6
if (reloc > st.maxReloc) st.maxReloc = reloc; if (reloc > st.maxReloc) st.maxReloc = reloc;
if (ev.lag < st.minLag) st.minLag = ev.lag; if (ev.lag < st.minLag) st.minLag = ev.lag;
if (ev.lag > st.maxLag) st.maxLag = ev.lag; if (ev.lag > st.maxLag) st.maxLag = ev.lag;
if (std::llabs(ev.jump) != window) st.jumpAlwaysNominal = false; if (std::llabs(ev.jump) != st.nominalJump) st.jumpAlwaysNominal = false;
if (lastSpliceAt != 0) { intervalSum += static_cast<double>(i - lastSpliceAt); ++intervals; } if (lastSpliceAt != 0) { intervalSum += static_cast<double>(i - lastSpliceAt); ++intervals; }
lastSpliceAt = i; lastSpliceAt = i;
} }
@@ -503,24 +496,29 @@ static void measureRow(const char* label, double freqHz, int sr, std::int64_t wi
const double resid = medianResidual(out, from, to, wantCpf); const double resid = medianResidual(out, from, to, wantCpf);
std::vector<AudioSample> src(s.frames.begin(), s.frames.end()); std::vector<AudioSample> src(s.frames.begin(), s.frames.end());
const SpliceStats st = spliceGeometry(src, window, rate, shift, outFrames); const SpliceStats st =
spliceGeometry(src, window, rate, shift, outFrames, nullptr, s.sourcePeriodFrames);
const double lo = static_cast<double>(window - window / 4); const double lo = static_cast<double>(window - window / 4);
const double hi = static_cast<double>(window + window / 4); const double hi = static_cast<double>(window + window / 4);
int n = 0; int n = 0;
// Reachability of the FIXED-window interval. With a source period known this is no longer
// the binding question — the nominal jump is a multiple of the period by construction —
// but it stays reported because it is what the "NO" rows below were diagnosed by.
const bool reach = alignmentReachable(srcPeriod, lo, hi, &n); const bool reach = alignmentReachable(srcPeriod, lo, hi, &n);
// Effective frequency error implied by the drift, and the phase step it works out to per // Effective frequency error implied by the drift, and the phase step it works out to per
// splice — the number that says whether a splice is stepping the phase or not. // splice — the number that says whether a splice is stepping the phase or not.
const double driftPerSplice = st.count > 0 ? drift / static_cast<double>(st.count) : 0.0; const double driftPerSplice = st.count > 0 ? drift / static_cast<double>(st.count) : 0.0;
std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | period got %8.2f want %8.2f " std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | P det %8.2f jump %5lld | "
"(%+.2f%%) | splices %4lld every %7.0f fr | phase drift %+8.3f cyc " "period got %8.2f want %8.2f (%+.2f%%) | splices %4lld every %7.0f fr | "
"(%+7.1f deg/splice, worst step %.1f deg) | resid %.4f | peak %.3f | " "phase drift %+8.3f cyc (%+7.1f deg/splice, worst step %.1f deg) | "
"align %s%s\n", "resid %.4f | peak %.3f | fixed-window align %s%s\n",
label, freqHz, shift, rate, gotPeriod, wantPeriod, label, freqHz, shift, rate, s.sourcePeriodFrames,
static_cast<long long>(st.nominalJump), gotPeriod, wantPeriod,
wantPeriod > 0.0 ? 100.0 * (gotPeriod - wantPeriod) / wantPeriod : 0.0, wantPeriod > 0.0 ? 100.0 * (gotPeriod - wantPeriod) / wantPeriod : 0.0,
st.count, st.meanInterval, drift, 360.0 * driftPerSplice, 360.0 * worstStep, st.count, st.meanInterval, drift, 360.0 * driftPerSplice, 360.0 * worstStep,
resid, peak, reach ? "YES" : "NO", resid, peak, reach ? "YES" : "NO",
reach ? "" : " <-- no whole period in the reachable interval"); reach ? "" : " <-- no whole period in the fixed-window reachable interval");
CHECK(finite); CHECK(finite);
} }
@@ -536,7 +534,7 @@ static void deepDive(const char* label, double freqHz, int sr, std::int64_t wind
const double zc = periodIn(out, 40000, 280000); const double zc = periodIn(out, 40000, 280000);
// Search bounded to [0.5, 1.7] x the wanted period: a pure sine autocorrelates equally at // Search bounded to [0.5, 1.7] x the wanted period: a pure sine autocorrelates equally at
// EVERY multiple of its period, so an unbounded search reports 2P about half the time. // EVERY multiple of its period, so an unbounded search reports 2P about half the time.
const double ac = autocorrPeriod(out, 60000, 60000, const double ac = autocorrelationPeriod(out, 60000, 60000,
std::max<std::int64_t>(40, std::max<std::int64_t>(40,
static_cast<std::int64_t>(wantPeriod * 0.5)), static_cast<std::int64_t>(wantPeriod * 0.5)),
static_cast<std::int64_t>(wantPeriod * 1.7)); static_cast<std::int64_t>(wantPeriod * 1.7));
@@ -617,10 +615,11 @@ static void reportFloorProbeMechanism() {
std::sin(2.0 * kPi * static_cast<double>(i) / period)); std::sin(2.0 * kPi * static_cast<double>(i) / period));
} }
std::vector<double> out; std::vector<double> out;
const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out); const SpliceStats st = spliceGeometry(src, w, rate, shift, 60000, &out,
g_pitchSynchronous ? period : 0.0);
const double want = period / shift; const double want = period / shift;
const double zc = periodIn(out, 20000, 50000); const double zc = periodIn(out, 20000, 50000);
const double ac = autocorrPeriod(out, 20000, 20000, const double ac = autocorrelationPeriod(out, 20000, 20000,
static_cast<std::int64_t>(want * 0.5), static_cast<std::int64_t>(want * 0.5),
static_cast<std::int64_t>(want * 1.7)); static_cast<std::int64_t>(want * 1.7));
std::printf(" P=%.0f: zero-crossing %.2f (%+.2f%%) | autocorrelation %.2f (%+.2f%%) " std::printf(" P=%.0f: zero-crossing %.2f (%+.2f%%) | autocorrelation %.2f (%+.2f%%) "
@@ -681,14 +680,39 @@ static void reportAlignableControls() {
deepDive("220 Hz rate 2.0", 220.0, 44100, 2205, 60, 2.0); deepDive("220 Hz rate 2.0", 220.0, 44100, 2205, 60, 2.0);
} }
int main() { // The frequency-dependent sections, run under whichever splice geometry is set. Everything
reportReachableInterval(); // that can differ between the two is in here; section A (the reachable interval, measured on
reportReachabilityByFrequency(); // noise) and the reachability arithmetic are properties of the fixed-window search alone and
// run once.
static void runFrequencySections() {
testRootRateUnityIsBitIdenticalToTheDirectRead(); testRootRateUnityIsBitIdenticalToTheDirectRead();
reportTransposedAt30Hz(); reportTransposedAt30Hz();
reportFrequencySweep(); reportFrequencySweep();
reportFloorProbeMechanism(); reportFloorProbeMechanism();
reportAlignableControls(); reportAlignableControls();
}
int main() {
reportReachableInterval();
reportReachabilityByFrequency();
// The same measurements twice, from one binary, so the two columns differ in exactly one
// thing. The FIXED-WINDOW pass reproduces the pre-PSOLA engine — it is the baseline every
// number in the investigation was taken against.
g_pitchSynchronous = false;
std::printf("\n\n##################################################################\n");
std::printf("### FIXED-WINDOW splices (no source period) — the prior behaviour ###\n");
std::printf("##################################################################\n");
runFrequencySections();
g_pitchSynchronous = true;
std::printf("\n\n##################################################################\n");
std::printf("### PITCH-SYNCHRONOUS splices (detected source period) ###\n");
std::printf("##################################################################\n");
runFrequencySections();
// The window sweep is about what a LARGER WINDOW would buy, which was the alternative to
// this track. Run under the shipped geometry only.
reportWindowSweep(); reportWindowSweep();
if (g_fail == 0) { if (g_fail == 0) {
+58
View File
@@ -909,6 +909,62 @@ static void testBuildSampleDataEmptyPcmIsUnplayable() {
CHECK(sd.frames.empty()); CHECK(sd.frames.empty());
} }
// Major-1 remediation: buildSampleData (sample_map.cpp:333-334) is the ONE call site wiring
// load-time detection to SampleData; detectPeriod's own unit coverage (test_period_detect.cpp)
// never exercises this call, so a deleted wire passed the gated suite unnoticed. Goes through
// the real build, not a direct detectPeriod call.
static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() {
const int rate = 44100;
const std::size_t frames = 30000;
std::vector<AudioSample> pcm(frames);
for (std::size_t i = 0; i < frames; ++i) {
pcm[i] = static_cast<float>(
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, {}});
CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz
}
// The span half of the same wire: buildSampleData must hand detection the LOOP region when the
// capture carries one, not the whole decoded PCM. Asserted through the real build for the same
// reason as the test above — period_detect's own coverage cannot see which span the loader picks.
static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() {
const int rate = 44100;
const std::size_t frames = 120000;
const std::int64_t loopStart = 60000;
const double kPi = 3.14159265358979323846;
const double loopPeriod = static_cast<double>(rate) / 30.0; // 1470 frames
// Head at 147 Hz, looped tail at 30 Hz: analysed whole, the probes split two-and-two and
// detection correctly refuses. Analysed over the loop, the 30 Hz sustain is unambiguous.
std::vector<AudioSample> pcm(frames);
double phase = 0.0;
for (std::size_t i = 0; i < frames; ++i) {
phase += 2.0 * kPi / (i < static_cast<std::size_t>(loopStart) ? 300.0 : loopPeriod);
pcm[i] = static_cast<float>(std::sin(phase));
}
InstrumentParams noLoop;
const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop),
DecodedPcm{pcm, 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, {}});
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
// to none — here that whole source has no one period, so the answer is the bare one above.
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, {}});
CHECK(shortSd.sourcePeriodFrames == bare.sourcePeriodFrames);
}
static void testBuildSampleDataCarriesTheVelocityCurve() { static void testBuildSampleDataCarriesTheVelocityCurve() {
InstrumentParams p; InstrumentParams p;
p.velocityCurve = VelocityCurve::linear(); p.velocityCurve = VelocityCurve::linear();
@@ -976,6 +1032,8 @@ int main() {
testBuildSampleDataDropsMismatchedSecondChannel(); testBuildSampleDataDropsMismatchedSecondChannel();
testBuildSampleDataEmptyPcmIsUnplayable(); testBuildSampleDataEmptyPcmIsUnplayable();
testBuildSampleDataCarriesTheVelocityCurve(); testBuildSampleDataCarriesTheVelocityCurve();
testBuildSampleDataDetectsThirtyHertzSourcePeriod();
testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource();
if (g_fail == 0) std::printf("sample_map: all tests passed\n"); if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0; return g_fail != 0;
+47 -6
View File
@@ -2991,6 +2991,25 @@ static void testPreserveUnityRateIsBitIdenticalToTheShippedRead() {
} }
} }
// --- The load->voice wire is load-bearing: Voice::start (voice.cpp:248-249) is the only place
// a detected period reaches the shifter. Assert the two SampleDatas actually render
// differently rather than only that setSourcePeriod is callable — dropping either of the
// wire's two lines left this whole gated suite green before this test existed.
static void testSourcePeriodChangesTheRenderedStream() {
const std::int64_t w = 2205; // the product window at 44.1k
const std::size_t n = 6000;
SampleData off = stretchProbeSample(20000, false);
SampleData on = off;
// 30 Hz @ 44.1k: periodAlignedJump takes ONE whole period (1470), which cannot fit twice
// inside the shifter's reachable jump bound — a different splice geometry from the fixed
// 2205-frame window, so a wire failure here cannot pass by coincidence.
on.sourcePeriodFrames = 1470.0;
std::vector<AudioSample> lOff(n), lOn(n), rUnused;
renderVoice(off, /*note=*/67, /*rate=*/1.0, w, false, lOff, rUnused); // +7 st: real splices
renderVoice(on, 67, 1.0, w, false, lOn, rUnused);
CHECK(hashStream(lOff) != hashStream(lOn));
}
// --- Rate changes DURATION only; the transposition alone sets pitch. --- // --- Rate changes DURATION only; the transposition alone sets pitch. ---
static void testPreserveStretchChangesDurationNotPitch() { static void testPreserveStretchChangesDurationNotPitch() {
// Gate, no loop: the voice's life is exactly how long the source lasts, so the frame at // Gate, no loop: the voice's life is exactly how long the source lasts, so the frame at
@@ -3214,7 +3233,10 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() {
const std::size_t blockFrames = 44100; // one second of audio const std::size_t blockFrames = 44100; // one second of audio
const std::size_t voiceCount = 32; const std::size_t voiceCount = 32;
const int kWarmupReps = 2; const int kWarmupReps = 2;
const int kTimedReps = 7; // 5 rather than 7: the source-period rows below doubled the row count, and run-to-run
// spread on this machine is ~5% either way, so the extra reps bought precision the number
// does not carry while costing the Debug gate real seconds.
const int kTimedReps = 5;
SampleData s = stretchProbeSample(200000, true); SampleData s = stretchProbeSample(200000, true);
s.loop.hasLoop = true; // held notes: all 32 sound for the whole run s.loop.hasLoop = true; // held notes: all 32 sound for the whole run
s.loop.start = 40000; s.loop.start = 40000;
@@ -3223,7 +3245,24 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() {
// 1.0 is the reference: it is the cost the shipped Preserve read already carries, so the // 1.0 is the reference: it is the cost the shipped Preserve read already carries, so the
// two stretched rows are read as a delta against it rather than in isolation. // two stretched rows are read as a delta against it rather than in isolation.
for (double rate : {1.0, 0.5, 2.0}) { //
// The last two rows carry a SOURCE PERIOD, which is where the pitch-synchronous splice can
// cost something: the jump becomes a whole number of periods, and when that is shorter than
// the window the splice cadence rises in proportion — more correlation searches per second.
// 1470 (30 Hz at 44.1k) is the worst realistic case in the audible band, forcing a jump of
// 2/3 the window and therefore 1.5x the searches. 220.5 (200 Hz) is the typical one: ten
// periods land exactly on the window, so the cadence is unchanged and the row should read
// as the no-period one — which is the measurement that separates "the mechanism costs
// something" from "a shorter jump costs something". There is no per-frame cost either way:
// the jump is resolved once at note-on.
struct Row { double rate; double period; };
const Row rows[] = {
{1.0, 0.0}, {0.5, 0.0}, {2.0, 0.0},
{1.0, 220.5}, {1.0, 1470.0}, {2.0, 1470.0},
};
for (const Row& row : rows) {
const double rate = row.rate;
s.sourcePeriodFrames = row.period;
std::vector<double> nsPerVoiceFrame; std::vector<double> nsPerVoiceFrame;
nsPerVoiceFrame.reserve(kTimedReps); nsPerVoiceFrame.reserve(kTimedReps);
for (int rep = 0; rep < kWarmupReps + kTimedReps; ++rep) { for (int rep = 0; rep < kWarmupReps + kTimedReps; ++rep) {
@@ -3263,10 +3302,11 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() {
const double medianNs = nsPerVoiceFrame[nsPerVoiceFrame.size() / 2]; const double medianNs = nsPerVoiceFrame[nsPerVoiceFrame.size() / 2];
const double secsAtMedian = const double secsAtMedian =
medianNs * static_cast<double>(blockFrames) * static_cast<double>(voiceCount) / 1e9; medianNs * static_cast<double>(blockFrames) * static_cast<double>(voiceCount) / 1e9;
std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: median %.1f ns/voice/" std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f, source period %6.1f: "
"frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime at the median)\n", "median %.1f ns/voice/frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime "
rate, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), kTimedReps, "at the median)\n",
100.0 * secsAtMedian); rate, row.period, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(),
kTimedReps, 100.0 * secsAtMedian);
} }
} }
@@ -3398,6 +3438,7 @@ int main() {
// The Preserve read path's stretch generalization. // The Preserve read path's stretch generalization.
testPreserveUnityRateIsBitIdenticalToTheShippedRead(); testPreserveUnityRateIsBitIdenticalToTheShippedRead();
testSourcePeriodChangesTheRenderedStream();
testPreserveStretchChangesDurationNotPitch(); testPreserveStretchChangesDurationNotPitch();
testPreserveStretchSpeaksOnFrameZeroAtEveryRate(); testPreserveStretchSpeaksOnFrameZeroAtEveryRate();
testPreserveStretchLoopsTheSourceSpan(); testPreserveStretchLoopsTheSourceSpan();
+93
View File
@@ -0,0 +1,93 @@
#pragma once
// The two tone metrics the Preserve tests and the hand-run low-frequency harness share, so
// there is one of each rather than a copy per file.
//
// Zero-crossing counting is deliberately NOT among them: splice debris adds spurious crossings
// that make that estimator anti-correlated with severity (a render can read a badly wrong
// PERIOD while it is spectrally clean, or vice versa).
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <vector>
namespace reasampler::test_support {
// Percentage (0..100) of the segment [from, from+len)'s spectral energy that falls outside
// +/- 6% of `wantPeriod` (frames), evaluated directly on a geometric period grid (no FFT-bin
// quantization). 0 = a clean single tone at that period; higher values mean harmonics,
// splice-cadence sidebands, or crossfade cancellation debris are present.
//
// `grid` trades resolution for cost: the gated tests run the default, the hand-run harness
// raises it. The value is NOT comparable across grid sizes or segment lengths — a long period
// under a short segment leaks part of its own mainlobe outside the +/-6% band, so a reading is
// only meaningful against a control measured at the SAME len and grid.
inline double energyOutsideFundamentalPercent(const std::vector<double>& v, std::size_t from,
std::size_t len, double wantPeriod,
int grid = 400) {
constexpr double kPi = 3.14159265358979323846;
const int kGrid = grid;
const double pLo = 30.0, pHi = 8000.0;
std::vector<double> mag(static_cast<std::size_t>(kGrid));
std::vector<double> per(static_cast<std::size_t>(kGrid));
for (int g = 0; g < kGrid; ++g) {
// Geometric grid: constant relative resolution across the swept period range.
const double p = pLo * std::pow(pHi / pLo, static_cast<double>(g) / (kGrid - 1));
per[static_cast<std::size_t>(g)] = p;
double re = 0.0, im = 0.0;
const double w = 2.0 * kPi / p;
for (std::size_t k = 0; k < len && from + k < v.size(); ++k) {
const double hann = 0.5 * (1.0 - std::cos(2.0 * kPi * static_cast<double>(k) /
static_cast<double>(len)));
const double x = v[from + k] * hann;
re += x * std::cos(w * static_cast<double>(k));
im += x * std::sin(w * static_cast<double>(k));
}
mag[static_cast<std::size_t>(g)] = std::sqrt(re * re + im * im);
}
double eTotal = 0.0, eFund = 0.0;
for (int g = 0; g < kGrid; ++g) {
const std::size_t i = static_cast<std::size_t>(g);
const double e = mag[i] * mag[i];
eTotal += e;
if (std::fabs(per[i] - wantPeriod) / wantPeriod < 0.06) eFund += e;
}
return eTotal > 0.0 ? 100.0 * (1.0 - eFund / eTotal) : 0.0;
}
// Period (frames) of the highest normalized-autocorrelation peak over [minLag, maxLag], with a
// parabolic refinement so the answer is not quantized to whole frames. Bracket the caller's
// range to roughly [0.5, 1.7] x the expected period: a pure tone autocorrelates equally at
// EVERY multiple of its period, so an unbounded search reports 2P about half the time.
inline double autocorrelationPeriod(const std::vector<double>& v, std::size_t from,
std::size_t len, std::int64_t minLag, std::int64_t maxLag) {
if (maxLag <= minLag) return 0.0;
double e0 = 0.0;
for (std::size_t k = 0; k < len && from + k < v.size(); ++k) e0 += v[from + k] * v[from + k];
if (e0 <= 0.0) return 0.0;
std::vector<double> score(static_cast<std::size_t>(maxLag - minLag + 1), 0.0);
double best = -1e18;
std::int64_t bestLag = minLag;
for (std::int64_t lag = minLag; lag <= maxLag; ++lag) {
double s = 0.0, e = 0.0;
for (std::size_t k = 0; k < len && from + k + static_cast<std::size_t>(lag) < v.size();
++k) {
const double b = v[from + k + static_cast<std::size_t>(lag)];
s += v[from + k] * b;
e += b * b;
}
const double r = e > 0.0 ? s / std::sqrt(e0 * e) : 0.0;
score[static_cast<std::size_t>(lag - minLag)] = r;
if (r > best) { best = r; bestLag = lag; }
}
const std::size_t i = static_cast<std::size_t>(bestLag - minLag);
double frac = 0.0;
if (i > 0 && i + 1 < score.size()) {
const double den = score[i - 1] - 2.0 * score[i] + score[i + 1];
if (den < 0.0) frac = 0.5 * (score[i - 1] - score[i + 1]) / den;
}
return static_cast<double>(bestLag) + frac;
}
} // namespace reasampler::test_support