FA1: unity-Preserve bypass kills preview onset latency; drain-slot reload keeps voices ringing through curve edits; velocity path proven end-to-end
This commit is contained in:
@@ -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<std::mutex> 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<std::mutex> 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<LoadedInstrument>& e) {
|
||||
return e->installedAt < seen;
|
||||
}),
|
||||
graveyard_.end());
|
||||
LoadedInstrument* prev = live_.exchange(built.release());
|
||||
if (prev) graveyard_.push_back({gen, std::unique_ptr<LoadedInstrument>(prev)});
|
||||
LoadedInstrument* evicted = draining_.exchange(prev);
|
||||
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(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<int>(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<std::uint16_t>(off >> 16);
|
||||
if (offSeq != 0 && offSeq != previewOffConsumed_) {
|
||||
previewOffConsumed_ = offSeq;
|
||||
inst->engine.noteOff(static_cast<int>(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<int>(off & 0xFF));
|
||||
if (drain) drain->engine.noteOff(static_cast<int>(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<std::size_t>(frames));
|
||||
}
|
||||
if (drain) {
|
||||
drain->engine.render(ch0, ch1, static_cast<std::size_t>(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<std::size_t>(frames));
|
||||
}
|
||||
if (drain) {
|
||||
drain->engine.render(ch0, static_cast<std::size_t>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LoadedInstrument*> live_{nullptr};
|
||||
std::atomic<LoadedInstrument*> draining_{nullptr}; // displaced instrument still rendering its tails
|
||||
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
|
||||
std::atomic<std::uint64_t> 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<LoadedInstrument> instrument;
|
||||
};
|
||||
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||
std::atomic<std::uint64_t> processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread)
|
||||
std::vector<std::unique_ptr<LoadedInstrument>> 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AudioSample> out;
|
||||
eng.render(out, 1000);
|
||||
return static_cast<double>(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();
|
||||
|
||||
+106
-3
@@ -19,6 +19,7 @@
|
||||
|
||||
#include "../src/vst/sampler_core.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
@@ -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<AudioSample> 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<AudioSample> 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<AudioSample> 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<double>(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<double>(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<AudioSample> out;
|
||||
eng.render(out, 1000);
|
||||
return static_cast<double>(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();
|
||||
|
||||
Reference in New Issue
Block a user