From d9321eaf3ac731eaee8639009f8ad51b0bff5a9d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 18:25:01 -0400 Subject: [PATCH] FA1: unity-Preserve bypass kills preview onset latency; drain-slot reload keeps voices ringing through curve edits; velocity path proven end-to-end --- src/vst/reasampler_processor.cpp | 117 ++++++++++++++++++++++--------- src/vst/reasampler_processor.h | 47 +++++++------ src/vst/sampler_core.cpp | 13 ++++ src/vst/sampler_core.h | 5 +- tests/test_sample_map.cpp | 45 ++++++++++++ tests/test_sampler_core.cpp | 109 +++++++++++++++++++++++++++- 6 files changed, 275 insertions(+), 61 deletions(-) diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index d5801bf..e147081 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -130,10 +130,12 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { } tresult PLUGIN_API ReaSamplerProcessor::terminate() { - // process() is not running at terminate. Free the live instrument and drain the - // graveyard. Take the pointer out of the atomic first so nothing else races it. + // process() is not running at terminate. Free the live + draining instruments and + // drain the graveyard. Take the pointers out of the atomics first so nothing else + // races them. std::lock_guard lock(reloadMutex_); delete live_.exchange(nullptr); + delete draining_.exchange(nullptr); graveyard_.clear(); return SingleComponentEffect::terminate(); } @@ -148,6 +150,13 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { reloadFromBank(); } else { std::lock_guard lock(reloadMutex_); + // process is guaranteed stopped: free EVERYTHING. The live instrument too — its + // voices are frozen mid-flight, and if it survived deactivation the reactivate + // reload would displace it into the DRAIN slot, resurrecting stale sustained + // voices as ghosts. Reactivation rebuilds from scratch (reloadFromBank above), + // so nothing is lost by clearing here. + delete live_.exchange(nullptr); + delete draining_.exchange(nullptr); graveyard_.clear(); } return kResultOk; @@ -436,27 +445,31 @@ std::string ReaSamplerProcessor::reloadFromBank() { } } - // 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the - // graveyard tagged with this generation (process may still be mid-block reading - // it). A null `built` (no bank / unreadable WAV) installs silence. - // `built` is heap-owned; release() hands ownership to the atomic, and the - // exchanged pointer is re-owned by the graveyard. + // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the + // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — + // a reload never cuts a sounding note; the next note-on plays the new state. The + // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard + // (process may still be mid-block reading it). A null `built` (no bank / unreadable + // WAV) installs silence while the displaced tails still ring out via the drain. + // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted + // pointer is re-owned by the graveyard. // - // Bounded reclaim: prune graveyard entries where displacedAt <= seen, where seen - // is the last generation process() published. process() publishes inst->installedAt - // (not a re-read of reloadGeneration_), so seen == D means process holds the - // instrument installed at gen D. An entry with displacedAt == D was displaced by - // reload D, which installed that very successor — process cannot be holding the - // displaced entry. The pruning condition is therefore <= (see header for the full - // proof). Remaining entries drain at setActive(false) / terminate() when process - // is guaranteed stopped. + // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is + // the minimum installedAt process() published over the pointers it holds. Both + // slots are monotone in installedAt, so seen is monotone and any future process() + // load yields installedAt >= seen — an entry below seen is provably unreachable + // (see the header proof). Remaining entries drain at setActive(false) / terminate() + // when process is guaranteed stopped. const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); graveyard_.erase( std::remove_if(graveyard_.begin(), graveyard_.end(), - [seen](const GraveyardEntry& e) { return e.displacedAt <= seen; }), + [seen](const std::unique_ptr& e) { + return e->installedAt < seen; + }), graveyard_.end()); LoadedInstrument* prev = live_.exchange(built.release()); - if (prev) graveyard_.push_back({gen, std::unique_ptr(prev)}); + LoadedInstrument* evicted = draining_.exchange(prev); + if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); return resolvedId; } @@ -543,24 +556,43 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { } tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { - // REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the - // whole block (a single atomic acquire), then publish inst->installedAt so the off- - // thread graveyard pruner knows exactly which generation this block is holding. + // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments + // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt + // over the pointers held so the off-thread graveyard pruner knows exactly which + // generations this block is holding (see the header proof). // // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an - // ordering race: reading reloadGeneration_ after live_ could observe a generation - // newer than the pointer we actually hold, causing the pruner to free an instrument + // ordering race: reading reloadGeneration_ after the slots could observe a generation + // newer than the pointers we actually hold, causing the pruner to free an instrument // process is still reading. installedAt was set on the reload path before the atomic - // exchange that made the instrument visible, so it is always <= the generation of any - // instrument that could have been loaded after our acquire above. + // exchange that made the instrument visible. + // + // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the + // last reload: its already-sounding voices keep rendering (and receive note-offs) so a + // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. + // A racing reload can briefly leave the same pointer in both slots (live_ was loaded + // before the swap, draining_ after); collapse that to live-only so one engine is never + // advanced twice per frame. LoadedInstrument* inst = live_.load(std::memory_order_acquire); - const std::uint64_t heldGen = inst ? inst->installedAt : 0; + LoadedInstrument* drain = draining_.load(std::memory_order_acquire); + if (drain == inst) drain = nullptr; + std::uint64_t heldGen = 0; + if (inst && drain) { + heldGen = inst->installedAt < drain->installedAt ? inst->installedAt + : drain->installedAt; + } else if (inst) { + heldGen = inst->installedAt; + } else if (drain) { + heldGen = drain->installedAt; + } processGeneration_.store(heldGen, std::memory_order_release); // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps // events at block granularity (no per-event sample-offset split) — audible timing is // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. - if (inst && data.inputEvents) { + // Note-offs also route to the DRAIN engine so a note held across a reload releases + // its old-snapshot voice too (otherwise it would sustain until the next reload). + if (data.inputEvents) { const int32 count = data.inputEvents->getEventCount(); for (int32 i = 0; i < count; ++i) { Event e; @@ -569,12 +601,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // A note-on with velocity 0 is a note-off by MIDI convention. const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); if (vel <= 0) { - inst->engine.noteOff(e.noteOn.pitch); - } else { + if (inst) inst->engine.noteOff(e.noteOn.pitch); + if (drain) drain->engine.noteOff(e.noteOn.pitch); + } else if (inst) { inst->engine.noteOn(e.noteOn.pitch, vel); } } else if (e.type == Event::kNoteOffEvent) { - inst->engine.noteOff(e.noteOff.pitch); + if (inst) inst->engine.noteOff(e.noteOff.pitch); + if (drain) drain->engine.noteOff(e.noteOff.pitch); } } } @@ -598,12 +632,16 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } } - if (inst) { + if (inst || drain) { const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); const std::uint16_t offSeq = static_cast(off >> 16); if (offSeq != 0 && offSeq != previewOffConsumed_) { previewOffConsumed_ = offSeq; - inst->engine.noteOff(static_cast(off & 0xFF)); + // Route the preview note-off to BOTH engines (mirror of the host note-off): a + // preview held across a reload — e.g. a curve edit committed mid-press — must + // release the old-snapshot voice now draining, not just the (empty) live engine. + if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); + if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); } } @@ -639,10 +677,14 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { if (ch0 && ch1) { // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo // path (both channels equal), so a mono capture in stereo mode is centered, not silent. + // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } if (inst) { inst->engine.render(ch0, ch1, static_cast(frames)); } + if (drain) { + drain->engine.render(ch0, ch1, static_cast(frames)); + } // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). for (int32 ch = 2; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { @@ -665,6 +707,9 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { if (inst) { inst->engine.render(ch0, static_cast(frames)); } + if (drain) { + drain->engine.render(ch0, static_cast(frames)); + } float peak = 0.f; for (int32 i = 0; i < frames; ++i) { const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; @@ -679,10 +724,12 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } // Report silence only when nothing is loaded (lets the host optimize when idle). - // With an instrument loaded we clear the flag so a ringing voice is not skipped. - out.silenceFlags = inst ? 0 : ((out.numChannels >= 64) - ? ~0ULL - : ((1ULL << out.numChannels) - 1)); + // With an instrument loaded — or a drain snapshot still ringing out — we clear the + // flag so a ringing voice is not skipped. + out.silenceFlags = (inst || drain) ? 0 + : ((out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1)); return kResultOk; } diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 3df4d13..13fc758 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -204,39 +204,42 @@ private: ReaperBridge bridge_; - // --- The audio-thread handoff (S4 real-time discipline) ----------------- - // process() atomically loads `live_` at block start and marshals/renders against it — - // a single atomic acquire, no lock, no free on the audio thread. + // --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) -- + // process() atomically loads `live_` AND `draining_` at block start and marshals/renders + // against them — two atomic acquires, no lock, no free on the audio thread. // // reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new // LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is - // NOT freed on the reload path: process() may still be mid-block reading it, and two - // rapid reloads could otherwise free a pointer process is using. Instead it is parked - // in `graveyard_` tagged with the reload generation at which it was displaced. + // NOT freed and NOT silenced: it moves into `draining_`, where process() keeps + // rendering its already-sounding voices (and routes note-offs to it) so a reload — + // a curve/param edit, a bank-generation refresh, an applied assignment — never cuts a + // ringing note (FA1, bug 3b). New note-ons go ONLY to the live instrument, so the next + // trigger plays the new state. The instrument evicted FROM the drain slot (two reloads + // old) is parked in `graveyard_` for reclaim — a rapid second reload hard-cuts only the + // oldest edit's tails (bounded compromise, documented). // - // Bounded reclaim: process() publishes inst->installedAt (the generation at which the - // held instrument was installed) via processGeneration_ — a single atomic store, RT- - // safe. The reload path prunes graveyard entries where displacedAt <= seen (where seen - // is the last published processGeneration_). + // Bounded reclaim: process() publishes the MINIMUM installedAt over the (non-null) + // pointers it holds this block via processGeneration_ — a single atomic store, RT-safe. + // The reload path frees graveyard entries whose installedAt < seen (the last published + // value). // - // Safety argument: an entry with displacedAt == D was displaced by reload D, which - // simultaneously installed its successor with installedAt == D. process() publishing - // seen == D means it holds that successor (or a later one). In either case, the - // displaced entry is not the pointer process is using, so freeing it is safe. The - // pruning condition is therefore <= (not strict <): an entry displaced at exactly the - // published generation is also provably unreachable. + // Safety argument: both slots are monotone in installedAt over time (live_ receives + // successively newer builds; draining_ receives successively newer displaced lives), so + // the published minimum is monotone across blocks, and any future process() load yields + // installedAt >= seen. An entry only reaches the graveyard by leaving BOTH slots + // (single-writer under reloadMutex_), so a graveyard entry with installedAt < seen can + // never again be loaded and is not currently held — freeing it is safe. process() + // publishes BEFORE rendering, so the pointers it renders with are covered by the value + // the pruner reads (a stale lower read is merely conservative). // // The graveyard's upper bound is the number of reloads since process last ran // (typically 0–1 in normal use). Remaining entries drain at setActive(false) / // terminate(), when the host guarantees process is stopped. std::atomic live_{nullptr}; + std::atomic draining_{nullptr}; // displaced instrument still rendering its tails std::atomic reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process) - std::atomic processGeneration_{0}; // generation last seen by process (written on audio thread, read off-thread) - struct GraveyardEntry { - std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced - std::unique_ptr instrument; - }; - std::vector graveyard_; // drained on reclaim + setActive(false) + terminate + std::atomic processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread) + std::vector> graveyard_; // drained on reclaim + setActive(false) + terminate std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access // The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence). diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 22222c3..8031090 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -280,6 +280,19 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote playMode_ = p.playMode; pitchEngine_ = p.pitchEngine; + // FA1 (preview latency): a UNITY-SHIFT Preserve voice — note at the effective root + // (baseRatio_ == 1.0, exact per keyTrackedRatio) with the pitch envelope off — is demoted to + // the Varispeed read path for this voice. At ratio 1.0 the two engines are byte-identical + // EXCEPT the OLA shifter's structural onset cost: a half-window (~25 ms at the 50 ms product + // window) delay plus a Hann fade-in, and a full-window warm() silence pass on the audio + // thread at note-on. None of that buys anything at unity (there is no shift to preserve + // duration against), so the demoted voice reads the source directly and speaks on frame one. + // The preview trigger fires at the root, so this is the preview's zero-added-latency path; + // transposed Preserve notes keep the shifter (its latency is inherent to OLA). + if (pitchEngine_ == PitchEngine::Preserve && baseRatio_ == 1.0 && !p.pitchEnv.enabled) { + pitchEngine_ = PitchEngine::Varispeed; + } + // Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp // into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than // starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0. diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index adee55c..e4df3ac 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -397,7 +397,10 @@ public: void setStartOrder(std::uint64_t order) { startOrder_ = order; } bool releasing() const { return releasing_; } // The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only - // meaningful while active(). + // meaningful while active(). NOTE (FA1): a Preserve ZONE voice started at unity shift + // (note == effective root, pitch env off) is demoted to Varispeed at start() — it runs no + // shifter, speaks with zero onset delay, and deliberately does not count toward the + // Preserve cap (it costs Varispeed CPU, not shifter CPU). PitchEngine pitchEngine() const { return pitchEngine_; } // Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index a25d818..5a73102 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1419,6 +1419,50 @@ static void testVelocityCurveResolvesToZone() { CHECK(r.zones[0].velocityCurve.equals(vst::VelocityCurve::linear())); } +// FA1 bug 3a — the COMPOSED end-to-end regression, mirroring the processor's reload composition +// exactly: an authored curve survives the component-state round-trip (the save/load seam), then +// resolvePerformance -> buildZonedKeymap -> VoiceEngine (constructed with a Preserve window, the +// DAW configuration) -> render, and the rendered level tracks velocity through the curve. This +// is the full pure slice of the click-to-sound path; only the bridge read + WAV decode (shell +// I/O) are outside it. A y=x curve at velocity 1 must be near-silent — NOT max volume. +static void testVelocityCurveEndToEndThroughReloadComposition() { + // 1. The instrument's own state: one full-keyboard zone with a LINEAR curve (the exact edit + // Daniel made), round-tripped through the v7 component-state wire (save -> load). + ComponentState s; + s.selectionId = "a"; + PerformanceZone z = zone("a", 0, 127); + z.velocityCurve = vst::VelocityCurve::linear(); + s.map.zones.push_back(z); + const ComponentState back = deserializeComponentState(serializeComponentState(s), 48000.0); + CHECK(back.map.zones.size() == 1); + if (back.map.zones.size() != 1) return; + + // 2. Resolve against a live bank blob (the shared bank_book parse, root 60 intrinsic). + const std::string json = bookJson({makeSample("a", "Kick", "reasampler_bank/a.wav", 60)}, {}); + const ResolvedPerformance rp = resolvePerformance(json, back.map); + CHECK(rp.zones.size() == 1); + if (rp.zones.size() != 1) return; + // The round-tripped zone still runs the PRESERVE product default (the DAW engine config). + CHECK(rp.zones[0].play.pitchEngine == PitchEngine::Preserve); + + // 3. Build the zoned keymap from decoded DC-1 PCM and play it through an engine constructed + // the way reloadFromBank constructs it (Preserve voices pre-sized to a real window). + auto steadyLevelAt = [&](int vel) -> double { + DecodedZonePcm pcm; + pcm.monoFrames.assign(4000, 1.0f); + pcm.sampleRate = 48000; + const Keymap km = buildZonedKeymap(rp.zones, {pcm}); + VoiceEngine eng(16, km, /*preserveCap=*/8, /*window=*/256); + eng.noteOn(62, vel); // transposed: the genuine OLA shifter path + std::vector out; + eng.render(out, 1000); + return static_cast(out[900]); // steady state (ring fully DC past the window) + }; + CHECK(approx(steadyLevelAt(127), 1.0)); + CHECK(approx(steadyLevelAt(64), 64.0 / 127.0)); + CHECK(steadyLevelAt(1) < 0.02); // velocity 1 through y=x: near-silent, never max volume +} + static void testVelocityCurveV6BackCompatLiftsToFlat() { // A v6 PAYLOAD blob (marker + version 6 + full play tail + keyTrack, but NO velocity-curve field) // lifts every zone to VelocityCurve::flat() (R10-F1 Option A — flat y=1). This is the DELIBERATE @@ -1822,6 +1866,7 @@ int main() { testVelocityCurveRoundTrip(); testVelocityCurveThroughComponentEnvelope(); testVelocityCurveResolvesToZone(); + testVelocityCurveEndToEndThroughReloadComposition(); testVelocityCurveV6BackCompatLiftsToFlat(); testPlayParamsV2BackCompatLiftsToDefaults(); testPlayParamsThroughComponentEnvelope(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 341aaf0..4ea02bb 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -19,6 +19,7 @@ #include "../src/vst/sampler_core.h" +#include #include #include #include @@ -1291,18 +1292,113 @@ static void testPreserveGateStereoLoopComposes() { } // --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. --- +// Uses TRANSPOSED notes only: a note at the root demotes to the Varispeed path (FA1 unity +// bypass) and deliberately does not count toward the cap — see the demotion test below. static void testPreserveVoiceCap() { SampleData s = dcSample(2000, 60); s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) Keymap km = Keymap::singleSampleChromatic(std::move(s)); // 8 voices total, Preserve cap of 2. VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256); - CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice - CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) - CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap + CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice + CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap) + CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap CHECK(eng.activeVoiceCount() == 2); } +// --------------------------------------------------------------------------- +// FA1 — Preserve unity bypass (preview latency) + velocity under the Preserve engine. +// --------------------------------------------------------------------------- + +// A Preserve voice started at UNITY shift (note == effective root, pitch env off) must speak on +// frame ONE — the FA1 latency fix. Pre-fix, the note ran through the OLA shifter, whose warm()d +// ring delays onset by a half window (~25 ms at the product 50 ms window): frame 0 was silence. +// The demoted voice reads the source directly (bit-identical to Varispeed at ratio 1.0). +static void testPreserveUnityVoiceSpeaksImmediately() { + SampleData s = dcSample(2000, 60); + s.play.pitchEngine = PitchEngine::Preserve; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512); + eng.noteOn(60, 127); // at root: unity shift -> demoted, zero onset delay + std::vector out; + eng.render(out, 4); + CHECK(approx(out[0], 1.0, 1e-6)); // the DC sample, on the very first frame +} + +// keyTrack 0 collapses EVERY note to unity — an off-root note also demotes and speaks at once. +static void testPreserveKeyTrackZeroAlsoSpeaksImmediately() { + SampleData s = dcSample(2000, 60); + s.play.pitchEngine = PitchEngine::Preserve; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + km.zones[0].keyTrack = 0.0; // no tracking: all keys play root pitch (unity) + VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512); + eng.noteOn(67, 127); + std::vector out; + eng.render(out, 4); + CHECK(approx(out[0], 1.0, 1e-6)); +} + +// A TRANSPOSED Preserve note keeps the genuine OLA path: onset is shifter-delayed (the inherent +// half-window cost of preserving duration) and the voice reaches full level once the ring fills. +// Also proves the demotion is unity-ONLY — the shifter still transposes off-root notes. +static void testPreserveTransposedVoiceKeepsOlaPath() { + SampleData s = dcSample(4000, 60); + s.play.pitchEngine = PitchEngine::Preserve; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512); + eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted + std::vector out; + eng.render(out, 1500); + // Early frames are the shifter's fill (near-silent) — the structural OLA onset. + double early = 0.0; + for (std::size_t i = 0; i < 8; ++i) { + early = (std::max)(early, static_cast(std::fabs(out[i]))); + } + CHECK(early < 0.1); + // Once the ring is full of the DC source (>= window frames in), output reaches the sample + // level (Hann taps partition unity, so DC passes at gain 1). + double late = 0.0; + for (std::size_t i = 600; i < 1500; ++i) { + late = (std::max)(late, static_cast(std::fabs(out[i]))); + } + CHECK(late > 0.9); +} + +// A unity-demoted voice does NOT count toward the Preserve cap (it runs no shifter — it costs +// Varispeed CPU, not OLA CPU), so root-note notes never starve transposed Preserve polyphony. +static void testPreserveUnityVoiceDoesNotConsumeCap() { + SampleData s = dcSample(2000, 60); + s.play.pitchEngine = PitchEngine::Preserve; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256); + CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // unity -> demoted, cap untouched + CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st genuine Preserve voice + CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap) + CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd genuine Preserve DROPPED + CHECK(eng.activeVoiceCount() == 3); // demoted + two Preserve +} + +// FA1 bug 3a regression, in the DAW's ACTUAL configuration: the velocity curve must drive the +// gain under the PRESERVE product-default engine with a CONFIGURED shifter window (every prior +// velocity test ran the bare Varispeed core). A linear y=x curve at velocity 1 must be +// near-silent — NOT max volume. +static void testVelocityCurveAppliesUnderPreserve() { + auto steadyLevelAt = [&](int vel) -> double { + SampleData s = dcSample(4000, 60); + s.play.pitchEngine = PitchEngine::Preserve; + Keymap km = Keymap::singleSampleChromatic(std::move(s)); + km.zones[0].velocityCurve = vst::VelocityCurve::linear(); + VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256); + eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion) + std::vector out; + eng.render(out, 1000); + return static_cast(out[900]); // steady state: ring is fully DC by frame 256 + }; + CHECK(approx(steadyLevelAt(127), 1.0, 0.02)); + CHECK(approx(steadyLevelAt(64), 64.0 / 127.0, 0.02)); + CHECK(steadyLevelAt(1) < 0.02); // y=x at velocity 1: near-silent, the Daniel repro case +} + // --- Per-zone A/D/S/R actually reaches the voice envelope (S12). --- // // Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at @@ -1409,6 +1505,13 @@ int main() { testPreserveGateStereoLoopComposes(); testPreserveVoiceCap(); + // FA1 — Preserve unity bypass (preview latency) + velocity under Preserve. + testPreserveUnityVoiceSpeaksImmediately(); + testPreserveKeyTrackZeroAlsoSpeaksImmediately(); + testPreserveTransposedVoiceKeepsOlaPath(); + testPreserveUnityVoiceDoesNotConsumeCap(); + testVelocityCurveAppliesUnderPreserve(); + // S12 review fix — per-zone A/D/S/R reaches the voice envelope. testPerZoneAdsrReachesVoiceEnvelope(); testZeroAdsrIsInstantSustain();