Γ-W1-T7: make Preserve's splices pitch-synchronous — the jump is a whole number of the source's own period, detected once at load
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.
This commit is contained in:
@@ -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.
|
- `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.
|
||||||
- `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 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`.
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -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,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 <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) {
|
||||||
|
if (sampleRate <= 0 || pcm.empty()) 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 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<std::size_t>(kPeriodDetectProbes, std::max<std::size_t>(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<double> periods;
|
||||||
|
std::vector<double> 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<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 {};
|
||||||
|
|
||||||
|
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.
|
||||||
|
if (agree * 2 <= periods.size()) return {};
|
||||||
|
|
||||||
|
PeriodEstimate est;
|
||||||
|
est.frames = sum / static_cast<double>(agree);
|
||||||
|
est.confidence = confSum / static_cast<double>(agree);
|
||||||
|
return est;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine
|
||||||
@@ -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 <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)
|
||||||
|
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<AudioSample>& pcm, int sampleRate);
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
// 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. 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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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
|
// 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.
|
||||||
|
data.sourcePeriodFrames =
|
||||||
|
instrument::engine::detectPeriod(data.frames, data.sampleRate).frames;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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 <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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<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();
|
||||||
|
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;
|
||||||
|
}
|
||||||
+228
-8
@@ -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;
|
||||||
@@ -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<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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// 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<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 +1041,11 @@ int main() {
|
|||||||
testStretchMovesDurationNotPitch();
|
testStretchMovesDurationNotPitch();
|
||||||
testStretchAndShiftComposeSafely();
|
testStretchAndShiftComposeSafely();
|
||||||
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
|
testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter();
|
||||||
|
testPeriodAlignedJumpSnapsToWholePeriodsWithinTheReachableBound();
|
||||||
|
testAnUnknownPeriodIsBitIdenticalToTheFixedWindowGeometry();
|
||||||
|
testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown();
|
||||||
|
testTwentyNineHertzAtRateTwoKeepsItsPitch();
|
||||||
|
testCadenceCornerIsUnmovedByAPitchSynchronousSplice();
|
||||||
testStretchEntryPointsOnPassThrough();
|
testStretchEntryPointsOnPassThrough();
|
||||||
|
|
||||||
if (g_fail == 0) {
|
if (g_fail == 0) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -3214,7 +3214,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 +3226,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 +3283,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user