diff --git a/docs/TODO.md b/docs/TODO.md index 2ea9c43..00613f6 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,20 +2,6 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like. -## The per-voice filter is solved against the WAV's sample rate, not the render rate - -**Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates. - -**The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it. - -**Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism. - -**The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default. - -**Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge. - -**Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit. - ## Filter ring-out is truncated on the source-exhaustion path **Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out. diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 629b23d..037980b 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -170,6 +170,24 @@ pitch envelope/curve (AD?) which is off by default."* - **S15/S16 are Tier 0–1 engine features, not Tier 2/3** — do not let the held Tier-2 feature list (velocity layers / round-robin / filter work) drive their build shape. +### Two frame domains — a capture plays at its own rate in any host + +A capture's file rate and the host's are independent, and the engine never resamples: the +voice reads SOURCE frames and emits OUTPUT frames, and `SampleData::sourceRateRatio` +(`play_params.h`) is the one conversion between them, folded into the read, the Preserve feed and +the shifter's tap at note-on. + +- **Source frames:** the PCM, the loop and its crossfade, the start point, the Trigger span, the + spline phase, the detected period, and the Preserve ring (`preserveWindowFrames`). +- **Output frames (the host's):** every stage time in `SampleData::play` (resolved at + `SampleData::renderRate`), the filter solve, the live ramps and the declick. A source-offset + envelope converts through `stageFitRate`, which carries the ratio. +- **Rejected alternative: resample at load.** It would round every loop point into the host + domain (detuning a single-cycle loop), force the editor and the bank intrinsics through a + conversion at every edge, and need a band-limited resampler in the core. The cost of the + chosen shape is that a mismatched capture's ROOT note is interpolated like any other repitched + note already is. + ### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30) Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other envelopes. We @@ -325,7 +343,7 @@ anything for a trigger shape. ### `map/` -- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`. +- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the HOST rate the voice emits at (never the WAV's own — see "Two frame domains" above) — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`. - `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping. - `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v16), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable, v16 the playback rate + the baseline pitch offset. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning. - `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home. diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 4a33025..8afdc77 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -97,9 +97,14 @@ decision about what the render made obsolete. - **`defaultBakeProgram`'s read-rate bound is an upper bound, not a model.** Anything that slows the read makes the head take longer to cross its span, so the window is scaled by the slowest read the voice can reach — a shallower excursion leaves trailing silence in the file. - Rate is a term of it under BOTH engines and the deepest downward pitch offset under Varispeed - alone (`playbackStretch` argues each); both the Trigger span and the Gate exhaustion length - take the product, and the Gate-with-loop branch takes neither. + Rate and the source/host rate conversion are terms of it under BOTH engines and the deepest + downward pitch offset under Varispeed alone (`playbackStretch` argues each); both the Trigger + span and the Gate exhaustion length take the product, and the Gate-with-loop branch takes + neither. +- **The snapshot and the plan must share one rate.** The snapshot's frame counts are resolved + for its `renderRate`; the shell passes the plan's rate into `bakeSnapshot` so they agree, and + `renderBake` refuses a pair that does not rather than printing every stage time off by the + ratio. - **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves are live, so the velocity is a property of the sound being printed and not a detail of the render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window). diff --git a/src/core/instrument/bake/bake_plan.cpp b/src/core/instrument/bake/bake_plan.cpp index 04820be..4a67d09 100644 --- a/src/core/instrument/bake/bake_plan.cpp +++ b/src/core/instrument/bake/bake_plan.cpp @@ -30,19 +30,23 @@ bool toFrames(double seconds, int rate, std::int64_t& out) { } // OUTPUT frames per source frame for the dialed voice, at its slowest reachable read — the -// factor a source span is scaled by to bound how long it takes to play out. Two terms: +// factor a source span is scaled by to bound how long it takes to play out. Three terms: // // Rate divides, under BOTH engines: Varispeed folds it into the read increment and Preserve // feeds the stretcher at it, so either way the source is consumed at that many frames per // output frame. Taken through the engine's clamp, because that is the value Voice::start // actually plays. // +// The source/host rate conversion divides too, under both engines and for the same reason: +// it is a factor of the read and of the feed alike (SampleData::sourceRateRatio). +// // The deepest DOWNWARD pitch offset stretches, under Varispeed ONLY, where the read head // advances at the pitch ratio. Preserve transposes inside the shifter and leaves the read // rate alone, which is the only sense in which the two are decoupled there. // -// A Gate release is ticked per output frame, so neither term touches it. -double playbackStretch(const PlayParams& play, int velocity) { +// A Gate release is ticked per output frame, so no term touches it. +double playbackStretch(const SampleData& dialed, int velocity) { + const PlayParams& play = dialed.play; double down = 0.0; if (play.pitchEngine == PitchEngine::Varispeed) { down = (std::min)(0.0, kVelocityPitchRangeSemitones * @@ -58,7 +62,8 @@ double playbackStretch(const PlayParams& play, int velocity) { : (std::min)(0.0, play.pitchEnv.peakSemitones); } } - return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate); + return std::pow(2.0, -down / 12.0) / engine::clampStretchRate(play.playRate) / + dialed.sourceRateRatio(); } // Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top) @@ -93,7 +98,7 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate, const double rate = static_cast(renderSampleRate); const auto frameCount = static_cast(dialed.frames.size()); const std::int64_t start = effectiveStart(dialed); - const double stretch = playbackStretch(dialed.play, p.velocity.value()); + const double stretch = playbackStretch(dialed, p.velocity.value()); const double releaseSeconds = static_cast(dialed.play.adsr.releaseFrames) / rate; double endOffsetSeconds = 0.0; diff --git a/src/core/instrument/bake/bake_plan.h b/src/core/instrument/bake/bake_plan.h index c8def2c..6e5bcf7 100644 --- a/src/core/instrument/bake/bake_plan.h +++ b/src/core/instrument/bake/bake_plan.h @@ -29,11 +29,13 @@ bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t cro bool bakeWindowNeedsHold(const SampleData& dialed); // The bake's programmed note, DERIVED from the dialed sound at `renderSampleRate` (the rate -// the bake renders at, which is what the engine's frame counts are consumed against): +// the bake renders at, which is what the engine's frame counts are consumed against — and must +// be the rate `dialed` was resolved at, its renderRate): // // Trigger — the note IS the play span (note-off is ignored anyway), stretched by the -// slowest read the dialed voice can reach: Rate under BOTH engines, plus the -// deepest downward pitch offset under Varispeed. +// slowest read the dialed voice can reach: Rate and the source/host rate +// conversion under BOTH engines, plus the deepest downward pitch offset under +// Varispeed. // Gate, loop — `hold` is the note length; the end offset is the release. // Gate, no loop— the read head runs off the source and frees the voice whatever the gate is // doing, so the note is the whole post-start span, stretched the same way. diff --git a/src/core/instrument/bake/bake_render.cpp b/src/core/instrument/bake/bake_render.cpp index d0b1696..305f44c 100644 --- a/src/core/instrument/bake/bake_render.cpp +++ b/src/core/instrument/bake/bake_render.cpp @@ -23,6 +23,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL bool limiterEnabled) { BakeAudio out; if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out; + // The snapshot's frame counts were resolved at ITS output rate; rendered at another, every + // stage time and the filter corner would print off by the ratio. + if (sample.outputRate() > 0 && sample.outputRate() != plan.sampleRate) return out; // Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan // (planBake already bounds both — bake_plan.cpp) could otherwise carry leadInFrames // near INT64_MAX and signed-overflow inside the guard meant to catch exactly that. @@ -51,11 +54,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL // Pre-size the Preserve shifters here, off any audio thread, exactly as the processor // does for its live engine — a cold shifter would smear the onset. - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * static_cast(plan.sampleRate) / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow, - VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false); + VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, + preserveWindowFrames(sample), VoiceMode::Poly, MonoTrigger::Retrigger, + /*takeoverDeclick=*/false); for (std::int64_t pos = 0; pos < plan.renderFrames();) { if (pos == plan.noteOnFrame) engine.noteOn(plan.note, plan.velocity); diff --git a/src/core/instrument/bake/bake_render.h b/src/core/instrument/bake/bake_render.h index c9805fe..122aee1 100644 --- a/src/core/instrument/bake/bake_render.h +++ b/src/core/instrument/bake/bake_render.h @@ -33,7 +33,8 @@ struct BakeAudio { // runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see // bake_render.cpp for why both print here rather than in the processor. `limiterEnabled` // false yields the pre-limiter render. The result is the plan's captured window: the -// lead-in frames are rendered and dropped. An unplayable sample yields an empty result. +// lead-in frames are rendered and dropped. An unplayable sample, or one resolved for an +// output rate other than the plan's, yields an empty result. BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear, bool limiterEnabled); diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 9c917b0..134e3aa 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -56,6 +56,10 @@ reasampler_test(live_delivery LINK sampler_core) # both mode shapes share, and the Trigger tail's terminal behaviour. reasampler_test(staged_envelopes LINK sampler_core) +# A capture whose file rate differs from the host's, across the same engine: pitch, duration, +# envelope, loop and filter quantities asserted in wall-clock terms. +reasampler_test(source_rate LINK sampler_core) + # Measurement harness for Preserve on low-frequency material: how the splice search's # reachable relocation interval interacts with a long source period. Written longhand and # deliberately NOT add_test()'d — it sweeps frequencies, windows and spectra and takes ~2m40s diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index c4229e9..00f760d 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -103,8 +103,8 @@ enum class PitchEngine { Varispeed, Preserve }; // still halves duration with no params set). inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; -// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother -// on big transpositions. Onset latency is zero — start() primes the ring with the first window +// OLA window for the Preserve PitchShifter, in ms of SOURCE (preserveWindowFrames); larger = +// smoother on big transpositions. Onset latency is zero — start() primes the ring with the first window // of real source, so output frame 0 is source frame 0 regardless of window size. inline constexpr double kPreserveWindowMs = 50.0; @@ -253,9 +253,13 @@ struct SampleLoop { struct SampleData { std::vector frames; std::vector framesR; // empty for a mono sample - int sampleRate = 0; // ratio math is note-relative, so rate cancels for - // repitch; still, 0 is invalid — every consumer must - // receive a real rate before use. + // The SOURCE's own rate: the PCM, the loop, the start, the crossfade and the source period + // are all frames at this rate. + int sampleRate = 0; + // The rate the voice EMITS frames at (the host's), and so the rate every frame count in + // `play` was resolved against. 0 = not supplied, which renders the source at its own rate — + // a hand-built SampleData, or a load before the host reported a rate. + int renderRate = 0; int rootNote = 60; SampleLoop loop; @@ -305,6 +309,34 @@ struct SampleData { // Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a // voice on an empty read span. bool playable() const { return !frames.empty(); } + + int outputRate() const { return renderRate > 0 ? renderRate : sampleRate; } + + // Source frames consumed per output frame at unity pitch — THE conversion between the two + // frame domains, folded into the read and the Preserve feed at note-on. Exactly 1.0 when the + // rates agree or either is unknown, which is what keeps a same-rate render bit-identical to + // the engine before the factor existed. + double sourceRateRatio() const { + const int out = outputRate(); + return (sampleRate > 0 && out > 0 && sampleRate != out) + ? static_cast(sampleRate) / static_cast(out) + : 1.0; + } }; +// The largest sourceRateRatio a load accepts. The Preserve feed consumes that many times the +// stretch rate in source frames per output frame, so the ratio bounds per-frame work on the +// audio thread; a header claiming more than this is treated as corrupt (buildSampleData). +inline constexpr double kMaxSourceRateRatio = 32.0; + +// The Preserve ring's window in SOURCE frames. The ring holds source and its feed and tap both +// carry sourceRateRatio, so a window sized at the source rate keeps every splice interval, +// search reach and alignable period in time_stretch.h fixed in Hz at any host rate. Floored at 2 +// so a valid window is always a real ring. +inline std::int64_t preserveWindowFrames(const SampleData& s) { + const std::int64_t w = static_cast( + kPreserveWindowMs * static_cast(s.sampleRate) / 1000.0 + 0.5); + return w < 2 ? 2 : w; +} + } // namespace reasampler diff --git a/src/core/instrument/engine/time_stretch.h b/src/core/instrument/engine/time_stretch.h index 163346c..77674fa 100644 --- a/src/core/instrument/engine/time_stretch.h +++ b/src/core/instrument/engine/time_stretch.h @@ -77,9 +77,13 @@ namespace reasampler::instrument::engine { // quantity from the raw percentages here. What survives: a period longer than the reachable // jump (~1.25 windows, so below ~16 Hz at 50 ms) still cannot align, and a source with no // single period falls back to it by design (periodAlignedJump, pitch_shift.h). +// +// These bound the MUSICAL rate. The feed is that rate times the source/host rate ratio +// (StretchCursor::start), and the ratio is deliberately NOT clamped here: the geometry above +// holds in wall-clock terms at any ratio (preserveWindowFrames, play_params.h, owns why). inline constexpr double kStretchRateMin = 0.5; inline constexpr double kStretchRateMax = 2.0; -inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) +inline constexpr int kMaxFeedPerFrame = 2; // ceil(kStretchRateMax) at a matched source rate // Non-positive and NaN fold to unity rather than to the minimum: an unusable rate should leave // playback alone, not silently quarter-speed it (the same stance as setShiftRatio's refusal to @@ -97,16 +101,20 @@ inline double clampStretchRate(double rate) { class StretchCursor { public: // `frame` is where the ring prime stopped; the per-frame feed continues there. - void start(std::int64_t frame) { + // `sourceRateRatio` is source frames per output frame at rate 1.0 (SampleData's own) — + // exactly 1.0 at a matched rate, where the multiply below is the identity. + void start(std::int64_t frame, double sourceRateRatio = 1.0) { frame_ = frame; debt_ = 0.0; + sourceRatio_ = sourceRateRatio > 0.0 ? sourceRateRatio : 1.0; } // Adds one output frame's worth of source at `rate` and returns how many whole source - // frames are now due, in [0, kMaxFeedPerFrame]. Take each of them with next(). The clamp - // lives here rather than at the caller because this return value is the loop bound. + // frames are now due, in [0, ceil(kStretchRateMax * sourceRateRatio)]. Take each of them + // with next(). The clamp lives here rather than at the caller because this return value is + // the loop bound; the load's kMaxSourceRateRatio (play_params.h) is what bounds the ratio. std::int64_t due(double rate) { - debt_ += clampStretchRate(rate); + debt_ += clampStretchRate(rate) * sourceRatio_; const std::int64_t whole = static_cast(debt_); // debt_ >= 0: trunc = floor debt_ -= static_cast(whole); return whole; @@ -127,6 +135,7 @@ public: private: std::int64_t frame_ = 0; double debt_ = 0.0; // fractional source frames carried into the next output frame + double sourceRatio_ = 1.0; }; } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index a8b4e11..eac0e7d 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -65,6 +65,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick // debt accumulate the SAME value: they must stay exactly one window apart for the note's // whole life. stretchRate_ = instrument::engine::clampStretchRate(stretchRate); + sourceRatio_ = sample.sourceRateRatio(); + feedRate_ = stretchRate_ * sourceRatio_; // Keyed on the read path this note will ACTUALLY take, which is not the same question as // the stored engine: advanceFrame runs the Preserve branch only while the shifters are // configured, and a Preserve voice whose shifters were never sized falls back to the @@ -142,10 +144,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick pitchEnv_.noteOn(); // A restart lands every live glide back on the new note's own values, at a step derived - // from this sample's rate rather than any assumed one. + // from the rate this voice emits at rather than any assumed one. filterRamping_ = false; const double rampStep = instrument::engine::liveRampStep( - static_cast(sample.sampleRate)); + static_cast(sample.outputRate())); rBaseCutoff_.step = rampStep; rModAmount_.step = rampStep; rResonance_.step = rampStep; @@ -163,7 +165,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick filterKeyTrack_ = p.filter.keyTrack; filterVelCurve_ = p.filter.velocityCurve.eval(static_cast(velocity)); filterVelOffset_ = p.filter.velAmount * filterVelCurve_; - filterRate_ = static_cast(sample.sampleRate); + // The emitted rate, never the source's: the corner is a frequency of what the voice + // outputs, and one output frame is one host frame. + filterRate_ = static_cast(sample.outputRate()); rModAmount_.set(p.filter.modAmount); rResonance_.set(static_cast(p.filter.settings.resonanceNorm)); rMorph_.set(static_cast(p.filter.settings.morphNorm)); @@ -241,9 +245,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick } // Per-frame feed continues at `p` (the feed bound when the prime exhausted the // playable span). - stretch_.start(p); - shiftL_.setFeedRate(stretchRate_); - shiftR_.setFeedRate(stretchRate_); + stretch_.start(p, sourceRatio_); + shiftL_.setFeedRate(feedRate_); + shiftR_.setFeedRate(feedRate_); // 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 @@ -269,7 +273,7 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { // // live.playRate is deliberately NOT read on either path: Rate is the note-on-latched class, // delivered as start()'s argument by VoiceEngine::startVoice (live_params.h owns why). The - // latched stretchRate_ is what stageFitRate carries into every conversion below, so a + // latched feedRate_ is what stageFitRate carries into every conversion below, so a // stage-time move mid-note lands in this note's own rate domain rather than resetting it. const bool gate = (playMode_ == PlayMode::Gate); // The baseline Pitch offset IS live, under both engines: Varispeed picks the new baseRatio_ diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 7a135e6..01f891a 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -207,24 +207,27 @@ private: // THE fold of every pitch factor that is constant for the note into one number, so // advanceFrame's read increment stays the single multiply `baseRatio_ * envFactor` it has // always been: key-tracked repitch, the velocity->pitch transpose, the baseline Pitch offset, - // and the Rate ratio — which start() zeroes out of this product when the note is running the + // the Rate ratio — which start() zeroes out of this product when the note is running the // Preserve read, since Rate feeds stretch_ (duration) there and must never reach the - // shifter's transpose. Cold: note-on, legato retune, and a live block, never per frame. + // shifter's transpose — and the source/host rate conversion, which belongs in BOTH reads: + // under Preserve the shifter's ring holds source frames, so its tap needs the same factor + // to land the root on the root. Cold: note-on, legato retune, and a live block. void recomputeBaseRatio() { if (sample_ == nullptr) return; baseRatio_ = keyTrackedRatio(note_, sample_->rootNote, keyTrack_) * - velPitchRatio_ * pitchOffsetRatio_ * rateRatio_; + velPitchRatio_ * pitchOffsetRatio_ * rateRatio_ * sourceRatio_; } // The rate the read head consumes SOURCE at, counting only the factors whose stage-time - // coupling is compensated. Under Preserve that is the stretch rate alone — the Pitch offset + // coupling is compensated. Under Preserve that is the feed rate alone — the Pitch offset // transposes inside the shifter and never touches the read. Under Varispeed both Rate and // Pitch are factors of the read increment and both are compensated: they are two views of one - // multiply, so the "30 ms is 30 ms" rule binds them identically. Key-tracking and the - // velocity->pitch transpose are deliberately LEFT OUT — those predate Rate, are shipped - // sounds, and compensating them would move every note off the root. + // multiply, so the "30 ms is 30 ms" rule binds them identically. The source/host conversion + // (inside feedRate_) is compensated under both, because it is a change of units, not a + // sound. Key-tracking and the velocity->pitch transpose are deliberately LEFT OUT — those + // predate Rate, are shipped sounds, and compensating them would move every note off the root. double stageFitRate() const { - return preserveRead_ ? stretchRate_ : stretchRate_ * pitchOffsetRatio_; + return preserveRead_ ? feedRate_ : feedRate_ * pitchOffsetRatio_; } // A staged AHD's wall-clock stage frames converted into the SOURCE-offset domain the @@ -258,7 +261,7 @@ private: const double postStart = static_cast( static_cast(sample_->frames.size()) - startFrame_); const double readRate = - preserveRead_ ? stretchRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_; + preserveRead_ ? feedRate_ : pitchSpanBaseRate_ * pitchOffsetRatio_; const double span = (readRate > 0.0) ? postStart / readRate : postStart; return static_cast(span + 0.5); } @@ -529,8 +532,8 @@ private: double outL, outRlocal = 0.0; if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { // The two rates the shifter takes (pitch_shift.h owns why they are independent): - // the source is FED at stretchRate_, and the tap is SHIFTED by - // 2^((note-root + pitchEnvSemis)/12) — the pitch envelope adds to the shift amount, + // the source is FED at feedRate_, and the tap is SHIFTED by baseRatio_ x + // 2^(pitchEnvSemis/12) — the pitch envelope adds to the shift amount, // never to the read rate. The feed runs one window ahead of readPos_ (the rings were // primed with that window at start()), under the same sustain-loop wrap rule, // reading integer source frames into the ring — no RATE-DEPENDENT interpolation @@ -607,13 +610,13 @@ private: outRlocal = shiftedL; } } - // Preserve advances the read head at the STRETCH rate — the one duration control. - // Everything downstream of it (the loop wrap, the Trigger span, the spline phase) - // therefore stays a source-frame fact and scales by construction. + // Preserve advances the read head at the FEED rate — the one duration control, in + // source frames. Everything downstream of it (the loop wrap, the Trigger span, the + // spline phase) therefore stays a source-frame fact and scales by construction. // // The two sustain-less envelopes are evaluated at sourceOffset(), which advances at // this rate — rateFittedAhd is what keeps their stage times wall-clock anyway. - ratio_ = stretchRate_; + ratio_ = feedRate_; } else { // VARISPEED: pitch and duration coupled. The read rate carries the repitch; the // pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when @@ -816,6 +819,11 @@ private: PitchShifter shiftR_; instrument::engine::StretchCursor stretch_; double stretchRate_ = 1.0; // Preserve playback rate, clamped and latched at note-on + // SampleData::sourceRateRatio, latched at note-on: source frames per output frame at unity. + double sourceRatio_ = 1.0; + // stretchRate_ in source frames per output frame (x sourceRatio_): the Preserve read head's + // and feed's exact rate, and the Rate factor every stage-time fit converts by. + double feedRate_ = 1.0; std::vector primeBuf_; // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start() diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 6e912e8..628f1cf 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -309,28 +309,34 @@ std::optional resolveFromRefs(const SampleRefs& refs, return resolveCapture(*ref, params); } -SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) { +SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded, + int renderRate) { SampleData data; if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence assert(decoded.sampleRate > 0 && "buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)"); if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first + data.sampleRate = decoded.sampleRate; + data.renderRate = renderRate > 0 ? renderRate : 0; + // A header rate this far above the host's is corrupt, not a capture — and playing it would + // put that ratio's worth of feed work on the audio thread per frame (kMaxSourceRateRatio). + if (data.sourceRateRatio() > kMaxSourceRateRatio) return SampleData{}; data.frames = std::move(decoded.monoFrames); // Carry the second channel only when it length-matches channel 0 (channelCount() // enforces the same rule; a mismatched pair falls back to mono rather than half-play). if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) { data.framesR = std::move(decoded.framesR); } - data.sampleRate = decoded.sampleRate; data.rootNote = resolved.rootNote; data.loop = resolved.loop; data.loopCrossfadeFrames = resolved.loopCrossfadeFrames; data.startFrame = resolved.startFrame; data.keyTrack = resolved.keyTrack; data.velocityCurve = resolved.velocityCurve; - // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's - // actual rate; source-timeline params (trigger %-length + fades, start) carry through. - data.play = resolvePlay(resolved.play, data.sampleRate); + // Resolve the stored wall-clock SECONDS to OUTPUT frames — every stage the engine counts is + // counted per emitted frame, or converted into source frames at note-on. Source-timeline + // params (trigger %-length, start, loop, crossfade) carry through in the WAV's own frames. + data.play = resolvePlay(resolved.play, data.outputRate()); // The one place Preserve's source period is computed: the load, off the audio thread. // Channel 0 only — a stereo pair's two channels share a fundamental, and the splice // schedule is linked across them anyway. The span is the sustain loop where one is long diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index ae1be19..af72e1c 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -145,9 +145,9 @@ std::vector extractChannel(const std::vector& interlea // --- Seconds -> frames -------------------------------------------------------- -// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live -// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through -// unchanged. `sampleRate` must be > 0 (the caller guards this). +// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against the rate the +// voice EMITS at — the host's (frames = round(seconds * rate)). Source-timeline fields carry +// through unchanged. `sampleRate` must be > 0 (the caller guards this). PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate); // --- The instrument's ONE parameter set (its OWN state) ----------------------- @@ -263,10 +263,13 @@ DecodedPcm decodeChannels(const std::vector& interleaved, // Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine // plays across the whole keyboard, repitched from the effective root. A second channel is // carried only when it length-matches channel 0 (SampleData::channelCount() enforces the -// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to -// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an -// unplayable SampleData (silence, never a crash). -SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded); +// same rule, so a bad pair never half-plays). `renderRate` is the host's rate: the stored +// wall-clock SECONDS resolve to frames against it, and the voice converts the decode's own +// rate to it (SampleData::sourceRateRatio). A non-positive renderRate (the host has not +// reported one yet) renders at the decode's rate. Empty PCM, a non-positive decode rate, or a +// decode rate more than kMaxSourceRateRatio times the host's yields an unplayable SampleData +// (silence, never a crash). +SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded, int renderRate); // The ComponentState envelope + params-payload binary codec lives in component_state_io.h: // it grows on every envelope bump and is consumed by the extension's preset-blob path too, diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index d21cd82..33f1ba1 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -214,7 +214,7 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT ## Modules - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. -- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. +- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source — or when the host rate is not the one the park (or a publish that landed while inactive) was resolved for, since every stage time in it and in the live block is in that rate's frames. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below. - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 8159a64..7820c73 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -101,7 +101,7 @@ void ReaSamplerEditor::resetParamControl(int id) { } double ReaSamplerEditor::liveSampleRate() const { - return processor_ ? processor_->sampleRate() : 0.0; + return processor_ ? static_cast(processor_->loadedSourceRate()) : 0.0; } double ReaSamplerEditor::previewVelocity01() const { diff --git a/src/shell/instrument/instrument_bake.cpp b/src/shell/instrument/instrument_bake.cpp index ed351bd..d55ecaf 100644 --- a/src/shell/instrument/instrument_bake.cpp +++ b/src/shell/instrument/instrument_bake.cpp @@ -114,7 +114,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { if (!sourceEntry) return fail("the loaded capture has no resolvable file"); const SelectedSample* source = &sourceEntry->ref; - const int sampleRate = static_cast(processor.sampleRate()); + const int sampleRate = processor.hostRenderRate(); if (sampleRate <= 0) return fail("the host has not reported a sample rate yet"); const std::optional tempo = Tempo::fromBpm(bridge.projectTempoBpm()); @@ -128,7 +128,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { // The snapshot comes first: the default program's window is derived from the sound it // carries (a Gate release, a Trigger play span), not from a constant. - std::optional snapshot = processor.bakeSnapshot(); + std::optional snapshot = processor.bakeSnapshot(sampleRate); if (!snapshot) return fail("the loaded capture could not be decoded for the render"); // The note fires at the velocity the user has been auditioning at: three velocity curves diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 5c32d62..e9d8aff 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -81,10 +81,12 @@ std::optional decodeRelative(const std::string& projectDir, // never be built from a different fold than the one the user is hearing. std::optional buildFromRef(const SelectedSample& sel, const InstrumentParams& params, - const std::string& projectDir, ChannelMode mode) { + const std::string& projectDir, ChannelMode mode, + int renderRate) { std::optional pcm = decodeRelative(projectDir, sel.relativePath, mode); if (!pcm) return std::nullopt; - SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm)); + SampleData sample = + buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate); if (!sample.playable()) return std::nullopt; return sample; } @@ -143,7 +145,7 @@ std::string ReaSamplerProcessor::reloadInstrument() { mode = channelMode_; } if (std::optional decoded = - buildFromRef(*sel, params, projectDir, mode)) { + buildFromRef(*sel, params, projectDir, mode, hostRenderRate())) { sample = std::move(*decoded); havePlayable = true; // Point the built snapshot at the instance's ONE engine-facing block and seed the @@ -156,9 +158,10 @@ std::string ReaSamplerProcessor::reloadInstrument() { // BEFORE the publish: the publish is what makes the audio thread re-merge, and the // merge resolves every held automation stage time against this rate. Stored after, // a merge in the window between them would resolve them against the previous - // capture's rate — or, on the first-ever build, against 0, where secondsToFrames + // build's rate — or, on the first-ever build, against 0, where secondsToFrames // collapses every automated envelope stage to zero frames. - builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed); + builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed); + loadedSourceRate_.store(sample.sampleRate, std::memory_order_relaxed); { // reloadMutex_ (held for this whole function) nests livePublishMutex_ here; // publishLiveParams never holds reloadMutex_, so this is the only nesting. @@ -189,7 +192,7 @@ std::string ReaSamplerProcessor::usageInstanceGuid() { return instanceGuid_; } -std::optional ReaSamplerProcessor::bakeSnapshot() { +std::optional ReaSamplerProcessor::bakeSnapshot(int renderRate) { const std::string selId = selectedSampleId(); if (selId.empty()) return std::nullopt; const InstrumentParams params = instrumentParams(); @@ -202,7 +205,7 @@ std::optional ReaSamplerProcessor::bakeSnapshot() { if (!sel) return std::nullopt; // No live block is attached: the bake renders the stored parameter set, which is what // every knob has already written, rather than whatever the audio thread is observing. - return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode()); + return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode(), renderRate); } void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry, @@ -328,13 +331,8 @@ ReaSamplerProcessor::buildInstrumentLocked(SampleData sample) { builtVoiceMode = voiceMode_; builtMonoTrigger = monoTrigger_; } - // Preserve OLA window in output frames from the host rate (kPreserveWindowMs), pre-sized - // here so process()-time note-on never allocates. Floored at 2 so a valid window is always - // a real ring, covering a pathological host rate <= 0 too. Re-derived per build, so a - // reactivation after the host changed its rate gets a ring sized for the new one. - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; + // Pre-sized here so process()-time note-on never allocates. + const std::int64_t preserveWindow = preserveWindowFrames(sample); const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; return std::make_unique( std::move(sample), static_cast(builtVoiceCount), gen, @@ -358,11 +356,18 @@ bool ReaSamplerProcessor::resumeDormantInstrument() { // Off the audio thread (setActive only). The reactivation half of the lifetime split: the // voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is. std::lock_guard lock(reloadMutex_); + // Every frame count in a built sample — and in the live block published beside it — was + // resolved for the host rate of its build. setupProcessing may have changed that rate while + // inactive (or a setState load ran before the host reported one), and neither a park nor a + // publish can be re-used across it: the full reload re-resolves both. + const int renderRate = hostRenderRate(); // A publish that landed while inactive (setState's reload, a bake's adopt) IS the // activation state — its voices have never rendered, and it has already superseded the // park. Rebuilding here would displace a correct instrument into the drain slot. - if (live_.load(std::memory_order_acquire)) return true; - if (!dormantSample_) return false; + if (const LoadedInstrument* cur = live_.load(std::memory_order_acquire)) { + return cur->sample.renderRate == renderRate; + } + if (!dormantSample_ || dormantSample_->renderRate != renderRate) return false; // The activation is still where a bank change made with NO EDITOR OPEN is picked up: // pollBankSync, the only other route to either of the two calls below, runs off the diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 8750798..d659277 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -409,7 +409,9 @@ private: // The effective root: params_.rootOverride, else the bank intrinsic, else middle C. int effectiveRoot() const; - // The live sample rate from the bridge, or 0 when unavailable (caller guards). + // The loaded capture's OWN rate — the time base that maps its waveform frames to the + // seconds a staged envelope stores — or 0 when nothing has loaded (caller guards). Not the + // host's: the voice plays a source frame at the source's rate whatever the host runs at. double liveSampleRate() const; // Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]). double previewVelocity01() const; diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 8b0f177..923ba78 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -146,11 +146,12 @@ public: std::string reloadInstrument(); // The dialed sound as plain data for the offline bake: the SAME refs resolve + decode + - // build reloadInstrument runs, with no live block attached. Rebuilt rather than copied - // off the live snapshot because a tier-3 live edit leaves that snapshot's own play - // params stale on purpose — copying it would bake the pre-drag values. nullopt when - // nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O). - std::optional bakeSnapshot(); + // build reloadInstrument runs, with no live block attached, resolved for `renderRate` — + // the rate the bake's plan renders at, passed in so the two cannot disagree. Rebuilt + // rather than copied off the live snapshot because a tier-3 live edit leaves that + // snapshot's own play params stale on purpose — copying it would bake the pre-drag values. + // nullopt when nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O). + std::optional bakeSnapshot(int renderRate); // Adopt a landed bake in ONE act: the new capture becomes this instance's ref and // selection, the parameter set and master gain go neutral, and a single reload @@ -178,9 +179,16 @@ public: // editor borrows it (outlives the editor). ReaperBridge& bridge() { return bridge_; } - // The live host sample rate latched from setupProcessing; the editor's envelope overlay - // shares this time base. 0.0 before setupProcessing runs. + // The live host sample rate latched from setupProcessing. 0.0 before setupProcessing runs. double sampleRate() const { return sampleRate_; } + // The same rate as the whole-Hz int every SampleData build and bake plan takes, so the + // reload and the bake round it one way. 0 before setupProcessing runs. + int hostRenderRate() const { + return sampleRate_ > 0.0 ? static_cast(sampleRate_ + 0.5) : 0; + } + // The loaded capture's OWN rate — the time base of its waveform frames, which is what the + // editor's staged-envelope overlay maps seconds onto. 0 before anything has loaded. + int loadedSourceRate() const { return loadedSourceRate_.load(std::memory_order_relaxed); } // The loaded capture's id (guarded by selectionMutex_, never read on the audio thread). // Empty id -> silence, no first-sample fallback. std::string selectedSampleId(); @@ -330,8 +338,9 @@ private: // folds the live bank blob into the refs, rebuilds the voice state around the parked // sample, publishes it through the same drain-slot swap, and republishes usage. True also // when a publish landed while inactive, which needs no rebuild. False when there is nothing - // to activate from, or when that fold moved what the park was decoded from — the caller - // then falls back to a full reload, which decodes the new file and is also where the pre-v10 + // to activate from, when the park or that publish was resolved for a host rate that has + // since changed, or when that fold moved what the park was decoded from — the caller then + // falls back to a full reload, which decodes the new file and is also where the pre-v10 // legacy lift lives. Off the audio thread only. bool resumeDormantInstrument(); @@ -409,17 +418,17 @@ private: // reload's WAV decode. The audio thread never takes this; process() only reads via // LiveParams::read's lock-free seqlock retry. std::mutex livePublishMutex_; - // The rate the loaded capture was decoded/built at, so a live republish resolves the - // stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing - // built yet. + // The output rate the loaded capture's play params were resolved at (SampleData:: + // outputRate — the host's), so a live republish resolves the stored wall-clock seconds to + // exactly the frames the built SampleData carries. 0 = nothing built yet. // - // ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload - // publishes the new block before installing the new instrument. Swapping to a capture at a - // different rate therefore hands drain voices still ringing from the old-rate capture - // envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap). - // Unavoidable while one block sits above every snapshot, and it touches a release tail - // only. + // ONE BLOCK, ONE RATE: one block sits above every snapshot, so a drain voice reads frame + // counts resolved at whatever this last became — harmless while it is the HOST rate for + // every capture. The one residue is a build made before the host reported a rate, which + // resolves at the capture's own and is replaced by the activation's reload. std::atomic builtSampleRate_{0}; + // The loaded capture's own decode rate; see loadedSourceRate(). + std::atomic loadedSourceRate_{0}; // --- The audio-thread handoff (drain slot) --- // process() atomically loads live_ + draining_ at block start (two acquires, no lock). @@ -553,7 +562,8 @@ private: std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired - // Latched from setupProcessing; 0.0 is explicitly invalid (reloadInstrument guards on it). + // Latched from setupProcessing; 0.0 = not reported yet (a build then renders at the + // capture's own rate until resumeDormantInstrument's rate check sends it back to a reload). double sampleRate_ = 0.0; Steinberg::int32 maxBlockSize_ = 4096; diff --git a/tests/test_bake_window.cpp b/tests/test_bake_window.cpp index ee5671c..79ba2d5 100644 --- a/tests/test_bake_window.cpp +++ b/tests/test_bake_window.cpp @@ -11,6 +11,9 @@ #include #include #include +#include + +#include "tone_metrics.h" using namespace reasampler; using namespace reasampler::instrument::bake; @@ -488,6 +491,54 @@ int main() { CHECK(hardPeak > softPeak * 2.0); } + // ============================ SOURCE RATE ======================================= + + // --- A capture at another rate bakes at its own pitch and its own wall-clock length -- + // 441 Hz for one second of a 44.1k file, baked at 48k: the window must hold 48000 frames + // of sound (not the source's 44100), and the print must be 441 Hz at the render rate. + for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + constexpr int kFileRate = 44100; + constexpr double kHz = 441.0; + SampleData s; + s.frames.resize(static_cast(kFileRate)); + for (std::size_t i = 0; i < s.frames.size(); ++i) { + s.frames[i] = static_cast( + 0.5 * std::sin(2.0 * 3.14159265358979323846 * kHz * static_cast(i) / + kFileRate)); + } + s.sampleRate = kFileRate; + s.renderRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = engine; + s.play.trigAhd.holdFraction = 1.0; + + CHECK(derivedFrames(s) == kRate + kPad); + const BakeAudio audio = bakeWith(s, 0.0); + CHECK(peakAt(audio, kRate - 1000, kRate - 10) > 0.4); // still sounding near 1 s + CHECK(lastSoundingFrame(audio) < audio.frameCount() - 1); // and done inside the window + + std::vector mono(static_cast(audio.frameCount())); + for (std::size_t f = 0; f < mono.size(); ++f) { + mono[f] = audio.interleaved[f * static_cast(audio.channelCount)]; + } + const double want = static_cast(kRate) / kHz; + const double got = reasampler::test_support::autocorrelationPeriod( + mono, 10000, 8000, static_cast(want * 0.5), + static_cast(want * 1.7)); + CHECK(std::fabs(got - want) < 0.005 * want); + } + + // --- A snapshot resolved for another output rate is refused, not printed off-pitch --- + { + SampleData s = dcSample(24000); + s.renderRate = 44100; // resolved for 44.1k; the plan renders at 48k + s.play.playMode = PlayMode::Trigger; + const std::optional plan = planOf(derivedProgram(s, 0.0)); + CHECK(plan.has_value()); + if (plan) CHECK(renderBake(s, *plan, kUnity, kNoLimiter).empty()); + } + // ============================== REFUSALS ======================================== // --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so -------- diff --git a/tests/test_period_render_integration.cpp b/tests/test_period_render_integration.cpp index ad7bd3c..8b6dad8 100644 --- a/tests/test_period_render_integration.cpp +++ b/tests/test_period_render_integration.cpp @@ -79,7 +79,7 @@ static void testDetectedPeriodReachesAndMovesThePreserveRender() { ref.relativePath = "b/a.wav"; ref.rootNote = 60; SampleData on = buildSampleData(resolveCapture(ref, InstrumentParams{}), - DecodedPcm{pcm, rate, {}}); + DecodedPcm{pcm, rate, {}}, rate); CHECK(std::fabs(on.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz, real detection on.play.adsr = flatAdsr(); diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 5138782..46c3b51 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -891,38 +891,74 @@ static void testBuildSampleDataThreadsEverything() { p.keyTrack = 0.25; p.play.adsr.attackSeconds = 0.01; const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p), - DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}}); + DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}}, 48000); CHECK(sd.playable()); CHECK(sd.frames.size() == 3); CHECK(sd.sampleRate == 48000); + CHECK(sd.renderRate == 48000); CHECK(sd.rootNote == 40); // the override, not the capture's 60 CHECK(sd.loop.hasLoop && sd.loop.start == 10 && sd.loop.end == 90); CHECK(sd.startFrame == 7); CHECK(sd.keyTrack == 0.25); CHECK(sd.channelCount() == 1); - // Wall-clock seconds resolve to frames at THIS decode's rate. + // Wall-clock seconds resolve to frames at the render rate. CHECK(sd.play.adsr.attackFrames == 480); } -static void testBuildSampleDataResolvesSecondsAtTheDecodeRate() { - // The rate that governs the conversion is the DECODE's, not a baked constant: the same - // parameter set built against two decodes yields two different frame counts. +static void testBuildSampleDataResolvesSecondsAtTheRenderRate() { + // The voice counts every stage in the frames it EMITS, so the host's rate governs the + // conversion, never the decode's: two decodes rendered at one host rate resolve alike, and + // one decode rendered at two host rates does not. InstrumentParams p; p.play.adsr.attackSeconds = 0.1; p.play.adsr.releaseSeconds = 0.25; + p.play.trigAhd.attackSeconds = 0.02; const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), p); - const SampleData at44 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}); - const SampleData at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}}); - CHECK(at44.play.adsr.attackFrames == 4410); - CHECK(at44.play.adsr.releaseFrames == 11025); - CHECK(at96.play.adsr.attackFrames == 9600); - CHECK(at96.play.adsr.releaseFrames == 24000); + const SampleData file44at48 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}, 48000); + const SampleData file96at48 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}}, 48000); + const SampleData file44at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}, 96000); + CHECK(file44at48.play.adsr.attackFrames == 4800); + CHECK(file44at48.play.adsr.releaseFrames == 12000); + CHECK(file44at48.play.trigAhd.attackFrames == 960); + CHECK(file96at48.play.adsr.attackFrames == 4800); + CHECK(file96at48.play.adsr.releaseFrames == 12000); + CHECK(file44at96.play.adsr.attackFrames == 9600); + CHECK(file44at96.play.adsr.releaseFrames == 24000); + // The decode's rate stays the SOURCE's, and the conversion between the two is the ratio. + CHECK(file44at48.sampleRate == 44100 && file44at48.renderRate == 48000); + CHECK(file44at48.sourceRateRatio() == 44100.0 / 48000.0); +} + +static void testBuildSampleDataWithNoHostRateRendersAtTheDecodeRate() { + // Before the host reports a rate there is nothing to convert to: the capture renders at its + // own rate, exactly as a hand-built SampleData does, until a rebuild supplies one. + InstrumentParams p; + p.play.adsr.attackSeconds = 0.1; + const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p), + DecodedPcm{{0.1f}, 44100, {}}, 0); + CHECK(sd.playable()); + CHECK(sd.renderRate == 0); + CHECK(sd.outputRate() == 44100); + CHECK(sd.sourceRateRatio() == 1.0); + CHECK(sd.play.adsr.attackFrames == 4410); +} + +static void testBuildSampleDataRefusesARateNoCaptureCouldHave() { + // The ceiling is a bound on audio-thread work, so the refusal sits exactly at it. + const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{}); + const int host = 48000; + const auto atCeiling = static_cast(host * kMaxSourceRateRatio); + CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling, {}}, host).playable()); + const SampleData corrupt = buildSampleData(rc, DecodedPcm{{0.1f}, atCeiling + 1, {}}, host); + CHECK(!corrupt.playable()); + // Far BELOW the host costs nothing per frame, so it plays. + CHECK(buildSampleData(rc, DecodedPcm{{0.1f}, 1000, {}}, host).playable()); } static void testBuildSampleDataCarriesTheSecondChannel() { const SampleData sd = buildSampleData( resolveCapture(ref("b/a.wav", 60), InstrumentParams{}), - DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}}); + DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}}, 44100); CHECK(sd.channelCount() == 2); CHECK(sd.framesR.size() == 2 && approx(sd.framesR[0], 0.9) && approx(sd.framesR[1], 0.8)); } @@ -931,7 +967,7 @@ static void testBuildSampleDataDropsMismatchedSecondChannel() { // A malformed pair must fall back to MONO rather than half-playing. const SampleData sd = buildSampleData( resolveCapture(ref("b/a.wav", 60), InstrumentParams{}), - DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}}); + DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}}, 44100); CHECK(sd.channelCount() == 1); CHECK(sd.framesR.empty()); } @@ -941,7 +977,7 @@ static void testBuildSampleDataEmptyPcmIsUnplayable() { // SampleData (silence), never a voice started on an empty read span. (A non-positive // rate is a programming error the build asserts on, so it is not exercised here.) const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{}); - const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}}); + const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}}, 44100); CHECK(!sd.playable()); CHECK(sd.frames.empty()); } @@ -959,7 +995,7 @@ static void testBuildSampleDataDetectsThirtyHertzSourcePeriod() { std::sin(2.0 * 3.14159265358979323846 * 30.0 * static_cast(i) / rate)); } const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), InstrumentParams{}), - DecodedPcm{pcm, rate, {}}); + DecodedPcm{pcm, rate, {}}, rate); CHECK(std::fabs(sd.sourcePeriodFrames - 1470.0) < 2.0); // 44100 / 30 Hz } @@ -984,13 +1020,13 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() { InstrumentParams noLoop; const SampleData bare = buildSampleData(resolveCapture(ref("b/a.wav", 60), noLoop), - DecodedPcm{pcm, rate, {}}); + DecodedPcm{pcm, rate, {}}, 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, {}}); + DecodedPcm{pcm, rate, {}}, 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 @@ -1000,7 +1036,7 @@ static void testBuildSampleDataDetectsOverTheSustainLoopNotTheWholeSource() { InstrumentParams shortLoop; shortLoop.loopOverride = SampleLoop{true, 118000, static_cast(frames)}; const SampleData shortSd = buildSampleData(resolveCapture(ref("b/a.wav", 60), shortLoop), - DecodedPcm{pcm, rate, {}}); + DecodedPcm{pcm, rate, {}}, rate); CHECK(shortSd.sourcePeriodFrames == 0.0); } @@ -1008,7 +1044,7 @@ static void testBuildSampleDataCarriesTheVelocityCurve() { InstrumentParams p; p.velocityCurve = VelocityCurve::linear(); const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p), - DecodedPcm{{0.1f}, 44100, {}}); + DecodedPcm{{0.1f}, 44100, {}}, 44100); // The curve reaches the engine's own copy: a mid velocity maps to ~half gain, which the // flat default would not do. CHECK(std::fabs(sd.velocityCurve.eval(64.0) - 64.0 / 127.0) < 1e-6); @@ -1067,7 +1103,9 @@ int main() { testResolveNoPickAndStaleIdAreSilence(); testResolveFromRefsNeedsNoBankAtAll(); testBuildSampleDataThreadsEverything(); - testBuildSampleDataResolvesSecondsAtTheDecodeRate(); + testBuildSampleDataResolvesSecondsAtTheRenderRate(); + testBuildSampleDataWithNoHostRateRendersAtTheDecodeRate(); + testBuildSampleDataRefusesARateNoCaptureCouldHave(); testBuildSampleDataCarriesTheSecondChannel(); testBuildSampleDataDropsMismatchedSecondChannel(); testBuildSampleDataEmptyPcmIsUnplayable(); diff --git a/tests/test_source_rate.cpp b/tests/test_source_rate.cpp new file mode 100644 index 0000000..fe2b42c --- /dev/null +++ b/tests/test_source_rate.cpp @@ -0,0 +1,392 @@ +// A capture whose file rate differs from the host's must play at its own pitch and for its own +// wall-clock duration, with every envelope, loop and filter quantity landing where it would at a +// matched rate. Every case renders through a real Voice at (file rate, host rate) pairs in both +// directions and at a large ratio, and asserts in wall-clock / Hz terms — output frames at the +// host rate — never in source frames. + +#include "../src/core/instrument/engine/voice.h" + +#include +#include +#include +#include + +#include "tone_metrics.h" + +using namespace reasampler; +namespace flt = reasampler::instrument::engine::filter; +using reasampler::test_support::autocorrelationPeriod; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +namespace { + +constexpr double kPi = 3.14159265358979323846; + +struct RateCase { + int file; + int host; +}; +// Up, down, and a ratio past 2 — the Preserve feed then takes more than two source frames per +// output frame, which is the case the stretcher's own [0.5, 2] bound must not clip. +constexpr RateCase kCases[] = {{44100, 48000}, {48000, 44100}, {96000, 44100}}; + +double ratioOf(const RateCase& c) { + return static_cast(c.file) / static_cast(c.host); +} + +SampleData sineSample(const RateCase& c, double hz, double seconds) { + SampleData s; + const auto n = static_cast(std::llround(seconds * c.file)); + s.frames.resize(n); + for (std::size_t i = 0; i < n; ++i) { + s.frames[i] = static_cast( + 0.5 * std::sin(2.0 * kPi * hz * static_cast(i) / c.file)); + } + s.sampleRate = c.file; + s.renderRate = c.host; + s.rootNote = 60; + return s; +} + +SampleData dcSample(const RateCase& c, std::size_t frames) { + SampleData s; + s.frames.assign(frames, 1.0f); + s.sampleRate = c.file; + s.renderRate = c.host; + return s; +} + +void startVoice(Voice& v, const SampleData& s, int note) { + if (s.play.pitchEngine == PitchEngine::Preserve) v.presizePreserveShifters(preserveWindowFrames(s)); + v.start(note, 100, s); +} + +std::vector render(const SampleData& s, int note, int frames) { + Voice v; + startVoice(v, s, note); + std::vector out(static_cast(frames), 0.0); + for (auto& x : out) x = v.renderFrame(); + return out; +} + +// Output frames the voice renders before it goes idle, capped. +std::int64_t activeFrames(const SampleData& s, int note, std::int64_t cap) { + Voice v; + startVoice(v, s, note); + std::int64_t n = 0; + while (v.active() && n < cap) { + v.renderFrame(); + ++n; + } + return n; +} + +double measuredPeriod(const std::vector& out, std::size_t from, std::size_t len, + double expected) { + return autocorrelationPeriod(out, from, len, static_cast(expected * 0.5), + static_cast(expected * 1.7)); +} + +bool within(double got, double want, double relTol) { + return std::fabs(got - want) <= relTol * std::fabs(want); +} + +double rms(const std::vector& x, std::size_t from, std::size_t to) { + double acc = 0.0; + for (std::size_t i = from; i < to; ++i) acc += x[i] * x[i]; + return std::sqrt(acc / static_cast(to - from)); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Pitch and duration. +// --------------------------------------------------------------------------- + +static void testVarispeedRootNotePlaysAtItsOwnPitchAndDuration() { + constexpr double kHz = 441.0; + for (const RateCase& c : kCases) { + const SampleData s = sineSample(c, kHz, 0.5); + const double wantPeriod = static_cast(c.host) / kHz; + const std::vector out = render(s, 60, 8000); + CHECK(within(measuredPeriod(out, 1000, 4000, wantPeriod), wantPeriod, 0.002)); + + // Half a second of source is half a second at the host rate, plus the one frame that + // detects the run-off; the slack is the read head's accumulated rounding at the edge. + const double wantFrames = static_cast(s.frames.size()) / ratioOf(c) + 1.0; + const auto got = static_cast(activeFrames(s, 60, 200000)); + CHECK(std::fabs(got - wantFrames) <= 1.5); + } +} + +static void testPreserveRootAndOctaveHoldPitchAndWallClockDuration() { + constexpr double kHz = 441.0; + for (const RateCase& c : kCases) { + SampleData s = sineSample(c, kHz, 0.5); + s.play.pitchEngine = PitchEngine::Preserve; + const double rootPeriod = static_cast(c.host) / kHz; + const double sourceSpan = static_cast(s.frames.size()) / ratioOf(c); + + const std::vector root = render(s, 60, 12000); + CHECK(within(measuredPeriod(root, 2000, 6000, rootPeriod), rootPeriod, 0.005)); + const std::vector octave = render(s, 72, 12000); + CHECK(within(measuredPeriod(octave, 2000, 6000, rootPeriod / 2.0), rootPeriod / 2.0, + 0.005)); + + // Duration is the source's own under Preserve at ANY note: the read runs to the end of + // the source and then rings the terminal declick out. + for (int note : {60, 72}) { + const auto got = static_cast(activeFrames(s, note, 200000)); + CHECK(got >= sourceSpan - 1.0); + CHECK(got <= sourceSpan + static_cast(kDeclickFrames) + 2.0); + } + } +} + +static void testTriggerPlayPercentageIsAFractionOfTheWallClockLength() { + for (const RateCase& c : kCases) { + SampleData s = dcSample(c, static_cast(c.file / 2)); // 0.5 s + s.play.playMode = PlayMode::Trigger; + s.play.trigger.lengthFraction = 0.5; + s.play.trigAhd.holdFraction = 1.0; + // A quarter second at the host rate, plus the frame that detects the span's end. + const double want = static_cast(c.host) * 0.25 + 1.0; + const auto got = static_cast(activeFrames(s, 60, 200000)); + CHECK(std::fabs(got - want) <= 1.5); + } +} + +// --------------------------------------------------------------------------- +// Envelope stage times are wall-clock: counted in OUTPUT frames at the host rate. +// --------------------------------------------------------------------------- + +static void testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines() { + for (const RateCase& c : kCases) { + for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + SampleData s = dcSample(c, static_cast(c.file)); // 1 s + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = engine; + // 20 ms, resolved at the host rate as resolvePlay does. + const std::int64_t attack = c.host / 50; + s.play.trigAhd.attackFrames = attack; + s.play.trigAhd.holdFraction = 1.0; + const std::vector out = render(s, 60, static_cast(attack * 2)); + // Linear attack: half way at half the attack, full just past it (the fitted attack is + // rounded to whole SOURCE frames, so "just past" carries a few frames of slack). + CHECK(std::fabs(out[static_cast(attack / 2)] - 0.5) < 0.01); + CHECK(out[static_cast(attack + 4)] > 0.99); + } + } +} + +static void testGateAdsrAttackTakesItsWallClockTime() { + for (const RateCase& c : kCases) { + SampleData s = dcSample(c, static_cast(c.file)); + const std::int64_t attack = c.host / 50; + s.play.adsr.attackFrames = attack; + s.play.adsr.sustainLevel = 1.0; + const std::vector out = render(s, 60, static_cast(attack * 2)); + CHECK(std::fabs(out[static_cast(attack / 2)] - 0.5) < 0.01); + CHECK(out[static_cast(attack + 1)] > 0.99); + } +} + +// The pitch AHD's Hold fraction is taken against the playable span in OUTPUT frames. Under +// Preserve the read runs at the source rate exactly, so a half-span hold must end half way +// through the sample's wall-clock length — not half way through its SOURCE frame count. +static void testPitchEnvelopeSpanIsTheSourcesWallClockLength() { + constexpr double kHz = 441.0; + for (const RateCase& c : kCases) { + SampleData s = sineSample(c, kHz, 1.0); + s.play.pitchEngine = PitchEngine::Preserve; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.peakSemitones = 12.0; + s.play.pitchEnv.shape.holdFraction = 0.5; // A = D = 0: peak for exactly half the span + const double rootPeriod = static_cast(c.host) / kHz; + const auto mid = static_cast(static_cast(c.host) * 0.5); + const std::vector out = render(s, 60, static_cast(c.host)); + constexpr std::size_t kLen = 3000; + constexpr std::size_t kMargin = 1500; + CHECK(within(measuredPeriod(out, mid - kMargin - kLen, kLen, rootPeriod / 2.0), + rootPeriod / 2.0, 0.01)); + CHECK(within(measuredPeriod(out, mid + kMargin, kLen, rootPeriod), rootPeriod, 0.01)); + } +} + +// --------------------------------------------------------------------------- +// The sustain loop and its crossfade are source-frame facts that play in wall-clock time. +// --------------------------------------------------------------------------- + +static void testSingleCycleLoopSustainsAtThePitchOfItsCycle() { + for (const RateCase& c : kCases) { + // One cycle of 441 Hz, looped forever: the held pitch must stay 441 Hz at the host rate. + const double cycle = static_cast(c.file) / 441.0; + SampleData s = sineSample(c, 441.0, 0.1); + const auto loopStart = static_cast(std::llround(cycle * 10.0)); + s.loop = SampleLoop{true, loopStart, loopStart + static_cast(std::llround(cycle))}; + s.play.adsr.sustainLevel = 1.0; + const double wantPeriod = static_cast(c.host) / (static_cast(c.file) / + static_cast(s.loop.end - s.loop.start)); + const int frames = c.host; // one second — ten times the source length + const std::vector out = render(s, 60, frames); + CHECK(within(measuredPeriod(out, static_cast(frames) - 5000, 4000, + wantPeriod), + wantPeriod, 0.002)); + } +} + +static void testLoopCrossfadeTakesItsWallClockTime() { + for (const RateCase& c : kCases) { + // Silence ahead of the loop, level inside it: the pre-seam fade blends the loop's tail + // toward the silence one loop length back, so each pass dips for exactly the fade. + const std::int64_t loopStart = c.file / 10; // 100 ms of lead-in + const std::int64_t loopLen = c.file / 5; // 200 ms loop + const std::int64_t fade = c.file / 100; // 10 ms crossfade, in SOURCE frames + SampleData s; + s.sampleRate = c.file; + s.renderRate = c.host; + s.frames.assign(static_cast(loopStart + loopLen), 1.0f); + for (std::int64_t i = 0; i < loopStart; ++i) s.frames[static_cast(i)] = 0.0f; + s.loop = SampleLoop{true, loopStart, loopStart + loopLen}; + s.loopCrossfadeFrames = fade; + s.startFrame = loopStart; + s.play.adsr.sustainLevel = 1.0; + // Exactly one loop pass at the host rate holds exactly one fade. + const auto pass = static_cast(static_cast(c.host) / 5.0); + const std::vector out = render(s, 60, pass); + int dipped = 0; + for (double v : out) if (v < 0.9999) ++dipped; + const double want = static_cast(c.host) / 100.0; // 10 ms at the host rate + CHECK(std::fabs(static_cast(dipped) - want) <= 3.0); + } +} + +// --------------------------------------------------------------------------- +// The filter corner is solved against the rate the voice EMITS at. +// --------------------------------------------------------------------------- + +static void testFilterCornerLandsAtItsHertzAtAMismatchedRate() { + const float cutoffNorm = flt::filterNormFromCutoffHz(1000.0f); + const double toneHz = 2.0 * static_cast(flt::filterCutoffHzFromNorm(cutoffNorm)); + const auto filtered = [&](const RateCase& c) { + SampleData s = sineSample(c, toneHz, 0.5); + s.play.adsr.sustainLevel = 1.0; + s.play.filter.enabled = true; + s.play.filter.settings.cutoffNorm = cutoffNorm; + s.play.filter.settings.resonanceNorm = 0.0f; + s.play.filter.settings.morphNorm = 1.0f; // low-pass + s.play.filter.env.sustainLevel = 1.0; + return render(s, 60, 16000); + }; + for (const RateCase& c : kCases) { + // The reference is the same tone in Hz through the same knob position at a MATCHED rate + // equal to this case's host rate. An octave above the corner a 2-pole low-pass is on its + // 12 dB/oct skirt, so a corner off by the rate ratio moves the level by over a dB. + const RateCase matched{c.host, c.host}; + const std::vector got = filtered(c); + const std::vector ref = filtered(matched); + const double gotRms = rms(got, 4000, 16000); + const double refRms = rms(ref, 4000, 16000); + CHECK(refRms < 0.5 * 0.5 / std::sqrt(2.0)); // the corner actually attenuates + CHECK(std::fabs(20.0 * std::log10(gotRms / refRms)) < 0.2); + const double wantPeriod = static_cast(c.host) / toneHz; + CHECK(within(measuredPeriod(got, 4000, 4000, wantPeriod), wantPeriod, 0.002)); + } +} + +// --------------------------------------------------------------------------- +// Same rate: bit-identical to the engine before the factor existed. +// --------------------------------------------------------------------------- + +// Everything that touches a rate at once: both engines, stereo, the filter under an envelope, +// the pitch envelope, a Trigger AHD, and a crossfaded Gate loop. +static SampleData richSample(PitchEngine engine, PlayMode mode) { + constexpr int kRate = 44100; + SampleData s; + const std::size_t n = 30000; + s.frames.resize(n); + s.framesR.resize(n); + for (std::size_t i = 0; i < n; ++i) { + const double t = static_cast(i) / kRate; + s.frames[i] = static_cast(0.4 * std::sin(2.0 * kPi * 220.0 * t) + + 0.2 * std::sin(2.0 * kPi * 1330.0 * t)); + s.framesR[i] = static_cast(0.4 * std::sin(2.0 * kPi * 330.0 * t)); + } + s.sampleRate = kRate; + s.rootNote = 57; + s.loop = SampleLoop{true, 12000, 24000}; + s.loopCrossfadeFrames = 800; + s.sourcePeriodFrames = kRate / 220.0; + s.play.playMode = mode; + s.play.pitchEngine = engine; + s.play.adsr = AdsrParams{300, 100, 900, 0.7, 2000, 1.5, 0.7, 1.2}; + s.play.trigger.lengthFraction = 0.8; + s.play.trigAhd = AhdParams{400, 3000, 0.5, 1.3, 0.8}; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.peakSemitones = 5.0; + s.play.pitchEnv.shape = AhdParams{200, 1500, 0.3, 1.0, 1.0}; + s.play.filter.enabled = true; + s.play.filter.settings.cutoffNorm = 0.4f; + s.play.filter.settings.resonanceNorm = 0.5f; + s.play.filter.settings.morphNorm = 0.8f; + s.play.filter.modAmount = 0.3; + s.play.filter.keyTrack = 0.5; + s.play.filter.env = AdsrParams{500, 0, 1500, 0.4, 1000, 1.0, 1.0, 1.0}; + s.play.filter.trigEnv = AhdParams{300, 2000, 0.4, 1.0, 1.0}; + return s; +} + +static std::vector renderStereoNote(const SampleData& s, int note) { + Voice v; + startVoice(v, s, note); + std::vector out; + for (int i = 0; i < 40000; ++i) { + if (i == 30000) v.release(); + AudioSample l = 0.0f, r = 0.0f; + v.renderFrameStereo(l, r); + out.push_back(l); + out.push_back(r); + } + return out; +} + +// renderRate 0 is how every other core test builds its SampleData, so those suites' exact-value +// assertions pin that path; the loader always states the host rate. The two must agree bit for +// bit at a matched rate, or a same-rate capture would sound different in the plugin than in the +// suites that vouch for it. +static void testStatingAMatchedHostRateChangesNoBit() { + for (PitchEngine engine : {PitchEngine::Varispeed, PitchEngine::Preserve}) { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + for (int note : {57, 64, 45}) { + const SampleData unstated = richSample(engine, mode); // renderRate 0 + SampleData stated = unstated; + stated.renderRate = stated.sampleRate; + const std::vector a = renderStereoNote(unstated, note); + const std::vector b = renderStereoNote(stated, note); + bool identical = a.size() == b.size(); + for (std::size_t i = 0; identical && i < a.size(); ++i) { + if (a[i] != b[i]) identical = false; + } + CHECK(identical); + } + } + } +} + +int main() { + testVarispeedRootNotePlaysAtItsOwnPitchAndDuration(); + testPreserveRootAndOctaveHoldPitchAndWallClockDuration(); + testTriggerPlayPercentageIsAFractionOfTheWallClockLength(); + testTriggerAhdAttackTakesItsWallClockTimeUnderBothEngines(); + testGateAdsrAttackTakesItsWallClockTime(); + testPitchEnvelopeSpanIsTheSourcesWallClockLength(); + testSingleCycleLoopSustainsAtThePitchOfItsCycle(); + testLoopCrossfadeTakesItsWallClockTime(); + testFilterCornerLandsAtItsHertzAtAMismatchedRate(); + testStatingAMatchedHostRateChangesNoBit(); + if (g_fail == 0) std::printf("source_rate: all tests passed\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_time_stretch.cpp b/tests/test_time_stretch.cpp index b01b831..8a322c7 100644 --- a/tests/test_time_stretch.cpp +++ b/tests/test_time_stretch.cpp @@ -10,6 +10,7 @@ // a variable-length feed loop RT-safe. // 4. the clamp: out-of-range folds to the bounds, unusable input folds to unity (never to a // silent stall or a quarter-speed surprise). +// 4b. the source/host rate ratio multiplies the CLAMPED rate, unclipped by the clamp itself. // 5. the sustain loop wraps the cursor and never lets it leave [start, end) — "loop the // source" holds at every rate, including one that steps over the loop end. @@ -109,6 +110,34 @@ static void testRateClamp() { CHECK(d.due(50.0) <= kMaxFeedPerFrame); } +// --- 4b. The source/host rate ratio scales the feed AFTER the musical clamp. --- +static void testSourceRateRatioScalesTheClampedRate() { + const ResolvedLoop lp = noLoop(); + // 96k source in a 44.1k host: past the musical ceiling on its own, and not clipped by it. + const double ratio = 96000.0 / 44100.0; + for (double rate : {0.5, 1.0, 2.0, 50.0}) { + StretchCursor c; + c.start(0, ratio); + const std::int64_t outFrames = 100000; + std::int64_t worst = 0; + for (std::int64_t i = 0; i < outFrames; ++i) { + const std::int64_t due = c.due(rate); + if (due > worst) worst = due; + for (std::int64_t k = 0; k < due; ++k) (void)c.next(lp); + } + const double expected = static_cast(outFrames) * clampStretchRate(rate) * ratio; + CHECK(std::fabs(static_cast(c.frame()) - expected) <= 1.0); + CHECK(static_cast(worst) <= std::ceil(kStretchRateMax * ratio)); + } + // A ratio of exactly 1.0 is the unity schedule, frame for frame. + StretchCursor u; + u.start(7, 1.0); + for (std::int64_t i = 0; i < 1000; ++i) { + CHECK(u.due(1.0) == 1); + CHECK(u.next(lp) == 7 + i); + } +} + // --- 5. The loop wraps the SOURCE cursor, at every rate. --- static void testCursorStaysInsideTheLoopSpan() { const ResolvedLoop lp = loopSpan(1000, 1040); // a 40-frame loop: rate 4 steps 10% of it @@ -144,6 +173,7 @@ int main() { testTotalConsumedTracksTheRate(); testFeedPerOutputFrameIsBounded(); testRateClamp(); + testSourceRateRatioScalesTheClampedRate(); testCursorStaysInsideTheLoopSpan(); if (g_fail == 0) {