diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index e4d5757..a852599 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -300,7 +300,12 @@ anything for a trigger shape. 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. + fixed-window geometry is the documented fallback. **Detection analyses the SUSTAIN LOOP when + the capture carries one long enough to host the full search band** (`periodAnalysisSpan`), + otherwise the whole source: the loop is what a Gate voice asymptotically plays, and a phrase + whose head is pitched differently from its sustain would otherwise disagree its way to none. + A shorter loop analyses the whole source rather than a narrowed band — a narrower span may + never buy itself a higher lowest-findable fundamental. - `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. diff --git a/src/core/instrument/engine/period_detect.cpp b/src/core/instrument/engine/period_detect.cpp index 266e414..5d9f563 100644 --- a/src/core/instrument/engine/period_detect.cpp +++ b/src/core/instrument/engine/period_detect.cpp @@ -120,30 +120,34 @@ double blockRms(const std::vector& pcm, std::size_t from, std::size } // namespace -PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) { - if (sampleRate <= 0 || pcm.empty()) return {}; +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, + std::size_t spanFrom, std::size_t spanCount) { + if (sampleRate <= 0 || spanCount == 0) return {}; + if (spanFrom > pcm.size() || spanCount > pcm.size() - spanFrom) return {}; const double rate = static_cast(sampleRate); std::size_t lagHi = static_cast(rate / kPeriodDetectMinHz); const std::size_t lagLo = static_cast(rate / kPeriodDetectMaxHz); if (lagLo < 2) return {}; // a rate so low the whole search band collapses // One probe block is W + lagHi frames with W == lagHi (YIN's usual sizing: the analysis - // window must cover the longest lag being tested). A short sample shortens the search + // window must cover the longest lag being tested). A short span shortens the search // rather than refusing outright — a 200 ms one-shot still has a period worth finding. - if (pcm.size() < 2 * lagHi) lagHi = pcm.size() / 2; + if (spanCount < 2 * lagHi) lagHi = spanCount / 2; if (lagHi <= lagLo + 2) return {}; const std::size_t block = 2 * lagHi; - const std::size_t probes = - std::min(kPeriodDetectProbes, std::max(1, pcm.size() / block)); + // Probe POSITIONS, not disjoint blocks — see kPeriodDetectProbes in the header for why + // lagHi is the separation that makes two overlapping probes independent evidence. + const std::size_t room = spanCount - block; + const std::size_t probes = std::min(kPeriodDetectProbes, 1 + room / lagHi); // 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; + // the whole span rather than only its opening. + const std::size_t stride = probes > 1 ? room / (probes - 1) : 0; std::vector periods; std::vector confidences; for (std::size_t p = 0; p < probes; ++p) { - const std::size_t from = p * stride; - if (from + block > pcm.size()) break; + const std::size_t from = spanFrom + p * stride; + if (from + block > spanFrom + spanCount) break; if (blockRms(pcm, from, block) < kSilenceRms) continue; const std::vector small = decimate(pcm, from, block); @@ -173,6 +177,22 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) if (periods.empty()) return {}; + // ONE surviving probe: the span could not host a second probe position, so there is no + // second estimate for the majority rule below to rule on — it would be deciding on an + // empty comparison. The accept rests on pickPeriod's absolute threshold, which is a real + // test and not an absence of one: the block genuinely repeats at this lag across its whole + // analysis window. Refusing instead would deny every short one-shot a period, and a period + // that turns out wrong costs a mis-centred correlation search at the splice, not an + // unrefined one (pitch_shift.cpp's splice searches +/- maxLag around whichever jump it is + // handed). Do not "unify" this back into the majority test — at size 1 that test accepts + // unconditionally, which is the same behaviour with none of the reasoning. + if (periods.size() == 1) { + PeriodEstimate lone; + lone.frames = periods[0]; + lone.confidence = confidences[0]; + return lone; + } + std::vector sorted = periods; std::sort(sorted.begin(), sorted.end()); const double median = sorted[sorted.size() / 2]; @@ -192,6 +212,7 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) // first half is one period and second half another gives two probes each way, and taking // either as "the" period would misalign every splice in the other half. Refusing is the // right answer there — the fixed-window fallback is what a source with no ONE period gets. + // Reached only with two or more probes; the lone-probe case returned above. if (agree * 2 <= periods.size()) return {}; PeriodEstimate est; @@ -200,4 +221,24 @@ PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) return est; } +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate) { + return detectPeriod(pcm, sampleRate, 0, pcm.size()); +} + +AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart, + std::int64_t loopEnd, bool hasLoop, int sampleRate) { + const AnalysisSpan whole{0, frameCount}; + if (!hasLoop || sampleRate <= 0) return whole; + if (loopStart < 0 || loopEnd <= loopStart) return whole; + if (static_cast(loopEnd) > frameCount) return whole; + + const std::size_t length = static_cast(loopEnd - loopStart); + // One full probe block. Below it detectPeriod shortens lagHi to fit, which raises the + // lowest findable fundamental — the one thing the narrower span may never cost. + const std::size_t minimum = + 2 * static_cast(static_cast(sampleRate) / kPeriodDetectMinHz); + if (length < minimum) return whole; + return AnalysisSpan{static_cast(loopStart), length}; +} + } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/period_detect.h b/src/core/instrument/engine/period_detect.h index f07112b..1d74bf2 100644 --- a/src/core/instrument/engine/period_detect.h +++ b/src/core/instrument/engine/period_detect.h @@ -6,6 +6,7 @@ // translation unit on the render path can name detectPeriod. A sampler's source is fixed and // fully known at load, which is the whole reason a detector is affordable here at all. +#include #include #include @@ -19,9 +20,12 @@ using audio::AudioSample; // authored and never persisted — this is a cache, not state. struct PeriodEstimate { double frames = 0.0; // 0 = no single period (inharmonic, polyphonic, percussive, noise) - // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic only today: the - // accept decision is `valid()` alone, and the loader takes `.frames` without reading this — - // do not assume it is load-bearing without checking who reads it. + // 1 - the accepted dissimilarity, [0,1]; 0 when frames == 0. Diagnostic: the accept decision + // is `valid()` alone and the loader takes `.frames` without reading this — its consumers are + // the tests and the measurement harness. It is deliberately NOT a second accept gate: every + // accepted probe already cleared kPeriodDetectThreshold, so confidence > 0.88 holds by + // construction and any gate below that is a no-op while any gate above it is a tuned number + // with nothing to derive it from. double confidence = 0.0; bool valid() const { return frames > 0.0; } @@ -43,15 +47,51 @@ inline constexpr double kPeriodDetectThreshold = 0.12; // How many blocks across the sample are estimated independently, and how far apart two of them // may land and still be called the same period. Agreement is what separates a genuinely // periodic source from one whose opening happens to look periodic. +// +// Probes are placed by POSITION and may overlap: what the rule needs is estimates from +// different places in the source, and two blocks a full longest-lag apart already differ by a +// whole cycle of the lowest frequency in the band, so neither can be a trivially shifted copy +// of the other at any period searched. Requiring DISJOINT blocks instead left every source +// under ~4x the longest lag with a single probe and so with no agreement to check at all. +// One probe survives as an irreducible case below `block + longest lag` frames and is accepted +// on the absolute threshold alone — see detectPeriod's contract. inline constexpr int kPeriodDetectProbes = 4; inline constexpr double kPeriodDetectAgreeTolerance = 0.02; // 2% of the median -// Estimates `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(). +// Estimates the fundamental period of `pcm[from, from+count)` at `sampleRate`. Cost is bounded +// by the constants above, not by the span length: at most kPeriodDetectProbes blocks of ~2 x +// the longest searched lag are analysed however long the span is. Allocates; never call from +// process(). An out-of-range span estimates nothing and returns none. // // Returns an invalid estimate (frames == 0) for silence, noise, and anything whose probes // disagree — the caller's documented fallback is the fixed-window splice geometry. +// +// Two probes or more must reach a STRICT MAJORITY agreement. A lone probe — which only happens +// on a span too short to host a second probe position — is accepted on the absolute threshold +// alone, because there is no second estimate for a majority rule to rule on and refusing would +// deny every short one-shot a period. +PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate, + std::size_t from, std::size_t count); + +// The whole source. PeriodEstimate detectPeriod(const std::vector& pcm, int sampleRate); +// The frames detection should analyse for a capture that carries a sustain loop, and the reason +// the answer is not simply "all of them": under Gate the loop region is asymptotically ALL the +// splicer plays, so a phrase whose head is pitched differently from its sustain would otherwise +// disagree its way to none over the whole source. `[loopStart, loopEnd)` is used only when it +// is at least `2 * (sampleRate / kPeriodDetectMinHz)` frames — the span below which detectPeriod +// starts shortening its own search band — so choosing the narrower span never costs search-band +// width and so can never lose a low fundamental that the whole source would have found. +// Anything else (no loop, an out-of-range span, a short one) yields the whole source. +// +// The read path's loop-validity authority is loop_span's resolveLoop; the bounds check here is +// on a cache input, not a second validity rule, and it refuses rather than repairs the same way. +struct AnalysisSpan { + std::size_t from = 0; + std::size_t count = 0; +}; +AnalysisSpan periodAnalysisSpan(std::size_t frameCount, std::int64_t loopStart, + std::int64_t loopEnd, bool hasLoop, int sampleRate); + } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 38f1e49..96ceb14 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -329,9 +329,14 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) 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. + // schedule is linked across them anyway. The span is the sustain loop where one is long + // enough (periodAnalysisSpan owns that rule) — every input to it commits through a full + // reload, so the cache is re-derived whenever the span it was chosen from moves. + const instrument::engine::AnalysisSpan span = instrument::engine::periodAnalysisSpan( + data.frames.size(), data.loop.start, data.loop.end, data.loop.hasLoop, data.sampleRate); data.sourcePeriodFrames = - instrument::engine::detectPeriod(data.frames, data.sampleRate).frames; + instrument::engine::detectPeriod(data.frames, data.sampleRate, span.from, span.count) + .frames; return data; } diff --git a/tests/test_period_detect.cpp b/tests/test_period_detect.cpp index b42b6d9..57a610a 100644 --- a/tests/test_period_detect.cpp +++ b/tests/test_period_detect.cpp @@ -10,15 +10,19 @@ // 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. +// 4. the band edges and the short-sample path, including the lone-probe accept. +// 5. the analysis span: a sustain loop stands in for the whole source, but never at the cost +// of search-band width. +// 6. what the load pays, and that it does not grow with the sample length. #include "../src/core/instrument/engine/period_detect.h" #include #include +#include #include #include +#include #include using namespace reasampler; @@ -217,7 +221,144 @@ static void testAShortSourceShortensTheSearchRatherThanRefusing() { CHECK(!detectPeriod(sineOfPeriod(40, 20.0), 44100).valid()); } -// --- 5. What the load pays ------------------------------------------------------------------ +// A lone probe is the one case the strict-majority rule cannot rule on, so pin BOTH halves of +// the carve-out: which sources land in it, and that they are accepted rather than refused. +// 30 Hz is first-class material here, and a short low-frequency source is exactly where the +// blunt "require two probes" fix would have silently stopped detecting. +static void testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe() { + const int rate = 44100; + const std::size_t lagHi = static_cast(rate / kPeriodDetectMinHz); + const std::size_t block = 2 * lagHi; + // Derive the frame count from the public constants rather than hardcoding one, so this test + // keeps naming the lone-probe case if the geometry ever moves. One probe fits while the + // span leaves less than lagHi of room after the first block. + const std::size_t frames = block + lagHi - 1; // 8819 at 44.1k -> exactly one probe + CHECK(1 + (frames - block) / lagHi == 1); + + const double p = static_cast(rate) / 30.0; // 1470 frames + const PeriodEstimate est = detectPeriod(sineOfPeriod(frames, p), rate); + std::printf(" lone probe, %zu frames @ 30 Hz -> %s (%.3f, want %.3f)\n", frames, + est.valid() ? "detected" : "NONE", est.frames, p); + CHECK(est.valid()); + if (est.valid()) CHECK(std::fabs(est.frames - p) < 1.0); + + // One frame more buys a second probe position; the answer must not change character. + const PeriodEstimate two = detectPeriod(sineOfPeriod(frames + 1, p), rate); + CHECK(1 + (frames + 1 - block) / lagHi == 2); + CHECK(two.valid()); + if (two.valid()) CHECK(std::fabs(two.frames - p) < 1.0); +} + +static void testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected() { + // 30 Hz and 29 Hz — the pair the Preserve geometry work is measured against. 29 Hz is the + // sharper case: its period does not divide the splice window, so the shifter needs the + // detected value to be right rather than merely present. + for (double hz : {30.0, 29.0}) { + const double p = 44100.0 / hz; + const PeriodEstimate est = detectPeriod(sineOfPeriod(160000, p), 44100); + std::printf(" %.0f Hz -> %s (%.3f, want %.3f)\n", hz, est.valid() ? "detected" : "NONE", + est.frames, p); + CHECK(est.valid()); + if (est.valid()) CHECK(std::fabs(est.frames - p) < 0.5); + } +} + +// --- 5. The analysis span ------------------------------------------------------------------- + +static void testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand() { + const int rate = 44100; + const std::size_t frames = 120000; + // One full probe block — the span below which detectPeriod starts shortening its own + // longest lag, which is the only thing the narrower span may never cost. + const std::size_t minimum = 2 * static_cast(rate / kPeriodDetectMinHz); + + // No loop, an inverted span, and a span reaching past the PCM all yield the whole source. + for (const auto& [lo, hi, has] : {std::tuple{0, 0, false}, + {60000, 120000, false}, + {90000, 90000, true}, + {90000, 80000, true}, + {-1, 90000, true}, + {60000, 130000, true}}) { + const AnalysisSpan s = periodAnalysisSpan(frames, lo, hi, has, rate); + CHECK(s.from == 0 && s.count == frames); + } + + // A loop one frame under the minimum falls back to the WIDER span, not to none. + const AnalysisSpan shortLoop = + periodAnalysisSpan(frames, 60000, 60000 + static_cast(minimum) - 1, true, + rate); + CHECK(shortLoop.from == 0 && shortLoop.count == frames); + + // At the minimum exactly, the loop is taken. + const AnalysisSpan atMinimum = + periodAnalysisSpan(frames, 60000, 60000 + static_cast(minimum), true, rate); + CHECK(atMinimum.from == 60000 && atMinimum.count == minimum); + + // And the too-short loop still DETECTS through the wider span — refusing there would be a + // regression against analysing the whole source, and a short sustain loop is common. + const double p = static_cast(rate) / 30.0; + const std::vector src = sineOfPeriod(frames, p); + const PeriodEstimate est = detectPeriod(src, rate, shortLoop.from, shortLoop.count); + std::printf(" short loop -> whole source: %s (%.3f)\n", est.valid() ? "detected" : "NONE", + est.frames); + CHECK(est.valid()); + if (est.valid()) CHECK(std::fabs(est.frames - p) < 0.5); +} + +static void testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop() { + // The case the whole-source analysis cannot answer: the head sustains one pitch, the looped + // tail another. Analysed whole, two probes land each side and the strict-majority rule + // correctly refuses — there is no ONE period over the whole source. But under Gate the + // splicer lives in the loop, whose period is perfectly well defined. + const int rate = 44100; + const std::size_t frames = 120000; + const std::int64_t loopStart = 60000; + const double headPeriod = 300.0; + const double loopPeriod = static_cast(rate) / 30.0; // 1470 frames + + std::vector src(frames); + double phase = 0.0; + for (std::size_t i = 0; i < frames; ++i) { + phase += 2.0 * kPi / + (i < static_cast(loopStart) ? headPeriod : loopPeriod); + src[i] = static_cast(std::sin(phase)); + } + + // BEFORE this rule: the whole source is what was analysed, and it reports none. + const PeriodEstimate whole = detectPeriod(src, rate); + std::printf(" phrase analysed whole -> %s (%.3f)\n", whole.valid() ? "DETECTED" : "none", + whole.frames); + CHECK(!whole.valid()); + + // AFTER: the loop is long enough to host the full band, so it is the analysed span. + const AnalysisSpan span = periodAnalysisSpan(frames, loopStart, + static_cast(frames), true, rate); + CHECK(span.from == static_cast(loopStart)); + const PeriodEstimate looped = detectPeriod(src, rate, span.from, span.count); + std::printf(" phrase analysed over its loop -> %s (%.3f, want %.3f)\n", + looped.valid() ? "detected" : "NONE", looped.frames, loopPeriod); + CHECK(looped.valid()); + if (looped.valid()) CHECK(std::fabs(looped.frames - loopPeriod) < 2.0); + + // The narrowed span must not turn a genuinely aperiodic loop into a period: same geometry, + // noise in the loop region. + std::vector noisyLoop = src; + std::uint32_t rng = 777u; + for (std::size_t i = static_cast(loopStart); i < frames; ++i) { + rng = rng * 1664525u + 1013904223u; + noisyLoop[i] = static_cast((static_cast(rng >> 8) / 8388608.0) - 1.0); + } + CHECK(!detectPeriod(noisyLoop, rate, span.from, span.count).valid()); +} + +static void testAnOutOfRangeSpanEstimatesNothing() { + const std::vector src = sineOfPeriod(120000, 441.0); + CHECK(!detectPeriod(src, 44100, 120001, 10).valid()); + CHECK(!detectPeriod(src, 44100, 119000, 5000).valid()); + CHECK(!detectPeriod(src, 44100, 0, 0).valid()); +} + +// --- 6. What the load pays ------------------------------------------------------------------ // The whole reason a detector is affordable in a sampler is that it runs ONCE, off the audio // thread, on a source that is already fully known. This prints what that once costs, and @@ -255,6 +396,11 @@ int main() { testAPercussiveDecayIsNotForcedIntoAPeriod(); testBelowTheBandReportsNoneAndAboveItReportsAWholeMultiple(); testAShortSourceShortensTheSearchRatherThanRefusing(); + testASourceTooShortForASecondProbeIsStillDetectedOnItsOneProbe(); + testTheTwoLowFrequenciesTheShifterWasBuiltForAreDetected(); + testTheLoopStandsInForTheSourceOnlyWhenItCostsNoSearchBand(); + testAPhraseWhoseLoopIsPitchedDifferentlyFromItsHeadDetectsOverTheLoop(); + testAnOutOfRangeSpanEstimatesNothing(); testDetectionCostIsBoundedRegardlessOfSampleLength(); if (g_fail == 0) { diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index a32aa32..e1f221b 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -926,6 +926,45 @@ static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() { CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz } +// The span half of the same wire: buildSampleData must hand detection the LOOP region when the +// capture carries one, not the whole decoded PCM. Asserted through the real build for the same +// reason as the test above — period_detect's own coverage cannot see which span the loader picks. +static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() { + const int rate = 44100; + const std::size_t frames = 120000; + const std::int64_t loopStart = 60000; + const double kPi = 3.14159265358979323846; + const double loopPeriod = static_cast(rate) / 30.0; // 1470 frames + + // Head at 147 Hz, looped tail at 30 Hz: analysed whole, the probes split two-and-two and + // detection correctly refuses. Analysed over the loop, the 30 Hz sustain is unambiguous. + std::vector pcm(frames); + double phase = 0.0; + for (std::size_t i = 0; i < frames; ++i) { + phase += 2.0 * kPi / (i < static_cast(loopStart) ? 300.0 : loopPeriod); + pcm[i] = static_cast(std::sin(phase)); + } + + InstrumentParams noLoop; + const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop), + DecodedPcm{pcm, rate, {}}); + CHECK(bare.sourcePeriodFrames == 0.0); // no loop -> whole source -> no ONE period + + InstrumentParams looped; + looped.loopOverride = SampleLoop{true, loopStart, static_cast(frames)}; + const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), looped), + DecodedPcm{pcm, rate, {}}); + CHECK(std::fabs(sd.sourcePeriodFrames - loopPeriod) < 2.0); + + // A loop too short to host the full search band falls back to the whole source rather than + // to none — here that whole source has no one period, so the answer is the bare one above. + InstrumentParams shortLoop; + shortLoop.loopOverride = SampleLoop{true, 118000, static_cast(frames)}; + const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop), + DecodedPcm{pcm, rate, {}}); + CHECK(shortSd.sourcePeriodFrames == bare.sourcePeriodFrames); +} + static void testBuildSampleDataCarriesTheVelocityCurve() { InstrumentParams p; p.velocityCurve = VelocityCurve::linear(); @@ -994,6 +1033,7 @@ int main() { testBuildSampleDataEmptyPcmIsUnplayable(); testBuildSampleDataCarriesTheVelocityCurve(); testBuildSampleDataDetectsThirtyHertzSourcePeriod(); + testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0;