Close Γ-W1-T7 re-review: pitch-sync cadence math, floor-model regression check, evidence-count fix, one-home comments
New cadence-collapse-band test at P=1470 shows PSOLA eliminates the corner rather than regressing it (18.52% -> 0.00%).
This commit is contained in:
@@ -289,13 +289,13 @@ 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.
|
- **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 exactly** (`periodAlignedJump`, `pitch_shift.h`); 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:
|
- `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
|
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
|
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**,
|
number of it. **Runs off the audio thread by link graph** (`period_detect.h` is the one home
|
||||||
so no TU on the render path can name `detectPeriod` — the same shape as the extension's link
|
for that invariant) — the same shape as the extension's link graph not gaining the voice
|
||||||
graph not gaining the voice engine. Its one caller is the loader (`map/sample_map`'s
|
engine. Its one caller is the loader (`map/sample_map`'s
|
||||||
`buildSampleData`), which hands the answer down on `SampleData::sourcePeriodFrames`. A period
|
`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
|
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,
|
rung of the payload ladder. **Answering "none" is a first-class result** — noise, polyphony,
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ 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
|
# Deliberately NOT linked by sampler_core, enforcing period_detect.h's off-audio-thread
|
||||||
# detector cannot run on the audio thread: no TU on the render path can name detectPeriod
|
# invariant at build time: sampler_core_tests links sampler_core and nothing else, so no TU
|
||||||
# without failing to link in sampler_core_tests, which links sampler_core and nothing else.
|
# on the render path can name detectPeriod without failing to link.
|
||||||
# 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_pure_library(period_detect SOURCES period_detect.cpp LINK PUBLIC peaks)
|
||||||
reasampler_test(period_detect LINK period_detect)
|
reasampler_test(period_detect LINK period_detect)
|
||||||
|
|
||||||
@@ -69,6 +68,11 @@ add_executable(preserve_low_frequency_tests
|
|||||||
# does, which is exactly the seam under measurement.
|
# does, which is exactly the seam under measurement.
|
||||||
target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect)
|
target_link_libraries(preserve_low_frequency_tests PRIVATE sampler_core period_detect)
|
||||||
|
|
||||||
|
# Bridges the two structural proofs above (sample_map never links the voice engine;
|
||||||
|
# sampler_core never links period_detect) for the one case that needs both: a REAL detected
|
||||||
|
# period reaching a real Preserve render. Its own target rather than extending either.
|
||||||
|
reasampler_test(period_render_integration LINK sample_map sampler_core)
|
||||||
|
|
||||||
# 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.
|
||||||
add_library(time_stretch INTERFACE)
|
add_library(time_stretch INTERFACE)
|
||||||
|
|||||||
@@ -145,25 +145,27 @@ PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
|
|||||||
|
|
||||||
std::vector<double> periods;
|
std::vector<double> periods;
|
||||||
std::vector<double> confidences;
|
std::vector<double> confidences;
|
||||||
// Probes that carried signal — the agreement denominator. A silent block is no evidence
|
// Probes that carried signal AND ran the real dip search — the agreement denominator. A
|
||||||
// either way and is excluded; every other outcome, a period found or not, is evidence.
|
// silent block is no evidence either way; a block whose decimated search band or full-rate
|
||||||
|
// refine bracket collapsed to nothing (the two geometry continues below) never ran that
|
||||||
|
// search either, so it is excluded on the same footing as silence, not counted as if it had.
|
||||||
std::size_t evidence = 0;
|
std::size_t evidence = 0;
|
||||||
for (std::size_t p = 0; p < probes; ++p) {
|
for (std::size_t p = 0; p < probes; ++p) {
|
||||||
// (probes - 1) * stride <= room by construction, so the last block always fits.
|
// (probes - 1) * stride <= room by construction, so the last block always fits.
|
||||||
const std::size_t from = spanFrom + p * stride;
|
const std::size_t from = spanFrom + p * stride;
|
||||||
if (blockRms(pcm, from, block) < kSilenceRms) continue;
|
if (blockRms(pcm, from, block) < kSilenceRms) continue;
|
||||||
++evidence;
|
|
||||||
|
|
||||||
const std::vector<double> small = decimate(pcm, from, block);
|
const std::vector<double> small = decimate(pcm, from, block);
|
||||||
const std::size_t smallHi = lagHi / kDecimate;
|
const std::size_t smallHi = lagHi / kDecimate;
|
||||||
const std::size_t smallW = small.size() - smallHi;
|
const std::size_t smallW = small.size() - smallHi;
|
||||||
if (smallHi <= lagLo / kDecimate + 2 || smallW == 0) continue;
|
if (smallHi <= lagLo / kDecimate + 2 || smallW == 0) continue; // degenerate geometry
|
||||||
const std::vector<double> dp = cmndf(small, smallW, smallHi);
|
const std::vector<double> dp = cmndf(small, smallW, smallHi);
|
||||||
|
|
||||||
double coarseTau = 0.0, dissimilarity = 1.0;
|
double coarseTau = 0.0, dissimilarity = 1.0;
|
||||||
if (!pickPeriod(dp, std::max<std::size_t>(2, lagLo / kDecimate), coarseTau,
|
if (!pickPeriod(dp, std::max<std::size_t>(2, lagLo / kDecimate), coarseTau,
|
||||||
dissimilarity)) {
|
dissimilarity)) {
|
||||||
continue; // no dip below threshold: this block has no single period
|
++evidence; // the search ran and found no dip: real evidence against a period
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bracket the full-rate refinement at +/- 2 decimated samples around the coarse pick:
|
// Bracket the full-rate refinement at +/- 2 decimated samples around the coarse pick:
|
||||||
@@ -174,7 +176,8 @@ PeriodEstimate detectPeriod(const std::vector<AudioSample>& pcm, int sampleRate,
|
|||||||
std::max(static_cast<double>(lagLo), centre - 2.0 * kDecimate));
|
std::max(static_cast<double>(lagLo), centre - 2.0 * kDecimate));
|
||||||
const std::size_t hi = static_cast<std::size_t>(
|
const std::size_t hi = static_cast<std::size_t>(
|
||||||
std::min(static_cast<double>(lagHi), centre + 2.0 * kDecimate));
|
std::min(static_cast<double>(lagHi), centre + 2.0 * kDecimate));
|
||||||
if (hi <= lo) continue;
|
if (hi <= lo) continue; // degenerate refine bracket
|
||||||
|
++evidence; // the search ran and found a period: real evidence for one
|
||||||
periods.push_back(refineFullRate(pcm, from, block - hi, lo, hi));
|
periods.push_back(refineFullRate(pcm, from, block - hi, lo, hi));
|
||||||
confidences.push_back(1.0 - dissimilarity);
|
confidences.push_back(1.0 - dissimilarity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,14 +119,15 @@ public:
|
|||||||
void setFeedRate(double rate);
|
void setFeedRate(double rate);
|
||||||
|
|
||||||
// The period of the source being fed, in SOURCE frames, making every splice jump a whole
|
// 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
|
// number of it — <= 0 means "unknown" (see periodAlignedJump for the exact fallback); the
|
||||||
// geometry byte for byte — the default, so a caller that never calls this sees no change.
|
// default, so a caller that never calls this sees no change.
|
||||||
// Detection itself is off-thread and elsewhere (period_detect, which the engine deliberately
|
// 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.
|
// 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.
|
// Cleared by configure()/reset(); NOT by prime()/warm(), which do not change the source.
|
||||||
void setSourcePeriod(double periodFrames);
|
void setSourcePeriod(double periodFrames);
|
||||||
|
|
||||||
// The nominal jump splices currently use — window() unless a source period narrowed it.
|
// The nominal jump splices currently use — window() unless a source period retuned it to
|
||||||
|
// the nearest whole-period multiple, which can land either narrower or wider than window().
|
||||||
std::int64_t spliceJump() const { return jump_; }
|
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
|
||||||
@@ -209,11 +210,18 @@ private:
|
|||||||
// 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)
|
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 jump_ = 0; // nominal splice jump; window_ unless period_ retunes it
|
||||||
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY
|
std::int64_t jumpMax_ = 0; // largest jump whose post-splice delay stays STRICTLY
|
||||||
// inside [dLow_, dHigh_] at the worst search lag, so a
|
// inside [dLow_, dHigh_] at the worst search lag, so a
|
||||||
// period-sized jump can never land back on a trigger and
|
// period-sized jump can never land back on a trigger and
|
||||||
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_)
|
// thrash (dHigh_-dLow_-maxLag_-1, i.e. 1.25*window_). At
|
||||||
|
// jump_==jumpMax_ a DOWN-splice's correlation read comes
|
||||||
|
// within ~41 frames of the write head (measured: the
|
||||||
|
// exact ring/lag/corrFrames_ geometry at the product
|
||||||
|
// window, worst case over every lag the search reaches) —
|
||||||
|
// real margin, not zero, but tight enough that widening
|
||||||
|
// maxLag_, corrFrames_ or jumpMax_ without re-deriving
|
||||||
|
// this bound risks reading unwritten ring content.
|
||||||
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
|
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)
|
||||||
|
|||||||
@@ -15,22 +15,42 @@ namespace reasampler::instrument::engine {
|
|||||||
// source frames) — the RT-safety argument for feeding a variable count at all.
|
// source frames) — the RT-safety argument for feeding a variable count at all.
|
||||||
//
|
//
|
||||||
// This range NARROWS the splice-cadence failure onto the source fundamental; it does not
|
// This range NARROWS the splice-cadence failure onto the source fundamental; it does not
|
||||||
// eliminate it. A splice recurs every `window / |rate - shift|` output frames (the tap's
|
// eliminate it. A splice recurs every `pitch_shift.h`'s spliceJump() / |rate - shift| output
|
||||||
// delay drifts across one window at that per-frame rate); the shifted tone's own period is
|
// frames (the tap's delay drifts across one nominal jump at that per-frame rate); the shifted
|
||||||
// `sourcePeriod / shift` output frames. Whenever the recurrence interval is shorter than
|
// tone's own period is `sourcePeriod / shift` output frames. Whenever the recurrence interval
|
||||||
// that period, a splice lands inside a single perceived cycle and the correlation search
|
// is shorter than that period, a splice lands inside a single perceived cycle and the
|
||||||
// has less than one period to align against. Measured at rate 4.0, shift 0.25 (-24 st):
|
// correlation search has less than one period to align against. Measured at rate 4.0, shift
|
||||||
// interval 2205/3.75 ~= 588 vs period ~4*P ~= 785 frames (P ~= 196) — matches the originally
|
// 0.25 (-24 st), fixed-window jump (2205): interval 2205/3.75 ~= 588 vs period ~4*P ~= 785
|
||||||
// observed 539-vs-785 failure. This range's ceiling (2.0, not 4.0) raises the safe floor, it
|
// frames (P ~= 196) — matches the originally observed 539-vs-785 failure. This range's ceiling
|
||||||
// does not remove it: at rate 2.0, shift 0.25, interval = 2205/1.75 = 1260 still produces
|
// (2.0, not 4.0) raises the safe floor, it does not remove it: at rate 2.0, shift 0.25, interval
|
||||||
// measurable splice debris for any source period P > 315 frames (~140 Hz at 44.1k) — inside
|
// = 2205/1.75 = 1260 still produces measurable splice debris for any source period P > 315
|
||||||
// bass/low-vocal material, and -24 st is reachable from the Pitch knob alone. pitch_shift_tests
|
// frames (~140 Hz at 44.1k) — inside bass/low-vocal material, and -24 st is reachable from the
|
||||||
// (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter) asserts this corner directly at
|
// Pitch knob alone. pitch_shift_tests (testStretchCadenceCornerArtifactEnergyAtRate2ShiftQuarter)
|
||||||
// P=500/600/700: energy outside the fundamental runs 7-21% there against ~0% on an aligned
|
// asserts this corner directly at P=500/600/700: energy outside the fundamental runs 7-21% there
|
||||||
// control at the same rate/shift — zero-crossing period is NOT what it checks, since splice
|
// against ~0% on an aligned control at the same rate/shift — zero-crossing period is NOT what it
|
||||||
// debris fools that estimator into reading the wrong period on a render whose fundamental is
|
// checks, since splice debris fools that estimator into reading the wrong period on a render
|
||||||
// actually fine. (The pre-stretch rate-1.0 engine's floor by the same inequality is P > 735,
|
// whose fundamental is provably correct. (The pre-stretch rate-1.0 engine's floor by the same
|
||||||
// ~60 Hz — what this range raises the floor from, not what it removes.)
|
// inequality is P > 735, ~60 Hz — what this range raises the floor from, not what it removes.)
|
||||||
|
//
|
||||||
|
// The above derives the floor with jump == window(), which is only the FIXED-WINDOW half of
|
||||||
|
// the story. Once a source period is known, spliceJump() is periodAlignedJump's answer instead
|
||||||
|
// (pitch_shift.h), and that answer can land NARROWER than window() — as low as ~0.63*window for
|
||||||
|
// some periods — which SHRINKS the interval and moves the failure threshold EARLIER, not later.
|
||||||
|
// There is no single closed-form floor for this case (the jump is itself a function of P), so
|
||||||
|
// read it at the concrete corner instead: at P=1470 (30 Hz at 44.1k) the same rate 2.0/shift
|
||||||
|
// 0.25 corner's jump narrows from window (2205) to 1470, and its interval from 1260 to
|
||||||
|
// 1470/1.75 = 840. Independently, at the plain (no time-stretch) rate 1.0 case, solving this
|
||||||
|
// same inequality for shift at P=1470 puts the failure threshold at shift = P/(jump+P): 0.4
|
||||||
|
// (-16 st) at the fixed-window jump (2205), 0.5 (-12 st) at the pitch-synchronous jump (1470) —
|
||||||
|
// the geometry fix that lets 30 Hz align AT ALL moves this unrelated cadence inequality's own
|
||||||
|
// trip point from roughly -16 st to roughly -12 st for the same source. Do NOT read this as a
|
||||||
|
// proven regression: the inequality above was calibrated for RANDOM-PHASE (unaligned) splices,
|
||||||
|
// and a pitch-synchronous splice is waveform-aligned by construction, which the inequality does
|
||||||
|
// not model — whether the shorter interval still produces audible debris once every splice
|
||||||
|
// lands in phase is what pitch_shift_tests' own P=1470 cadence-collapse-band measurement
|
||||||
|
// answers, not this derivation. Do not narrow kStretchRateMin/kStretchRateMax in response to
|
||||||
|
// this: sub-50 Hz sine material is first-class product material, not an edge case, and a
|
||||||
|
// narrower range does not fix a floor it does not reach.
|
||||||
//
|
//
|
||||||
// A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now
|
// A SECOND, INDEPENDENT limit bound the same material, and no rate bound touched it. It is now
|
||||||
// CLOSED for any source whose period is detected, but the geometry is worth keeping because it
|
// CLOSED for any source whose period is detected, but the geometry is worth keeping because it
|
||||||
@@ -56,7 +76,7 @@ namespace reasampler::instrument::engine {
|
|||||||
// pitch_shift_tests' testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown, a different
|
// pitch_shift_tests' testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown, a different
|
||||||
// quantity from the raw percentages here. What survives: a period longer than the reachable
|
// quantity from the raw percentages here. What survives: a period longer than the reachable
|
||||||
// jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no
|
// 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.
|
// single period falls back to it by design (periodAlignedJump, pitch_shift.h).
|
||||||
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)
|
||||||
|
|||||||
@@ -394,10 +394,21 @@ static void testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand() {
|
|||||||
periodAnalysisSpan(frames, 60000, 60000 + static_cast<std::int64_t>(minimum), true, rate);
|
periodAnalysisSpan(frames, 60000, 60000 + static_cast<std::int64_t>(minimum), true, rate);
|
||||||
CHECK(atMinimum.from == 60000 && atMinimum.count == minimum);
|
CHECK(atMinimum.from == 60000 && atMinimum.count == minimum);
|
||||||
|
|
||||||
// And the too-short loop still DETECTS through the wider span — refusing there would be a
|
|
||||||
// regression against analysing the whole source, and a short sustain loop is common.
|
|
||||||
const double p = static_cast<double>(rate) / 30.0;
|
const double p = static_cast<double>(rate) / 30.0;
|
||||||
const std::vector<AudioSample> src = sineOfPeriod(frames, p);
|
const std::vector<AudioSample> src = sineOfPeriod(frames, p);
|
||||||
|
|
||||||
|
// The span IS taken at the minimum, but that alone doesn't say detection BEHAVES there: at
|
||||||
|
// exactly one probe block, detectPeriod takes the lone-probe carve-out (no agreement check
|
||||||
|
// at all) — the span choice and the accept rule meet at this exact boundary, and that
|
||||||
|
// meeting point is what needs to actually detect, not just be selected.
|
||||||
|
const PeriodEstimate atMin = detectPeriod(src, rate, atMinimum.from, atMinimum.count);
|
||||||
|
std::printf(" loop at the minimum span -> %s (%.3f, want %.3f)\n",
|
||||||
|
atMin.valid() ? "detected" : "NONE", atMin.frames, p);
|
||||||
|
CHECK(atMin.valid());
|
||||||
|
if (atMin.valid()) CHECK(std::fabs(atMin.frames - p) < 0.5);
|
||||||
|
|
||||||
|
// And the too-short loop still DETECTS through the wider span — refusing there would be a
|
||||||
|
// regression against analysing the whole source, and a short sustain loop is common.
|
||||||
const PeriodEstimate est = detectPeriod(src, rate, shortLoop.from, shortLoop.count);
|
const PeriodEstimate est = detectPeriod(src, rate, shortLoop.from, shortLoop.count);
|
||||||
std::printf(" short loop -> whole source: %s (%.3f)\n", est.valid() ? "detected" : "NONE",
|
std::printf(" short loop -> whole source: %s (%.3f)\n", est.valid() ? "detected" : "NONE",
|
||||||
est.frames);
|
est.frames);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// The one gated case that runs the WHOLE load->voice wire through REAL detection, closing the
|
||||||
|
// gap between two halves proven separately: testBuildSampleDataDetectsThirtyHertzSourcePeriod
|
||||||
|
// (test_sample_map.cpp, detectPeriod -> SampleData) never renders, and
|
||||||
|
// testSourcePeriodChangesTheRenderedStream (test_sampler_core.cpp, SampleData -> Voice -> audio)
|
||||||
|
// sets sourcePeriodFrames by hand rather than detecting it from PCM. Deliberately its own
|
||||||
|
// target: sample_map_tests and sampler_core_tests each keep their one-lib-only structural proof
|
||||||
|
// (map doesn't link the voice engine, the engine doesn't link period_detect), so bridging the
|
||||||
|
// two lives here instead of extending either.
|
||||||
|
|
||||||
|
#include "../src/core/instrument/map/sample_map.h"
|
||||||
|
#include "../src/core/instrument/engine/voice_engine.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
using namespace reasampler::instrument::engine;
|
||||||
|
using namespace reasampler::instrument::map;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// An ADSR that stays fully open (level 1) forever while held — isolates the render from
|
||||||
|
// envelope shaping (matches test_sampler_core.cpp's flatAdsr; not shared, both files stand
|
||||||
|
// alone).
|
||||||
|
static AdsrParams flatAdsr() {
|
||||||
|
AdsrParams a;
|
||||||
|
a.attackFrames = 0;
|
||||||
|
a.decayFrames = 0;
|
||||||
|
a.sustainLevel = 1.0;
|
||||||
|
a.releaseFrames = 0;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FNV-1a over the raw float bits (matches test_sampler_core.cpp's hashStream).
|
||||||
|
static std::uint64_t hashStream(const std::vector<AudioSample>& v) {
|
||||||
|
std::uint64_t h = 1469598103934665603ull;
|
||||||
|
for (const AudioSample s : v) {
|
||||||
|
std::uint32_t bits = 0;
|
||||||
|
std::memcpy(&bits, &s, sizeof(bits));
|
||||||
|
for (int b = 0; b < 4; ++b) {
|
||||||
|
h ^= static_cast<std::uint64_t>((bits >> (8 * b)) & 0xffu);
|
||||||
|
h *= 1099511628211ull;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void renderVoice(const SampleData& s, int note, std::int64_t window,
|
||||||
|
std::size_t outFrames, std::vector<AudioSample>& out) {
|
||||||
|
Voice v;
|
||||||
|
v.presizePreserveShifters(window);
|
||||||
|
v.start(note, 127, s, /*declickTakeover=*/false, /*rate=*/1.0);
|
||||||
|
out.resize(outFrames);
|
||||||
|
for (std::size_t i = 0; i < outFrames; ++i) out[i] = v.renderFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30 Hz @ 44.1k through the REAL wire: buildSampleData (sample_map.cpp:333-334) calls
|
||||||
|
// detectPeriod itself, so this proves the detected period actually reaches and moves the
|
||||||
|
// Preserve render — not just that a hand-set sourcePeriodFrames does (that is the sampler_core
|
||||||
|
// half; this is the missing map->engine seam).
|
||||||
|
static void testDetectedPeriodReachesAndMovesThePreserveRender() {
|
||||||
|
const std::int64_t w = 2205; // the product window at 44.1k
|
||||||
|
const int rate = 44100;
|
||||||
|
const std::size_t frames = 30000;
|
||||||
|
std::vector<AudioSample> pcm(frames);
|
||||||
|
for (std::size_t i = 0; i < frames; ++i) {
|
||||||
|
pcm[i] = static_cast<float>(
|
||||||
|
std::sin(2.0 * kPi * 30.0 * static_cast<double>(i) / rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedSample ref;
|
||||||
|
ref.relativePath = "b/a.wav";
|
||||||
|
ref.rootNote = 60;
|
||||||
|
SampleData on = buildSampleData(resolveCapture(ref, InstrumentParams{}),
|
||||||
|
DecodedPcm{pcm, rate, {}});
|
||||||
|
CHECK(std::fabs(on.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz, real detection
|
||||||
|
|
||||||
|
on.play.adsr = flatAdsr();
|
||||||
|
on.play.pitchEngine = PitchEngine::Preserve;
|
||||||
|
SampleData off = on;
|
||||||
|
off.sourcePeriodFrames = 0.0; // the fixed-window fallback the pre-wire render used
|
||||||
|
|
||||||
|
std::vector<AudioSample> outOn, outOff;
|
||||||
|
renderVoice(on, /*note=*/67, w, 6000, outOn); // +7 st: real splices
|
||||||
|
renderVoice(off, 67, w, 6000, outOff);
|
||||||
|
for (AudioSample v : outOn) CHECK(std::isfinite(v));
|
||||||
|
CHECK(hashStream(outOn) != hashStream(outOff)); // the detected period actually moved the render
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testDetectedPeriodReachesAndMovesThePreserveRender();
|
||||||
|
|
||||||
|
if (g_fail == 0) {
|
||||||
|
std::printf("all period_render_integration tests passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("%d period_render_integration check(s) failed\n", g_fail);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <limits>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
using namespace reasampler;
|
using namespace reasampler;
|
||||||
@@ -920,6 +921,9 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() {
|
|||||||
{"34 Hz rate 2.0", 34.0, 2.0, 0.0},
|
{"34 Hz rate 2.0", 34.0, 2.0, 0.0},
|
||||||
};
|
};
|
||||||
double controlWorst = 0.0, subjectWorst = 0.0, subjectOffWorst = 0.0;
|
double controlWorst = 0.0, subjectWorst = 0.0, subjectOffWorst = 0.0;
|
||||||
|
// std::max alone floors a NEGATIVE floor-relative excess to 0 — but a negative excess means
|
||||||
|
// the floor model mismatches the render, not a clean one, so track the signed minimum too.
|
||||||
|
double subjectWorstMin = std::numeric_limits<double>::infinity();
|
||||||
for (const Row& r : rows) {
|
for (const Row& r : rows) {
|
||||||
const double period = 44100.0 / r.freq;
|
const double period = 44100.0 / r.freq;
|
||||||
std::vector<AudioSample> src(srcLen);
|
std::vector<AudioSample> src(srcLen);
|
||||||
@@ -943,6 +947,7 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() {
|
|||||||
controlWorst = std::max(controlWorst, pctOn);
|
controlWorst = std::max(controlWorst, pctOn);
|
||||||
} else {
|
} else {
|
||||||
subjectWorst = std::max(subjectWorst, pctOn);
|
subjectWorst = std::max(subjectWorst, pctOn);
|
||||||
|
subjectWorstMin = std::min(subjectWorstMin, pctOn);
|
||||||
subjectOffWorst = std::max(subjectOffWorst, pctOff);
|
subjectOffWorst = std::max(subjectOffWorst, pctOff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -953,6 +958,8 @@ static void testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown() {
|
|||||||
std::printf(" [30 Hz] worst subject excess %.2f%% vs worst control excess %.2f%%\n",
|
std::printf(" [30 Hz] worst subject excess %.2f%% vs worst control excess %.2f%%\n",
|
||||||
subjectWorst, controlWorst);
|
subjectWorst, controlWorst);
|
||||||
CHECK(subjectWorst < 0.10);
|
CHECK(subjectWorst < 0.10);
|
||||||
|
CHECK(subjectWorstMin > -0.10); // a negative excess this large is a model
|
||||||
|
// mismatch, not a clean render — catch it too
|
||||||
CHECK(subjectWorst <= controlWorst + 0.05); // 0.05 absorbs the floor subtraction's sign noise
|
CHECK(subjectWorst <= controlWorst + 0.05); // 0.05 absorbs the floor subtraction's sign noise
|
||||||
// Vacuity guard, matching testTwentyNineHertzAtRateTwoKeepsItsPitch's sibling check: the
|
// Vacuity guard, matching testTwentyNineHertzAtRateTwoKeepsItsPitch's sibling check: the
|
||||||
// FIXED-WINDOW (no period set) arm is asserted too, so a setSourcePeriod that silently did
|
// FIXED-WINDOW (no period set) arm is asserted too, so a setSourcePeriod that silently did
|
||||||
@@ -1029,6 +1036,48 @@ static void testCadenceCornerIsUnmovedByAPitchSynchronousSplice() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M1 (Gamma-W1-T7 re-review): the corner above sits where periodAlignedJump narrows the jump
|
||||||
|
// by only ~10% (2205 -> 2000/2400/2100 at P=500/600/700), never reaching the collapse band
|
||||||
|
// (jump_->0.63-0.67*window) time_stretch.h's own derivation flags as where the recurrence
|
||||||
|
// interval shrinks hardest. P=1470 (30 Hz at 44.1k) is the case that track exists for: n=2
|
||||||
|
// overshoots jumpMax_ (2*1470=2940 > 2756), forcing n=1 and jump_=1470=0.667*window — 1.5x the
|
||||||
|
// splice rate of the fixed-window fallback.
|
||||||
|
//
|
||||||
|
// MEASURED (Debug, this machine): metric floor 55.86% (P=1470's want-period of 5880 fr under a
|
||||||
|
// 32768-frame segment leaks a lot of mainlobe, same effect as the P=500-700 rows, just larger),
|
||||||
|
// fixed-window excess 18.52%, pitch-synchronous excess 0.00%. The faster cadence does NOT
|
||||||
|
// regress this corner — every splice at n=1 lands exactly one source period away, so despite
|
||||||
|
// firing 1.5x as often each one is phase-perfect rather than merely aligned-on-average, and the
|
||||||
|
// corner clears rather than worsens. Recorded as read, not tuned: if a future change moves
|
||||||
|
// these numbers, update this comment to match, don't loosen the bounds to hide it.
|
||||||
|
static void testCadenceCollapseBandAtThirtyHertzUnderPitchSynchronousSplice() {
|
||||||
|
using reasampler::test_support::energyOutsideFundamentalPercent;
|
||||||
|
const std::int64_t w = 2205;
|
||||||
|
const double rate = 2.0;
|
||||||
|
const double shift = std::pow(2.0, -24.0 / 12.0); // -24 st, the same corner as above
|
||||||
|
const double period = 1470.0; // 30 Hz at 44.1k
|
||||||
|
const std::size_t outFrames = 60000, from = 20000, len = 32768;
|
||||||
|
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, rate, shift, outFrames, nullptr);
|
||||||
|
const std::vector<double> on = runStretch(src, w, rate, 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 collapse band, PSOLA] period %.0f (want %.0f, metric floor %.2f%%): "
|
||||||
|
"excess %.2f%% -> %.2f%%\n", period, want, floor, pctOff - floor, pctOn - floor);
|
||||||
|
CHECK(pctOn - floor < 1.0); // measured 0.00%: phase-perfect at n=1, not merely aligned
|
||||||
|
// Vacuity guard (shape of testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown's :961): the
|
||||||
|
// FIXED-WINDOW arm is asserted too (measured 18.52% excess), so a setSourcePeriod that
|
||||||
|
// silently did nothing would render both arms identically and pass the bound above by luck.
|
||||||
|
CHECK(pctOff - floor > pctOn - floor + 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
// 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() {
|
||||||
@@ -1056,6 +1105,7 @@ int main() {
|
|||||||
testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown();
|
testThirtyHertzSplicesAlignOnceTheSourcePeriodIsKnown();
|
||||||
testTwentyNineHertzAtRateTwoKeepsItsPitch();
|
testTwentyNineHertzAtRateTwoKeepsItsPitch();
|
||||||
testCadenceCornerIsUnmovedByAPitchSynchronousSplice();
|
testCadenceCornerIsUnmovedByAPitchSynchronousSplice();
|
||||||
|
testCadenceCollapseBandAtThirtyHertzUnderPitchSynchronousSplice();
|
||||||
testStretchEntryPointsOnPassThrough();
|
testStretchEntryPointsOnPassThrough();
|
||||||
|
|
||||||
if (g_fail == 0) {
|
if (g_fail == 0) {
|
||||||
|
|||||||
@@ -592,8 +592,15 @@ static void reportFloorProbeMechanism() {
|
|||||||
int n = 0;
|
int n = 0;
|
||||||
const bool reach = alignmentReachable(period, lo, hi, &n);
|
const bool reach = alignmentReachable(period, lo, hi, &n);
|
||||||
const double freq = static_cast<double>(sr) / period;
|
const double freq = static_cast<double>(sr) / period;
|
||||||
// The cadence inequality from time_stretch.h, evaluated for this row.
|
// The cadence inequality from time_stretch.h, evaluated for this row against the
|
||||||
const double cadence = static_cast<double>(w) / std::fabs(rate - shift);
|
// ACTUAL nominal jump this geometry resolves to — under g_pitchSynchronous that is
|
||||||
|
// periodAlignedJump's answer, not always the fixed window, so the two passes of this
|
||||||
|
// report (fixed-window / pitch-synchronous) must not print the same number.
|
||||||
|
PitchShifter jumpProbe;
|
||||||
|
jumpProbe.configure(w);
|
||||||
|
jumpProbe.setSourcePeriod(g_pitchSynchronous ? period : 0.0);
|
||||||
|
const double cadence =
|
||||||
|
static_cast<double>(jumpProbe.spliceJump()) / std::fabs(rate - shift);
|
||||||
const double outPeriod = period / shift;
|
const double outPeriod = period / shift;
|
||||||
std::printf(" P=%5.0f (%.1f Hz): alignable in [%.0f,%.0f]? %s%s | cadence %.0f fr vs "
|
std::printf(" P=%5.0f (%.1f Hz): alignable in [%.0f,%.0f]? %s%s | cadence %.0f fr vs "
|
||||||
"output period %.0f fr -> %s\n",
|
"output period %.0f fr -> %s\n",
|
||||||
|
|||||||
Reference in New Issue
Block a user