Phase S voices: user-set polyphony, mono stack (retrig|legato), isolated preview card, idle-drain retirement; unity bypass preview-only (v7)
This commit is contained in:
@@ -33,17 +33,12 @@ namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
// Tier-0 fixed instrument shape (Tier 2 makes these editable). A gentle amp envelope so
|
||||
// notes neither click on nor cut off abruptly; sustain at unity (velocity does the
|
||||
// dynamics), a short release for a natural tail. Times are in seconds, converted to
|
||||
// frames against the live sample rate at build time.
|
||||
constexpr std::size_t kMaxVoices = 16;
|
||||
|
||||
// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
||||
// materially heavier than a Varispeed voice. Below the Varispeed polyphony bound so a chord of
|
||||
// Preserve notes stays within the RT budget; a Preserve note-on past the cap is dropped rather
|
||||
// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather
|
||||
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
|
||||
// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling.
|
||||
// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the
|
||||
// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays
|
||||
// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget.
|
||||
constexpr std::size_t kPreserveVoiceCap = 8;
|
||||
|
||||
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
||||
@@ -219,6 +214,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
std::lock_guard<std::mutex> lock(previewMutex_);
|
||||
previewVelocity_ = cs.previewVelocity;
|
||||
}
|
||||
// Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly,
|
||||
// Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the
|
||||
// reload below so the rebuilt engine is born with the saved polyphony/mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
voiceCount_ = cs.voiceCount;
|
||||
voiceMode_ = cs.voiceMode;
|
||||
monoTrigger_ = cs.monoTrigger;
|
||||
}
|
||||
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||
reloadFromBank();
|
||||
return kResultOk;
|
||||
@@ -240,6 +244,13 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
|
||||
}
|
||||
state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity
|
||||
{
|
||||
// Phase S: persist the voice-system parameters (component state v7).
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
state_out.voiceCount = voiceCount_;
|
||||
state_out.voiceMode = voiceMode_;
|
||||
state_out.monoTrigger = monoTrigger_;
|
||||
}
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
|
||||
if (!bytes.empty()) {
|
||||
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||
@@ -288,6 +299,55 @@ void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) {
|
||||
previewVelocity_ = velocity;
|
||||
}
|
||||
|
||||
int ReaSamplerProcessor::voiceCount() {
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
return voiceCount_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setVoiceCount(int count) {
|
||||
// Clamp to the shared pure-core range so the engine, the state bytes, and the editor's
|
||||
// control can never disagree about the legal polyphony span.
|
||||
if (count < kMinVoiceCount) count = kMinVoiceCount;
|
||||
if (count > kMaxVoiceCount) count = kMaxVoiceCount;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
if (voiceCount_ == count) return; // no-op: don't churn a rebuild
|
||||
voiceCount_ = count;
|
||||
}
|
||||
// Rebuild the engine OFF-thread through the drain-slot swap (the FA1 machinery): the
|
||||
// displaced instrument keeps rendering its ringing tails, so a polyphony change never
|
||||
// cuts a sounding note. Same contract for the mode/trigger setters below.
|
||||
reloadFromBank();
|
||||
}
|
||||
|
||||
VoiceMode ReaSamplerProcessor::voiceMode() {
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
return voiceMode_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
if (voiceMode_ == mode) return;
|
||||
voiceMode_ = mode;
|
||||
}
|
||||
reloadFromBank();
|
||||
}
|
||||
|
||||
MonoTrigger ReaSamplerProcessor::monoTrigger() {
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
return monoTrigger_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
if (monoTrigger_ == trigger) return;
|
||||
monoTrigger_ = trigger;
|
||||
}
|
||||
reloadFromBank();
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||
if (note < 0) note = 0;
|
||||
if (note > 127) note = 127;
|
||||
@@ -375,6 +435,17 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
|
||||
// Read once under its mutex, off the audio thread, before the decode loop.
|
||||
const ChannelMode mode = channelMode();
|
||||
// Phase S: snapshot the voice-system parameters once — they are baked into the built
|
||||
// engine's construction (the engine's config is immutable; a later change rebuilds).
|
||||
int builtVoiceCount = kDefaultVoiceCount;
|
||||
VoiceMode builtVoiceMode = VoiceMode::Poly;
|
||||
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
|
||||
{
|
||||
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
|
||||
builtVoiceCount = voiceCount_;
|
||||
builtVoiceMode = voiceMode_;
|
||||
builtMonoTrigger = monoTrigger_;
|
||||
}
|
||||
|
||||
std::string resolvedId;
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
@@ -440,8 +511,8 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), kMaxVoices, gen, kPreserveVoiceCap,
|
||||
preserveWindow);
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +544,33 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
return resolvedId;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::retireIdleDrain() {
|
||||
// Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or
|
||||
// it still sounds" — the common case costs one relaxed load and no mutex.
|
||||
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
|
||||
if (idleGen == 0) return;
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
|
||||
// Retire ONLY if the publication names the drain currently in the slot. A stale value
|
||||
// (about an already-evicted, older drain) can never match the newer occupant's
|
||||
// installedAt — the slot is monotone in generation — so a mid-swap race is closed by
|
||||
// this identity check, not by timing.
|
||||
if (!drain || drain->installedAt != idleGen) return;
|
||||
draining_.store(nullptr, std::memory_order_release);
|
||||
graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
|
||||
// Prune what is now provably unreachable — the same monotone-generation proof as the
|
||||
// reload path's reclaim (see reloadFromBank): an entry with installedAt < seen cannot be
|
||||
// held by process() now or ever again. The just-parked drain frees here immediately when
|
||||
// process() has already published past it; otherwise on the next reload/retire/deactivate.
|
||||
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||
graveyard_.erase(
|
||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
||||
[seen](const std::unique_ptr<LoadedInstrument>& e) {
|
||||
return e->installedAt < seen;
|
||||
}),
|
||||
graveyard_.end());
|
||||
}
|
||||
|
||||
ReaSamplerProcessor::BankSyncResult
|
||||
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call
|
||||
@@ -480,6 +578,11 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
|
||||
BankSyncResult result;
|
||||
|
||||
// Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer
|
||||
// cadence that drives reloads — an edited-away instrument stops costing memory as soon
|
||||
// as its tails die instead of squatting in the drain slot until the next reload.
|
||||
retireIdleDrain();
|
||||
|
||||
// --- S8: assignment-request consume FIRST -------------------------------------
|
||||
// Decode the pending assignment request (nullopt when absent/malformed). Resolve its
|
||||
// (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
|
||||
@@ -587,6 +690,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
}
|
||||
processGeneration_.store(heldGen, std::memory_order_release);
|
||||
|
||||
// Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine
|
||||
// voice AND its preview card silent) by naming its OWN installedAt (0 = no drain / still
|
||||
// sounding). Evaluated at block START — idleness is monotone for a drain (it receives no
|
||||
// note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply
|
||||
// publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe.
|
||||
drainIdleGeneration_.store(
|
||||
(drain && drain->fullyIdle()) ? drain->installedAt : 0,
|
||||
std::memory_order_relaxed);
|
||||
|
||||
// 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.
|
||||
@@ -616,8 +728,10 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
// S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed
|
||||
// atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last
|
||||
// one we consumed; fire it once, then latch the sequence so the same request never re-fires.
|
||||
// Preview note-on/off drive the SAME voice engine as host MIDI (a preview is just a note with
|
||||
// no MIDI wire) — off-thread posted, audio-thread consumed, no lock, no allocation.
|
||||
// Phase S: preview note-on/off drive the dedicated PREVIEW CARD — a single voice structurally
|
||||
// OUTSIDE the MIDI pool, so a full pool can never drop a preview and a preview can never
|
||||
// steal a playing MIDI voice (the FA1-review isolation fix). Host MIDI routes ONLY to the
|
||||
// engine (above); the card is summed alongside it in the render below.
|
||||
// Consume (advance the sequence) even when inst is null so a note-on posted while no instrument
|
||||
// is loaded does not re-fire stale on the next instrument load.
|
||||
{
|
||||
@@ -628,7 +742,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
if (inst) {
|
||||
const int vel = static_cast<int>((on >> 8) & 0xFF);
|
||||
const int note = static_cast<int>(on & 0xFF);
|
||||
if (vel > 0) inst->engine.noteOn(note, vel);
|
||||
if (vel > 0) inst->preview.noteOn(note, vel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,11 +751,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
|
||||
if (offSeq != 0 && offSeq != previewOffConsumed_) {
|
||||
previewOffConsumed_ = offSeq;
|
||||
// Route the preview note-off to BOTH engines (mirror of the host note-off): a
|
||||
// Route the preview note-off to BOTH cards (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));
|
||||
// release the old-snapshot card now draining, not just the (fresh) live one.
|
||||
if (inst) inst->preview.noteOff(static_cast<int>(off & 0xFF));
|
||||
if (drain) drain->preview.noteOff(static_cast<int>(off & 0xFF));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,9 +795,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
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));
|
||||
inst->preview.render(ch0, ch1, static_cast<std::size_t>(frames));
|
||||
}
|
||||
if (drain) {
|
||||
drain->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
|
||||
drain->preview.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) {
|
||||
@@ -706,9 +822,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
|
||||
if (inst) {
|
||||
inst->engine.render(ch0, static_cast<std::size_t>(frames));
|
||||
inst->preview.render(ch0, static_cast<std::size_t>(frames));
|
||||
}
|
||||
if (drain) {
|
||||
drain->engine.render(ch0, static_cast<std::size_t>(frames));
|
||||
drain->preview.render(ch0, static_cast<std::size_t>(frames));
|
||||
}
|
||||
float peak = 0.f;
|
||||
for (int32 i = 0; i < frames; ++i) {
|
||||
|
||||
Reference in New Issue
Block a user