From 93230208ff9669b33c9d0f44be536d626c40ac3c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 23:19:16 -0400 Subject: [PATCH] =?UTF-8?q?=CE=93-W1-T7:=20make=20Preserve's=20splices=20p?= =?UTF-8?q?itch-synchronous=20=E2=80=94=20the=20jump=20is=20a=20whole=20nu?= =?UTF-8?q?mber=20of=20the=20source's=20own=20period,=20detected=20once=20?= =?UTF-8?q?at=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 30 Hz out-of-band energy 15.45% -> 0.00%; the 29 Hz rate-2.0 detune -133 -> +0 cents. An unknown period keeps the fixed-window geometry bit for bit. The detector cannot reach process(): sampler_core does not link it. --- src/core/instrument/CLAUDE.md | 12 + src/core/instrument/engine/CMakeLists.txt | 11 +- src/core/instrument/engine/period_detect.cpp | 203 ++++++++++++++ src/core/instrument/engine/period_detect.h | 54 ++++ src/core/instrument/engine/pitch_shift.cpp | 47 +++- src/core/instrument/engine/pitch_shift.h | 38 ++- src/core/instrument/engine/play_params.h | 8 + src/core/instrument/engine/time_stretch.h | 35 ++- src/core/instrument/engine/voice.cpp | 6 + src/core/instrument/map/CMakeLists.txt | 3 +- src/core/instrument/map/sample_map.cpp | 7 + tests/energy_outside_fundamental.h | 50 ---- tests/test_period_detect.cpp | 266 +++++++++++++++++++ tests/test_pitch_shift.cpp | 236 +++++++++++++++- tests/test_preserve_low_frequency.cpp | 120 +++++---- tests/test_sampler_core.cpp | 33 ++- tests/tone_metrics.h | 93 +++++++ 17 files changed, 1085 insertions(+), 137 deletions(-) create mode 100644 src/core/instrument/engine/period_detect.cpp create mode 100644 src/core/instrument/engine/period_detect.h delete mode 100644 tests/energy_outside_fundamental.h create mode 100644 tests/test_period_detect.cpp create mode 100644 tests/tone_metrics.h diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 055c9e4..e4d5757 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -289,6 +289,18 @@ anything for a trigger shape. - `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter AND time-stretcher for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. - **The WRITE rate (duration) and the TAP rate (pitch) are independent, and that is the whole time-stretcher** — `writeFrame` for a surplus source frame, `processNoInput` for a starved output frame, plain `process` for the 1:1 case, `setShiftRatio` for pitch, and `setFeedRate` so the splice crossfade is sized against the real drain rate. The header owns the argument, including why this is not the resampled-read-with-a-cancelling-shift the `WDL_Resampler` invariant above forbids. + - **Splices are PITCH-SYNCHRONOUS when the source's period is known** (`setSourcePeriod`, fed from `period_detect` via the loader): the nominal jump becomes the multiple of that period nearest the window that still fits the ring's jump bound (~1.25 windows), so an aligned landing point sits at the CENTRE of the correlation search instead of possibly not existing inside it at all. The search is unchanged and still earns its keep — it absorbs the jump's rounding to whole frames and tracks a source whose period drifts. **An unknown period restores the fixed-window geometry byte for byte**; do not "simplify" that fallback into an approximation of it. +- `period_detect` — the source's own fundamental period, estimated ONCE per load (two-pass YIN: + a decimated cumulative-mean-normalized difference picks the period, the full-rate difference + function refines it to a fraction of a frame), so `pitch_shift`'s splice jump can be a whole + number of it. **It runs off the audio thread BY LINK GRAPH: `sampler_core` does not link it**, + so no TU on the render path can name `detectPeriod` — the same shape as the extension's link + graph not gaining the voice engine. Its one caller is the loader (`map/sample_map`'s + `buildSampleData`), which hands the answer down on `SampleData::sourcePeriodFrames`. A period + is DERIVED from the audio, so it is cache and not state: nothing persists it, and it takes no + rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony, + percussion and a source whose period changes mid-sample all return it, and the shifter's + fixed-window geometry is the documented fallback. - `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 Fritsch–Carlson 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. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 96e4d39..5bb6a60 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -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 chain. reasampler_test(pitch_shift LINK pitch_shift) +# Deliberately NOT linked by sampler_core, and that omission is the structural proof the +# detector cannot run on the audio thread: no TU on the render path can name detectPeriod +# without failing to link in sampler_core_tests, which links sampler_core and nothing else. +# Its one caller is the loader (map/sample_map), which runs off-thread by construction. +reasampler_pure_library(period_detect SOURCES period_detect.cpp LINK PUBLIC peaks) +reasampler_test(period_detect LINK period_detect) + reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp) # Links only velocity_curve, deliberately not editor_geometry: the proof the engine can # depend on the curve without inheriting the editor's layout types. @@ -58,7 +65,9 @@ reasampler_test(staged_envelopes LINK sampler_core) # Release, when the question is what Preserve does to a given frequency. add_executable(preserve_low_frequency_tests ${REASAMPLER_TESTS_DIR}/test_preserve_low_frequency.cpp) -target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core) +# period_detect beside sampler_core, not through it: the harness plays the role the loader +# does, which is exactly the seam under measurement. +target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect) # The Preserve read's source-feed schedule — the TIME half beside pitch_shift's PITCH half. # Header-only (it sits on the per-sample feed), hence INTERFACE. diff --git a/src/core/instrument/engine/period_detect.cpp b/src/core/instrument/engine/period_detect.cpp new file mode 100644 index 0000000..266e414 --- /dev/null +++ b/src/core/instrument/engine/period_detect.cpp @@ -0,0 +1,203 @@ +// 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 +#include +#include + +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 decimate(const std::vector& src, std::size_t from, + std::size_t count) { + std::vector 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(src[from + i * kDecimate + static_cast(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 cmndf(const std::vector& x, std::size_t W, std::size_t lagHi) { + std::vector 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(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& 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& 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(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& pcm, std::size_t from, std::size_t W, + std::size_t lo, std::size_t hi) { + std::vector 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(pcm[from + j]) - + static_cast(pcm[from + j + tau]); + s += diff * diff; + } + d[tau - lo] = s; + } + const std::size_t best = + static_cast(std::min_element(d.begin(), d.end()) - d.begin()); + return static_cast(lo + best) + parabolicOffset(d, best); +} + +double blockRms(const std::vector& 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(pcm[from + i]); + e += x * x; + } + return std::sqrt(e / static_cast(count)); +} + +} // namespace + +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) { + if (sampleRate <= 0 || pcm.empty()) return {}; + const double rate = static_cast(sampleRate); + std::size_t lagHi = static_cast(rate / kPeriodDetectMinHz); + const std::size_t lagLo = static_cast(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 sample shortens the search + // rather than refusing outright — a 200 ms one-shot still has a period worth finding. + if (pcm.size() < 2 * lagHi) lagHi = pcm.size() / 2; + if (lagHi <= lagLo + 2) return {}; + const std::size_t block = 2 * lagHi; + const std::size_t probes = + std::min(kPeriodDetectProbes, std::max(1, pcm.size() / block)); + // Room to spare after the last probe's block is spread between them, so the probes sample + // the whole sample rather than only its opening. + const std::size_t stride = probes > 1 ? (pcm.size() - block) / (probes - 1) : 0; + + std::vector periods; + std::vector confidences; + for (std::size_t p = 0; p < probes; ++p) { + const std::size_t from = p * stride; + if (from + block > pcm.size()) break; + if (blockRms(pcm, from, block) < kSilenceRms) continue; + + const std::vector 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 dp = cmndf(small, smallW, smallHi); + + double coarseTau = 0.0, dissimilarity = 1.0; + if (!pickPeriod(dp, std::max(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::max(static_cast(lagLo), centre - 2.0 * kDecimate)); + const std::size_t hi = static_cast( + std::min(static_cast(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 {}; + + std::vector 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. + if (agree * 2 <= periods.size()) return {}; + + PeriodEstimate est; + est.frames = sum / static_cast(agree); + est.confidence = confSum / static_cast(agree); + return est; +} + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/period_detect.h b/src/core/instrument/engine/period_detect.h new file mode 100644 index 0000000..515c0ff --- /dev/null +++ b/src/core/instrument/engine/period_detect.h @@ -0,0 +1,54 @@ +#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 +#include + +#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) + double confidence = 0.0; // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 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. +inline constexpr int kPeriodDetectProbes = 4; +inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median + +// Estimates `pcm`'s fundamental period at `sampleRate`. Cost is bounded by the constants above, +// not by the sample length: at most kPeriodDetectProbes blocks of ~2 x the longest searched lag +// are analysed however long the source is. Allocates; never call from process(). +// +// 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. +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate); + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/pitch_shift.cpp b/src/core/instrument/engine/pitch_shift.cpp index 39db550..34f46e4 100644 --- a/src/core/instrument/engine/pitch_shift.cpp +++ b/src/core/instrument/engine/pitch_shift.cpp @@ -4,8 +4,9 @@ // frame the caller feeds; the active read tap advances by the shift `ratio_` per OUTPUT frame, // so its delay behind the writer drifts at (feedRate - ratio) per frame — one frame in, one // frame out (`feedRate == 1`) preserves duration, and any other feed cadence stretches it. When -// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of -// one window — clamped to the filled span so it never lands in unwritten silence — refined +// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump (one +// window, or the nearest whole number of source periods to it once setSourcePeriod names one) +// — clamped to the filled span so it never lands in unwritten silence — refined // by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a // sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the // splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames @@ -26,6 +27,23 @@ constexpr double kPi = 3.14159265358979323846; } // namespace +std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames, + double periodFrames) { + if (windowFrames <= 1 || maxJumpFrames < 1) return windowFrames; + if (!(periodFrames > 0.0)) return windowFrames; + if (periodFrames > static_cast(maxJumpFrames)) return windowFrames; + std::int64_t n = static_cast( + static_cast(windowFrames) / periodFrames + 0.5); + if (n < 1) n = 1; + std::int64_t jump = static_cast(periodFrames * static_cast(n) + 0.5); + while (jump > maxJumpFrames && n > 1) { + --n; + jump = static_cast(periodFrames * static_cast(n) + 0.5); + } + if (jump < 1 || jump > maxJumpFrames) return windowFrames; + return jump; +} + void PitchShifter::configure(std::int64_t windowFrames) { window_ = windowFrames; if (window_ <= 1) { @@ -37,6 +55,8 @@ void PitchShifter::configure(std::int64_t windowFrames) { fading_ = false; fadePos_ = 0; fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0; + period_ = 0.0; + jump_ = jumpMax_ = 0; filled_ = 0; ratio_ = 1.0; feedRate_ = 1.0; @@ -62,6 +82,9 @@ void PitchShifter::configure(std::int64_t windowFrames) { dLow_ = window_ / 4; dHigh_ = ringLen_ - window_ / 4; corrFrames_ = std::max(1, std::min(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(1, dHigh_ - dLow_ - maxLag_ - 1); fadeLen_ = 0; reset(); } @@ -88,10 +111,17 @@ void PitchShifter::reset() { filled_ = 0; ratio_ = 1.0; feedRate_ = 1.0; + period_ = 0.0; + jump_ = window_ > 1 ? window_ : 0; tailFrozen_ = false; lastSplice_ = SpliceEvent{}; } +void PitchShifter::setSourcePeriod(double periodFrames) { + period_ = periodFrames > 0.0 ? periodFrames : 0.0; + jump_ = window_ > 1 ? periodAlignedJump(window_, jumpMax_, period_) : 0; +} + void PitchShifter::freezeTail() { if (window_ <= 1 || tailFrozen_) return; tailFrozen_ = true; @@ -199,7 +229,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) { std::int64_t jump = nominalJump; if (jump > 0) { const std::int64_t maxJump = filled_ - d - maxLag_ - 1; - if (jump > maxJump) jump = maxJump; + // Shortening a period-aligned jump to fit must land on a SHORTER MULTIPLE, not on the + // raw bound — a clamped jump is an unaligned one, which is the whole failure this + // module now avoids. With no period known (or none fitting) this is the bare clamp. + if (jump > maxJump) jump = periodAlignedJump(maxJump, maxJump, period_); if (jump < 1) jump = 1; } // The correlation reference reads FORWARD from the tap; keep it strictly behind the @@ -379,9 +412,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, while (d < 0.0) d += len; while (d >= len) d -= len; if (d <= static_cast(dLow_)) { - splice(+window_, d); + splice(+jump_, d); } else if (d >= static_cast(dHigh_)) { - splice(-window_, d); + splice(-jump_, d); } } } else { @@ -394,9 +427,9 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked, while (d < 0.0) d += len; while (d >= len) d -= len; if (d <= static_cast(dLow_)) { - splice(+window_, d); + splice(+jump_, d); } else if (d >= static_cast(dHigh_)) { - splice(-window_, d); + splice(-jump_, d); } } diff --git a/src/core/instrument/engine/pitch_shift.h b/src/core/instrument/engine/pitch_shift.h index 832a181..72ae0fb 100644 --- a/src/core/instrument/engine/pitch_shift.h +++ b/src/core/instrument/engine/pitch_shift.h @@ -2,8 +2,10 @@ // pitch_shift — per-voice pitch shifter and time-stretcher (the Preserve engine's DSP core). // Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap // chases the write head at the shift ratio; when it drifts out of its safe delay band it is -// relocated by a nominal window jump, refined by a cross-correlation search so the new read -// point is waveform-aligned, then old/new taps crossfade (raised-cosine). +// relocated by a nominal jump, refined by a cross-correlation search so the new read point is +// waveform-aligned, then old/new taps crossfade (raised-cosine). The nominal jump is a whole +// number of the SOURCE's own periods when setSourcePeriod names one (pitch-synchronous OLA), +// and the fixed window otherwise. // // The WRITE rate (how fast source is consumed = duration) and the TAP rate (setShiftRatio = // pitch) are INDEPENDENT, and only their difference drives the splice cadence. Feeding 1:1 via @@ -61,6 +63,21 @@ struct SpliceEvent { std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen }; +// The nominal splice jump for a source whose period is known: the multiple of `periodFrames` +// nearest `windowFrames` that still fits `maxJumpFrames`. Falls back to `windowFrames` — the +// pre-PSOLA geometry, exactly — whenever the period is unknown (<= 0) or too long for even one +// whole period to fit, which is the documented degradation for inharmonic, polyphonic, +// percussive and noise sources. +// +// Why this is the whole fix: a splice can only phase-align on a landing point that is a whole +// number of source periods away, and the correlation search only reaches [0.75, 1.25] windows. +// Periods with no multiple in that one interval — f < ~16 Hz, and 26.7-32 Hz at a 50 ms +// window — could never align, however good the search was. Making the NOMINAL a multiple puts +// an aligned point at the centre of the search rather than hoping one falls inside it. The +// jump is rounded to whole frames; the search's own sub-sample refinement absorbs the residue. +std::int64_t periodAlignedJump(std::int64_t windowFrames, std::int64_t maxJumpFrames, + double periodFrames); + // A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked: // channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the // two rings stay sample-aligned. @@ -101,6 +118,17 @@ public: // Values <= 0 are ignored. Exactly 1.0 reproduces the 1:1 geometry bit for bit. void setFeedRate(double rate); + // The period of the source being fed, in SOURCE frames, making every splice jump a whole + // number of it (see periodAlignedJump). <= 0 means "unknown" and restores the fixed-window + // geometry byte for byte — the default, so a caller that never calls this sees no change. + // Detection itself is off-thread and elsewhere (period_detect, which the engine deliberately + // does not link); this is a couple of divisions and is safe to call at note-on. + // Cleared by configure()/reset(); NOT by prime()/warm(), which do not change the source. + void setSourcePeriod(double periodFrames); + + // The nominal jump splices currently use — window() unless a source period narrowed it. + std::int64_t spliceJump() const { return jump_; } + // Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the // pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise // writes `in` at the write head, reads the active tap (crossfading against the outgoing tap @@ -180,6 +208,12 @@ private: // ratio-scaled at splice time so an up-shift's outgoing // tap can never drain into the writer mid-fade std::int64_t maxLag_ = 0; // correlation search half-range (window_/4) + double period_ = 0.0; // source period in frames, 0 = unknown (fixed-window) + std::int64_t jump_ = 0; // nominal splice jump; window_ unless period_ narrows it + std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY + // inside [dLow_, dHigh_] at the worst search lag, so a + // period-sized jump can never land back on a trigger and + // thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_) std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so // the reference read forward from the tap stays behind // the writer by construction at an up-splice) diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index 1ba35f1..6fd2af8 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -264,6 +264,14 @@ struct SampleData { // (never per frame). Default flat y=1 — every velocity plays at unity. VelocityCurve velocityCurve = VelocityCurve::flat(); + // The source's own fundamental period in SOURCE frames, which makes Preserve's splices + // pitch-synchronous (pitch_shift.h). DERIVED from the PCM at load, not authored and never + // persisted — a cache, not state, so it takes no rung of the payload ladder. 0 means + // unknown (nothing detected it, or the source has no single period) and restores the + // fixed-window splice geometry byte for byte, which is why a hand-built SampleData is + // still exactly the bare engine. + double sourcePeriodFrames = 0.0; + PlayParams play; // The live-parameter block a sounding voice tracks, or null for the bare latched engine diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index c661dae..d259fd8 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -32,20 +32,27 @@ namespace reasampler::instrument::engine { // actually fine. (The pre-stretch rate-1.0 engine's floor by the same inequality is P > 735, // ~60 Hz — what this range raises the floor from, not what it removes.) // -// A SECOND, INDEPENDENT limit binds the same material, and no rate bound touches it. A splice -// relocates the tap by the nominal window refined by a search over +/- window/4, so the -// reachable relocation distances are exactly [0.75, 1.25] * window; a phase-aligned splice -// needs a WHOLE NUMBER of source periods inside that one interval. The interval is 0.5*window -// wide, so any period <= window/2 always has a multiple in it — but above that, coverage -// breaks into disjoint bands (n=1 covers periods [0.75, 1.25]*window, n=2 covers -// [0.375, 0.625]*window) and the gap between them is reachable by nothing. Because both the -// interval and the period scale with the sample rate, the unalignable set is fixed in Hz by -// the window's MILLISECONDS: at 50 ms that is f < 16 Hz and 26.7 Hz < f < 32 Hz. Measured -// (Release, 44.1k and 48k) at 30 Hz: the rendered pitch stays correct, but energy outside the -// fundamental is 3.6% at +2 st / rate 1.0 and 15.5% at rate 2.0, against 0.00% at 34 Hz under -// identical conditions; at 29 Hz / rate 2.0 the tone itself lands 7.4% flat. Unlike the -// cadence inequality above, this one is not about how OFTEN a splice fires — a window of at -// least two source periods removes it outright, and nothing else does. +// A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now +// CLOSED for any source whose period is detected, but the geometry is worth keeping because it +// is what the fixed-window fallback still lives under. A splice relocated the tap by the +// nominal window refined by a search over +/- window/4, so the reachable relocation distances +// were exactly [0.75, 1.25] * window; a phase-aligned splice needs a WHOLE NUMBER of source +// periods inside that interval. The interval is 0.5*window wide, so any period <= window/2 +// always has a multiple in it — but above that, coverage breaks into disjoint bands (n=1 covers +// periods [0.75, 1.25]*window, n=2 covers [0.375, 0.625]*window) and the gap between them was +// reachable by nothing. Because both the interval and the period scale with the sample rate, +// that unalignable set is fixed in Hz by the window's MILLISECONDS: at 50 ms, f < 16 Hz and +// 26.7 Hz < f < 32 Hz. Measured there (Release, 44.1k and 48k) at 30 Hz: the rendered pitch +// stayed correct, but energy outside the fundamental was 3.6% at +2 st / rate 1.0 and 15.5% at +// rate 2.0, against 0.00% at 34 Hz under identical conditions; at 29 Hz / rate 2.0 the tone +// itself landed 7.4% flat (-133 cents). +// +// The fix is not a wider window: it is a nominal jump that is a whole number of the source's +// own periods, so an aligned landing point exists by construction (pitch_shift.h's +// periodAlignedJump, fed by period_detect at load). The same measurements then read 0.00% and +// 0.00%, and 29 Hz renders at +0.0 cents. What survives: a period longer than the reachable +// jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no +// single period falls back to this fixed-window geometry by design. inline constexpr double kStretchRateMin = 0.5; inline constexpr double kStretchRateMax = 2.0; inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 5badbc4..7da8710 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -241,6 +241,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick stretch_.start(p); shiftL_.setFeedRate(stretchRate_); shiftR_.setFeedRate(stretchRate_); + // Pitch-synchronous splices: the period was detected once at load (period_detect, + // which this library deliberately does not link — the loader hands the answer down on + // SampleData). 0 restores the fixed-window geometry, so a capture with no single + // period plays exactly as it always did. + shiftL_.setSourcePeriod(sample.sourcePeriodFrames); + shiftR_.setSourcePeriod(sample.sourcePeriodFrames); if (!loopWrap && primeCount < w) { // Sub-window playable span: the source is already exhausted at prime time. shiftL_.freezeTail(); diff --git a/src/core/instrument/map/CMakeLists.txt b/src/core/instrument/map/CMakeLists.txt index 91e80d7..ad5c39a 100644 --- a/src/core/instrument/map/CMakeLists.txt +++ b/src/core/instrument/map/CMakeLists.txt @@ -40,7 +40,8 @@ target_link_libraries(play_seconds INTERFACE velocity_curve peaks curve_law) reasampler_pure_library(sample_map SOURCES sample_map.cpp LINK PUBLIC bank_book wav_codec play_seconds velocity_curve peaks curve_law - musical_division) + musical_division + PRIVATE period_detect) # Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning # both halves of the mapping/codec split where the frozen-format assertions live. reasampler_test(sample_map LINK sample_map component_state_io) diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index edc116d..38f1e49 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -3,6 +3,8 @@ #include "core/instrument/map/sample_map.h" +#include "core/instrument/engine/period_detect.h" // the load-time Preserve source period + #include // std::remove_if #include // assert #include // std::move @@ -325,6 +327,11 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's // actual rate; source-timeline params (trigger %-length + fades, start) carry through. data.play = resolvePlay(resolved.play, data.sampleRate); + // The one place Preserve's source period is computed: the load, off the audio thread. + // Channel 0 only — a stereo pair's two channels share a fundamental, and the splice + // schedule is linked across them anyway. + data.sourcePeriodFrames = + instrument::engine::detectPeriod(data.frames, data.sampleRate).frames; return data; } diff --git a/tests/energy_outside_fundamental.h b/tests/energy_outside_fundamental.h deleted file mode 100644 index b71ba97..0000000 --- a/tests/energy_outside_fundamental.h +++ /dev/null @@ -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 -#include -#include - -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& 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 mag(static_cast(kGrid)); - std::vector per(static_cast(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(g) / (kGrid - 1)); - per[static_cast(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(k) / - static_cast(len))); - const double x = v[from + k] * hann; - re += x * std::cos(w * static_cast(k)); - im += x * std::sin(w * static_cast(k)); - } - mag[static_cast(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(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 diff --git a/tests/test_period_detect.cpp b/tests/test_period_detect.cpp new file mode 100644 index 0000000..b42b6d9 --- /dev/null +++ b/tests/test_period_detect.cpp @@ -0,0 +1,266 @@ +// 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. +// 5. what the load pays, and that it does not grow with the sample length. + +#include "../src/core/instrument/engine/period_detect.h" + +#include +#include +#include +#include +#include + +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 sineOfPeriod(std::size_t frames, double period, + double phase = 0.0) { + std::vector s(frames); + for (std::size_t i = 0; i < frames; ++i) { + s[i] = static_cast( + std::sin(2.0 * kPi * static_cast(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 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(rate) / 30.0; + const std::vector 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 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(i) / p) / h; + } + src[i] = static_cast(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 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(i) / p); + } + src[i] = static_cast(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 src(120000); + std::uint32_t rng = 22222u; + for (auto& x : src) { + rng = rng * 1664525u + 1013904223u; + x = static_cast((static_cast(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 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 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(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 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(rng >> 8) / 8388608.0) - 1.0; + lp += 0.25 * (n - lp); + const double env = std::exp(-static_cast(i % 22050) / 2000.0); + src[i] = static_cast(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 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 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()); +} + +// --- 5. 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 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(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(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(); + 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; +} diff --git a/tests/test_pitch_shift.cpp b/tests/test_pitch_shift.cpp index c63cf6c..393b1ff 100644 --- a/tests/test_pitch_shift.cpp +++ b/tests/test_pitch_shift.cpp @@ -30,11 +30,17 @@ // 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 // 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 "energy_outside_fundamental.h" +#include "tone_metrics.h" +#include #include +#include #include #include @@ -595,14 +601,17 @@ static void testStereoLinkedLagSharedSchedule() { // 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, // 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 runStretch(const std::vector& src, std::int64_t w, double feedRate, double shift, std::size_t outFrames, - std::size_t* consumed) { + std::size_t* consumed, double sourcePeriod = 0.0) { PitchShifter ps; ps.configure(w); ps.prime(src.data(), w); ps.setShiftRatio(shift); ps.setFeedRate(feedRate); + ps.setSourcePeriod(sourcePeriod); std::size_t pos = static_cast(w); double debt = 0.0; std::vector out(outFrames); @@ -746,12 +755,23 @@ static void testStretchAndShiftComposeSafely() { // 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, // 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 -// damage instead: measured here (same rate/shift/source, this file's own metric parameters) -// at 7.31% / 14.41% / 21.22% for P=500/600/700, against 0.10% on an alignable control (P=200, -// below the safe floor) at the same rate and shift — so that is what this asserts: a known, -// 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. +// the count cannot tell from a real detune. Energy outside the fundamental is measured here +// (same rate/shift/source, this file's own metric parameters) at 7.31% / 14.41% / 21.22% for +// P=500/600/700, against 0.10% on an alignable control (P=200, below the safe floor) at the +// same rate and shift. Those readings are stable and are what the bounds below hold. +// +// **CORRECTED — what those three readings MEAN.** They were once read as the corner's damage. +// They are almost entirely the metric's own floor: an ideal tone at the same want-period, +// measured identically, reads 7.08% / 13.90% / 20.92% (idealToneFloorPercent, below), because +// a long period under a 32768-frame segment leaks part of its own mainlobe outside the +/-6% +// band. The corner's real EXCESS over that floor is 0.23% / 0.51% / 0.30% — small, real, and +// nothing like the headline numbers. The alignable control's 0.10% is genuinely near-zero only +// because its want-period is short enough to have almost no floor. The bounds below are kept +// as a stable regression tripwire on the raw readings; read the excess, not the reading. +// +// 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() { using reasampler::test_support::energyOutsideFundamentalPercent; const std::int64_t w = 2205; @@ -804,6 +824,201 @@ 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 v(from + len + 2); + for (std::size_t i = 0; i < v.size(); ++i) { + v[i] = std::sin(2.0 * kPi * static_cast(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(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(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 src = sine(400000, 400000.0 / 196.37); + const std::vector never = runStretch(src, w, 1.0, 1.5, 40000, nullptr); + const std::vector 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; + for (const Row& r : rows) { + const double period = 44100.0 / r.freq; + std::vector src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * static_cast(i) / period)); + } + const double shift = std::pow(2.0, r.semis / 12.0); + const double want = period / shift; + const std::vector off = runStretch(src, w, r.rate, shift, outFrames, nullptr); + const std::vector 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); + } + // 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 +} + +// 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 src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / period)); + } + auto centsOf = [&](double sourcePeriod) { + const std::vector out = + runStretch(src, w, /*rate=*/2.0, /*shift=*/1.0, 60000, nullptr, sourcePeriod); + const double got = autocorrelationPeriod(out, 20000, 20000, + static_cast(period * 0.5), + static_cast(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 src(srcLen); + for (std::size_t i = 0; i < srcLen; ++i) { + src[i] = static_cast( + std::sin(2.0 * kPi * static_cast(i) / period)); + } + const std::vector off = runStretch(src, w, 2.0, shift, outFrames, nullptr); + const std::vector 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) — // neither may touch the empty ring. static void testStretchEntryPointsOnPassThrough() { @@ -826,6 +1041,11 @@ int main() { testStretchMovesDurationNotPitch(); testStretchAndShiftComposeSafely(); testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter(); + testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound(); + testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry(); + testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown(); + testTwentyNineHertzAtRateTwoKeepsItsPitch(); + testCadenceCornerIsUnmovedByAPitchSynchronousSplice(); testStretchEntryPointsOnPassThrough(); if (g_fail == 0) { diff --git a/tests/test_preserve_low_frequency.cpp b/tests/test_preserve_low_frequency.cpp index 7c02540..8888015 100644 --- a/tests/test_preserve_low_frequency.cpp +++ b/tests/test_preserve_low_frequency.cpp @@ -14,6 +14,11 @@ // 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. // +// 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: // A. the reachable relocation interval, observed rather than derived (jump/lag/frac off // 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. // 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/time_stretch.h" #include "../src/core/instrument/engine/voice.h" +#include "tone_metrics.h" #include #include @@ -44,12 +51,20 @@ static int g_fail = 0; 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 // --------------------------------------------------------------------------------------- // 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, PitchEngine engine, double phase = 0.0) { SampleData s; @@ -61,6 +76,7 @@ static SampleData sineSample(double freqHz, int sampleRate, std::size_t frames, s.sampleRate = sampleRate; s.rootNote = 60; s.play.pitchEngine = engine; // Gate, no loop, default (fully open) AHDSR + if (g_pitchSynchronous) s.sourcePeriodFrames = detectPeriod(s.frames, sampleRate).frames; return s; } @@ -162,37 +178,10 @@ static double medianResidual(const std::vector& v, std::size_t from, std return r[mid]; } -// Period of the highest normalized-autocorrelation peak over [minLag, maxLag] — a pitch -// estimator that, unlike zero-crossing counting, is not fooled by a low-level fast component -// adding spurious crossings. The two disagreeing is itself the diagnosis. -static double autocorrPeriod(const std::vector& v, std::size_t from, std::size_t len, - 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 score(static_cast(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(lag) < v.size(); - ++k) { - const double b = v[from + k + static_cast(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(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(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(bestLag) + frac; -} +// A pitch estimator that, unlike zero-crossing counting, is not fooled by a low-level fast +// component adding spurious crossings. The two disagreeing is itself the diagnosis. Shared with +// the gated tests (tone_metrics.h) so there is one estimator and not two. +using reasampler::test_support::autocorrelationPeriod; // 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 @@ -264,24 +253,28 @@ struct SpliceStats { double minReloc = 1e18, maxReloc = -1e18; std::int64_t minLag = 1LL << 40, maxLag = -(1LL << 40); 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. // The audio is not kept — this measures the DECISIONS, not the sound. static SpliceStats spliceGeometry(const std::vector& src, std::int64_t window, double rate, double shift, std::size_t outFrames, - std::vector* audio = nullptr) { + std::vector* audio = nullptr, + double sourcePeriod = 0.0) { PitchShifter ps; ps.configure(window); ps.prime(src.data(), window); ps.setShiftRatio(shift); ps.setFeedRate(rate); + ps.setSourcePeriod(sourcePeriod); StretchCursor cur; cur.start(window); loop::ResolvedLoop lp{}; // inactive: the source is long enough to run straight through SpliceStats st; + st.nominalJump = ps.spliceJump(); if (audio != nullptr) audio->assign(outFrames, 0.0); std::size_t lastSpliceAt = 0; double intervalSum = 0.0; @@ -308,7 +301,7 @@ static SpliceStats spliceGeometry(const std::vector& src, std::int6 if (reloc > st.maxReloc) st.maxReloc = reloc; if (ev.lag < st.minLag) st.minLag = 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(i - lastSpliceAt); ++intervals; } 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); std::vector 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(window - window / 4); const double hi = static_cast(window + window / 4); 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); // 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. const double driftPerSplice = st.count > 0 ? drift / static_cast(st.count) : 0.0; - std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | period got %8.2f want %8.2f " - "(%+.2f%%) | splices %4lld every %7.0f fr | phase drift %+8.3f cyc " - "(%+7.1f deg/splice, worst step %.1f deg) | resid %.4f | peak %.3f | " - "align %s%s\n", - label, freqHz, shift, rate, gotPeriod, wantPeriod, + std::printf(" %-26s f=%6.1f Hz shift=%.4f rate=%.2f | P det %8.2f jump %5lld | " + "period got %8.2f want %8.2f (%+.2f%%) | splices %4lld every %7.0f fr | " + "phase drift %+8.3f cyc (%+7.1f deg/splice, worst step %.1f deg) | " + "resid %.4f | peak %.3f | fixed-window align %s%s\n", + label, freqHz, shift, rate, s.sourcePeriodFrames, + static_cast(st.nominalJump), gotPeriod, wantPeriod, wantPeriod > 0.0 ? 100.0 * (gotPeriod - wantPeriod) / wantPeriod : 0.0, st.count, st.meanInterval, drift, 360.0 * driftPerSplice, 360.0 * worstStep, 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); } @@ -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); // 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. - const double ac = autocorrPeriod(out, 60000, 60000, + const double ac = autocorrelationPeriod(out, 60000, 60000, std::max(40, static_cast(wantPeriod * 0.5)), static_cast(wantPeriod * 1.7)); @@ -617,10 +615,11 @@ static void reportFloorProbeMechanism() { std::sin(2.0 * kPi * static_cast(i) / period)); } std::vector 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 zc = periodIn(out, 20000, 50000); - const double ac = autocorrPeriod(out, 20000, 20000, + const double ac = autocorrelationPeriod(out, 20000, 20000, static_cast(want * 0.5), static_cast(want * 1.7)); 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); } -int main() { - reportReachableInterval(); - reportReachabilityByFrequency(); +// The frequency-dependent sections, run under whichever splice geometry is set. Everything +// that can differ between the two is in here; section A (the reachable interval, measured on +// noise) and the reachability arithmetic are properties of the fixed-window search alone and +// run once. +static void runFrequencySections() { testRootRateUnityIsBitIdenticalToTheDirectRead(); reportTransposedAt30Hz(); reportFrequencySweep(); reportFloorProbeMechanism(); 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(); if (g_fail == 0) { diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index d593670..9ead4e7 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -3214,7 +3214,10 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() { const std::size_t blockFrames = 44100; // one second of audio const std::size_t voiceCount = 32; 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); s.loop.hasLoop = true; // held notes: all 32 sound for the whole run s.loop.start = 40000; @@ -3223,7 +3226,24 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() { // 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. - 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 nsPerVoiceFrame; nsPerVoiceFrame.reserve(kTimedReps); for (int rep = 0; rep < kWarmupReps + kTimedReps; ++rep) { @@ -3263,10 +3283,11 @@ static void testPreserveStretchThirtyTwoVoicesHoldUp() { const double medianNs = nsPerVoiceFrame[nsPerVoiceFrame.size() / 2]; const double secsAtMedian = medianNs * static_cast(blockFrames) * static_cast(voiceCount) / 1e9; - std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f: median %.1f ns/voice/" - "frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime at the median)\n", - rate, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), kTimedReps, - 100.0 * secsAtMedian); + std::printf(" [measure] 32 stereo Preserve voices @ rate %.2f, source period %6.1f: " + "median %.1f ns/voice/frame [%.1f .. %.1f] over %d reps (%.1f%% of realtime " + "at the median)\n", + rate, row.period, medianNs, nsPerVoiceFrame.front(), nsPerVoiceFrame.back(), + kTimedReps, 100.0 * secsAtMedian); } } diff --git a/tests/tone_metrics.h b/tests/tone_metrics.h new file mode 100644 index 0000000..2126894 --- /dev/null +++ b/tests/tone_metrics.h @@ -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 +#include +#include +#include +#include + +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& 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 mag(static_cast(kGrid)); + std::vector per(static_cast(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(g) / (kGrid - 1)); + per[static_cast(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(k) / + static_cast(len))); + const double x = v[from + k] * hann; + re += x * std::cos(w * static_cast(k)); + im += x * std::sin(w * static_cast(k)); + } + mag[static_cast(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(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& 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 score(static_cast(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(lag) < v.size(); + ++k) { + const double b = v[from + k + static_cast(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(lag - minLag)] = r; + if (r > best) { best = r; bestLag = lag; } + } + const std::size_t i = static_cast(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(bestLag) + frac; +} + +} // namespace reasampler::test_support