Merge pS-voice: voice-system redesign — user-set polyphony, mono (retrig|legato), isolated preview card, two-tier panic (v7); incorporates FA1 realtime fixes
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstdio> // snprintf (Phase S voice-count readout)
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -63,6 +64,7 @@ constexpr UINT kSyncTimerIntervalMs = 500;
|
|||||||
constexpr int kTitleHeight = 26;
|
constexpr int kTitleHeight = 26;
|
||||||
constexpr int kHeroWaveformHeight = 150; // the enlarged Sample-face hero (was a 72px strip)
|
constexpr int kHeroWaveformHeight = 150; // the enlarged Sample-face hero (was a 72px strip)
|
||||||
constexpr int kClusterHeight = 52; // root strip + preview + channel toggle
|
constexpr int kClusterHeight = 52; // root strip + preview + channel toggle
|
||||||
|
constexpr int kVoiceDeckHeight = 26; // Phase S provisional voice deck band
|
||||||
constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip)
|
constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip)
|
||||||
constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
|
constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
|
||||||
|
|
||||||
@@ -161,6 +163,9 @@ void ReaSamplerEditor::refreshFromBank() {
|
|||||||
selectedId_ = processor_->selectedSampleId();
|
selectedId_ = processor_->selectedSampleId();
|
||||||
map_ = processor_->performanceMap();
|
map_ = processor_->performanceMap();
|
||||||
channelMode_ = processor_->channelMode();
|
channelMode_ = processor_->channelMode();
|
||||||
|
voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot
|
||||||
|
voiceMode_ = processor_->voiceMode();
|
||||||
|
monoTrigger_ = processor_->monoTrigger();
|
||||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||||
// Drop a filter that names a bank no longer present.
|
// Drop a filter that names a bank no longer present.
|
||||||
if (!activeFilterBankId_.empty()) {
|
if (!activeFilterBankId_.empty()) {
|
||||||
@@ -728,6 +733,7 @@ struct SampleBands {
|
|||||||
Rect hero; // the hero waveform + S-VIEW-3 envelope overlay
|
Rect hero; // the hero waveform + S-VIEW-3 envelope overlay
|
||||||
Rect velCurve; // the S-VIEW-10 velocity-curve editor box (empty when suppressed)
|
Rect velCurve; // the S-VIEW-10 velocity-curve editor box (empty when suppressed)
|
||||||
Rect cluster; // root strip + preview-trigger + velocity knob + channel toggle
|
Rect cluster; // root strip + preview-trigger + velocity knob + channel toggle
|
||||||
|
Rect voice; // Phase S voice deck (PROVISIONAL): voice count + Poly/Mono + Retrig/Legato
|
||||||
Rect control; // the param control strip (Mode / Pitch / AHDSR|Trigger / AD pitch / keyTrack)
|
Rect control; // the param control strip (Mode / Pitch / AHDSR|Trigger / AD pitch / keyTrack)
|
||||||
};
|
};
|
||||||
SampleBands computeSampleBands(int w, int h) {
|
SampleBands computeSampleBands(int w, int h) {
|
||||||
@@ -759,6 +765,12 @@ SampleBands computeSampleBands(int w, int h) {
|
|||||||
const int clusterH = (std::min)(kClusterHeight, (std::max)(0, h - y));
|
const int clusterH = (std::min)(kClusterHeight, (std::max)(0, h - y));
|
||||||
b.cluster = Rect{0, y, w, y + clusterH};
|
b.cluster = Rect{0, y, w, y + clusterH};
|
||||||
y += clusterH;
|
y += clusterH;
|
||||||
|
// Phase S voice deck: a narrow PROVISIONAL band between the cluster and the param strip
|
||||||
|
// (voice count stepper + Poly/Mono + Retrig/Legato). The Wave B recompose owns the final
|
||||||
|
// placement; carving a distinct band keeps the wiring self-contained and easy to relocate.
|
||||||
|
const int voiceH = (std::min)(kVoiceDeckHeight, (std::max)(0, h - y));
|
||||||
|
b.voice = Rect{0, y, w, y + voiceH};
|
||||||
|
y += voiceH;
|
||||||
b.control = Rect{kPad, y, w - kPad, h};
|
b.control = Rect{kPad, y, w - kPad, h};
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
@@ -868,6 +880,43 @@ ChannelToggleRects channelToggleRects(const Rect& area) {
|
|||||||
return {mono, stereo};
|
return {mono, stereo};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase S voice deck (PROVISIONAL — the Wave B recompose owns the final placement): one row of
|
||||||
|
// per-instance voice-system controls inside the `voice` band. Left to right: a "Voices" label,
|
||||||
|
// a [-] step-down button, the count readout, a [+] step-up button, then a two-segment
|
||||||
|
// [Poly|Mono] toggle, then a two-segment [Retrig|Legato] toggle (live only in Mono). Both draw
|
||||||
|
// + hit-test derive from this ONE formula so they never drift (the channelToggleRects pattern).
|
||||||
|
constexpr int kVoiceSegW = 52;
|
||||||
|
constexpr int kVoiceSegH = 18;
|
||||||
|
constexpr int kVoiceStepW = 18; // the [-] / [+] stepper buttons
|
||||||
|
constexpr int kVoiceCountW = 30; // the numeric readout between them
|
||||||
|
constexpr int kVoiceLabelW = 44; // the "Voices" caption
|
||||||
|
constexpr int kVoiceGap = 16; // gap between the stepper / toggle groups
|
||||||
|
struct VoiceDeckRects {
|
||||||
|
Rect label; // "Voices" caption (decorative)
|
||||||
|
Rect minus; // step the count down
|
||||||
|
Rect count; // the numeric readout (decorative)
|
||||||
|
Rect plus; // step the count up
|
||||||
|
Rect poly; // VoiceMode::Poly segment
|
||||||
|
Rect mono; // VoiceMode::Mono segment
|
||||||
|
Rect retrig; // MonoTrigger::Retrigger segment
|
||||||
|
Rect legato; // MonoTrigger::Legato segment
|
||||||
|
};
|
||||||
|
VoiceDeckRects voiceDeckRects(const Rect& band) {
|
||||||
|
const int top = band.top + (band.height() - kVoiceSegH) / 2;
|
||||||
|
const int bot = top + kVoiceSegH;
|
||||||
|
int x = band.left + kPad;
|
||||||
|
VoiceDeckRects r;
|
||||||
|
r.label = Rect{x, top, x + kVoiceLabelW, bot}; x = r.label.right;
|
||||||
|
r.minus = Rect{x, top, x + kVoiceStepW, bot}; x = r.minus.right + 2;
|
||||||
|
r.count = Rect{x, top, x + kVoiceCountW, bot}; x = r.count.right + 2;
|
||||||
|
r.plus = Rect{x, top, x + kVoiceStepW, bot}; x = r.plus.right + kVoiceGap;
|
||||||
|
r.poly = Rect{x, top, x + kVoiceSegW, bot}; x = r.poly.right;
|
||||||
|
r.mono = Rect{x, top, x + kVoiceSegW, bot}; x = r.mono.right + kVoiceGap;
|
||||||
|
r.retrig = Rect{x, top, x + kVoiceSegW, bot}; x = r.retrig.right;
|
||||||
|
r.legato = Rect{x, top, x + kVoiceSegW, bot};
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface.
|
// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface.
|
||||||
// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws
|
// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws
|
||||||
// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip
|
// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip
|
||||||
@@ -1115,6 +1164,55 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary);
|
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Phase S voice deck (PROVISIONAL placement; Wave B owns the final composition) -----
|
||||||
|
if (bands.voice.height() > 0) {
|
||||||
|
fillSurface(bmp, toKitBox(bands.voice), Role::BgPanel, InteractionState::Rest);
|
||||||
|
const VoiceDeckRects vd = voiceDeckRects(bands.voice);
|
||||||
|
kitTextCentered(bmp, vd.label, "Voices", Font::Micro, Role::TextDim);
|
||||||
|
// Count stepper: [-] N [+]. The steppers grey out at the range edges (the shared
|
||||||
|
// pure-core kMin/kMaxVoiceCount — one spelling with the engine + the state bytes).
|
||||||
|
const bool canDown = voiceCount_ > kMinVoiceCount;
|
||||||
|
const bool canUp = voiceCount_ < kMaxVoiceCount;
|
||||||
|
fillSurface(bmp, toKitBox(vd.minus), Role::BgCell,
|
||||||
|
canDown ? InteractionState::Rest : InteractionState::Disabled);
|
||||||
|
fillSurface(bmp, toKitBox(vd.plus), Role::BgCell,
|
||||||
|
canUp ? InteractionState::Rest : InteractionState::Disabled);
|
||||||
|
kitTextCentered(bmp, vd.minus, "-", Font::Label,
|
||||||
|
canDown ? Role::TextPrimary : Role::TextDim);
|
||||||
|
kitTextCentered(bmp, vd.plus, "+", Font::Label,
|
||||||
|
canUp ? Role::TextPrimary : Role::TextDim);
|
||||||
|
char countBuf[8];
|
||||||
|
snprintf(countBuf, sizeof(countBuf), "%d", voiceCount_);
|
||||||
|
kitTextCentered(bmp, vd.count, countBuf, Font::ValueMono, Role::TextPrimary);
|
||||||
|
// Poly | Mono voice-mode toggle (the channel-toggle grammar: active segment lit).
|
||||||
|
const bool isMono = (voiceMode_ == VoiceMode::Mono);
|
||||||
|
fillSurface(bmp, toKitBox(vd.poly), Role::BgCell,
|
||||||
|
!isMono ? InteractionState::Active : InteractionState::Rest);
|
||||||
|
fillSurface(bmp, toKitBox(vd.mono), Role::BgCell,
|
||||||
|
isMono ? InteractionState::Active : InteractionState::Rest);
|
||||||
|
kitTextCentered(bmp, vd.poly, "Poly", Font::Label,
|
||||||
|
!isMono ? Role::BgBase : Role::TextPrimary);
|
||||||
|
kitTextCentered(bmp, vd.mono, "Mono", Font::Label,
|
||||||
|
isMono ? Role::BgBase : Role::TextPrimary);
|
||||||
|
// Retrig | Legato mono-takeover toggle — meaningful only in Mono; drawn Disabled
|
||||||
|
// (inert) in Poly so the dependency reads at a glance.
|
||||||
|
const bool isLegato = (monoTrigger_ == MonoTrigger::Legato);
|
||||||
|
const InteractionState retrigState =
|
||||||
|
!isMono ? InteractionState::Disabled
|
||||||
|
: (!isLegato ? InteractionState::Active : InteractionState::Rest);
|
||||||
|
const InteractionState legatoState =
|
||||||
|
!isMono ? InteractionState::Disabled
|
||||||
|
: (isLegato ? InteractionState::Active : InteractionState::Rest);
|
||||||
|
fillSurface(bmp, toKitBox(vd.retrig), Role::BgCell, retrigState);
|
||||||
|
fillSurface(bmp, toKitBox(vd.legato), Role::BgCell, legatoState);
|
||||||
|
kitTextCentered(bmp, vd.retrig, "Retrig", Font::Label,
|
||||||
|
!isMono ? Role::TextDim
|
||||||
|
: (!isLegato ? Role::BgBase : Role::TextPrimary));
|
||||||
|
kitTextCentered(bmp, vd.legato, "Legato", Font::Label,
|
||||||
|
!isMono ? Role::TextDim
|
||||||
|
: (isLegato ? Role::BgBase : Role::TextPrimary));
|
||||||
|
}
|
||||||
|
|
||||||
// --- The "Modes-and-down" control strip (S-VIEW-2: moved from Zone) --------------------
|
// --- The "Modes-and-down" control strip (S-VIEW-2: moved from Zone) --------------------
|
||||||
paintControls(bmp, bands.control, zone);
|
paintControls(bmp, bands.control, zone);
|
||||||
}
|
}
|
||||||
@@ -1873,6 +1971,39 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase S voice deck (PROVISIONAL). Every edit writes through the processor setter,
|
||||||
|
// which rebuilds the engine off-thread via the drain-slot swap (ringing tails survive);
|
||||||
|
// the local snapshot updates in step so the deck repaints without waiting for a sync tick.
|
||||||
|
if (bands.voice.height() > 0 && contains(bands.voice, x, y)) {
|
||||||
|
const VoiceDeckRects vd = voiceDeckRects(bands.voice);
|
||||||
|
if (contains(vd.minus, x, y) && voiceCount_ > kMinVoiceCount) {
|
||||||
|
voiceCount_ -= 1;
|
||||||
|
processor_->setVoiceCount(voiceCount_);
|
||||||
|
invalidate();
|
||||||
|
} else if (contains(vd.plus, x, y) && voiceCount_ < kMaxVoiceCount) {
|
||||||
|
voiceCount_ += 1;
|
||||||
|
processor_->setVoiceCount(voiceCount_);
|
||||||
|
invalidate();
|
||||||
|
} else if (contains(vd.poly, x, y)) {
|
||||||
|
voiceMode_ = VoiceMode::Poly;
|
||||||
|
processor_->setVoiceMode(VoiceMode::Poly);
|
||||||
|
invalidate();
|
||||||
|
} else if (contains(vd.mono, x, y)) {
|
||||||
|
voiceMode_ = VoiceMode::Mono;
|
||||||
|
processor_->setVoiceMode(VoiceMode::Mono);
|
||||||
|
invalidate();
|
||||||
|
} else if (voiceMode_ == VoiceMode::Mono && contains(vd.retrig, x, y)) {
|
||||||
|
monoTrigger_ = MonoTrigger::Retrigger; // inert (Disabled) in Poly
|
||||||
|
processor_->setMonoTrigger(MonoTrigger::Retrigger);
|
||||||
|
invalidate();
|
||||||
|
} else if (voiceMode_ == VoiceMode::Mono && contains(vd.legato, x, y)) {
|
||||||
|
monoTrigger_ = MonoTrigger::Legato;
|
||||||
|
processor_->setMonoTrigger(MonoTrigger::Legato);
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
return; // the deck band swallows its clicks (no fall-through to the hero/markers)
|
||||||
|
}
|
||||||
|
|
||||||
// S-VIEW-10: the velocity-curve editor beside the hero. Every in-box click is an edit
|
// S-VIEW-10: the velocity-curve editor beside the hero. Every in-box click is an edit
|
||||||
// (grab / Alt-delete / add-at-cursor), so materialize the one-zone site first (the
|
// (grab / Alt-delete / add-at-cursor), so materialize the one-zone site first (the
|
||||||
// mirror of the control strip's ensureSampleZone path).
|
// mirror of the control strip's ensureSampleZone path).
|
||||||
|
|||||||
@@ -347,6 +347,14 @@ private:
|
|||||||
PerformanceMap map_; // the opt-in zones (empty = no zones)
|
PerformanceMap map_; // the opt-in zones (empty = no zones)
|
||||||
ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot
|
ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot
|
||||||
|
|
||||||
|
// --- Phase S voice-deck snapshot (PROVISIONAL controls — the Wave B recompose owns the
|
||||||
|
// final deck). Mirrors of the processor's persisted voice-system params, refreshed with
|
||||||
|
// the rest of the live snapshot; every edit writes through the processor setters (which
|
||||||
|
// rebuild the engine off-thread via the drain-slot swap).
|
||||||
|
int voiceCount_ = kDefaultVoiceCount;
|
||||||
|
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||||
|
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||||
|
|
||||||
// --- Transient UI state (not persisted; component state carries selection + zones) ---
|
// --- Transient UI state (not persisted; component state carries selection + zones) ---
|
||||||
View view_ = View::kSample; // default face is the loaded-sample home (S-VIEW-1)
|
View view_ = View::kSample; // default face is the loaded-sample home (S-VIEW-1)
|
||||||
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||||
#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate)
|
#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kIoChanged (S7 re-negotiate)
|
||||||
#include "pluginterfaces/vst/ivstevents.h"
|
#include "pluginterfaces/vst/ivstevents.h"
|
||||||
|
#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic)
|
||||||
#include "pluginterfaces/vst/vstspeaker.h"
|
#include "pluginterfaces/vst/vstspeaker.h"
|
||||||
|
|
||||||
#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr)
|
#include "public.sdk/source/vst/vstbus.h" // Vst::AudioBus::setArrangement (S7 output arr)
|
||||||
@@ -33,17 +34,12 @@ namespace reasampler::vst {
|
|||||||
|
|
||||||
namespace {
|
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
|
// 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
|
// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather
|
||||||
// Preserve notes stays within the RT budget; a Preserve note-on past the cap is dropped rather
|
|
||||||
// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice
|
// 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;
|
constexpr std::size_t kPreserveVoiceCap = 8;
|
||||||
|
|
||||||
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
||||||
@@ -130,10 +126,12 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
||||||
// process() is not running at terminate. Free the live instrument and drain the
|
// process() is not running at terminate. Free the live + draining instruments and
|
||||||
// graveyard. Take the pointer out of the atomic first so nothing else races it.
|
// drain the graveyard. Take the pointers out of the atomics first so nothing else
|
||||||
|
// races them.
|
||||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||||
delete live_.exchange(nullptr);
|
delete live_.exchange(nullptr);
|
||||||
|
delete draining_.exchange(nullptr);
|
||||||
graveyard_.clear();
|
graveyard_.clear();
|
||||||
return SingleComponentEffect::terminate();
|
return SingleComponentEffect::terminate();
|
||||||
}
|
}
|
||||||
@@ -148,6 +146,13 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
|||||||
reloadFromBank();
|
reloadFromBank();
|
||||||
} else {
|
} else {
|
||||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
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();
|
graveyard_.clear();
|
||||||
}
|
}
|
||||||
return kResultOk;
|
return kResultOk;
|
||||||
@@ -210,6 +215,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
std::lock_guard<std::mutex> lock(previewMutex_);
|
std::lock_guard<std::mutex> lock(previewMutex_);
|
||||||
previewVelocity_ = cs.previewVelocity;
|
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).
|
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||||
reloadFromBank();
|
reloadFromBank();
|
||||||
return kResultOk;
|
return kResultOk;
|
||||||
@@ -231,6 +245,13 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
|||||||
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
|
state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker
|
||||||
}
|
}
|
||||||
state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity
|
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);
|
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
|
||||||
if (!bytes.empty()) {
|
if (!bytes.empty()) {
|
||||||
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||||
@@ -279,6 +300,57 @@ void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) {
|
|||||||
previewVelocity_ = 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;
|
||||||
|
}
|
||||||
|
// LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from
|
||||||
|
// the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change
|
||||||
|
// touches no audio data) and the displaced instrument keeps rendering its ringing tails,
|
||||||
|
// so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every
|
||||||
|
// zone from disk. Same contract for the mode/trigger setters below.
|
||||||
|
rebuildVoiceEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
rebuildVoiceEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
rebuildVoiceEngine();
|
||||||
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::previewNoteOn(int note) {
|
void ReaSamplerProcessor::previewNoteOn(int note) {
|
||||||
if (note < 0) note = 0;
|
if (note < 0) note = 0;
|
||||||
if (note > 127) note = 127;
|
if (note > 127) note = 127;
|
||||||
@@ -366,6 +438,17 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
|
// 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.
|
// Read once under its mutex, off the audio thread, before the decode loop.
|
||||||
const ChannelMode mode = channelMode();
|
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::string resolvedId;
|
||||||
std::unique_ptr<LoadedInstrument> built;
|
std::unique_ptr<LoadedInstrument> built;
|
||||||
@@ -431,33 +514,105 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||||
if (preserveWindow < 2) preserveWindow = 2;
|
if (preserveWindow < 2) preserveWindow = 2;
|
||||||
built = std::make_unique<LoadedInstrument>(
|
built = std::make_unique<LoadedInstrument>(
|
||||||
std::move(km), kMaxVoices, gen, kPreserveVoiceCap,
|
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||||
preserveWindow);
|
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
|
// 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the
|
||||||
// graveyard tagged with this generation (process may still be mid-block reading
|
// DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices —
|
||||||
// it). A null `built` (no bank / unreadable WAV) installs silence.
|
// a reload never cuts a sounding note; the next note-on plays the new state. The
|
||||||
// `built` is heap-owned; release() hands ownership to the atomic, and the
|
// instrument evicted FROM the drain slot (two reloads old) goes to the graveyard
|
||||||
// exchanged pointer is re-owned by 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
|
publishBuiltLocked(std::move(built));
|
||||||
// is the last generation process() published. process() publishes inst->installedAt
|
return resolvedId;
|
||||||
// (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
|
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
||||||
// displaced entry. The pruning condition is therefore <= (see header for the full
|
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
||||||
// proof). Remaining entries drain at setActive(false) / terminate() when process
|
// reloadFromBank and rebuildVoiceEngine — the one safety-critical swap dance.
|
||||||
// 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);
|
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||||
graveyard_.erase(
|
graveyard_.erase(
|
||||||
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
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());
|
graveyard_.end());
|
||||||
LoadedInstrument* prev = live_.exchange(built.release());
|
LoadedInstrument* prev = live_.exchange(built.release());
|
||||||
if (prev) graveyard_.push_back({gen, std::unique_ptr<LoadedInstrument>(prev)});
|
LoadedInstrument* evicted = draining_.exchange(prev);
|
||||||
return resolvedId;
|
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||||
|
// OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract:
|
||||||
|
// a voice-param change touches NO audio data, so this rebuilds the engine + preview card
|
||||||
|
// around a COPY of the live instrument's already-decoded keymap — no bridge, no disk —
|
||||||
|
// and publishes through the same drain-slot swap, so ringing tails survive.
|
||||||
|
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||||
|
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
|
||||||
|
if (!cur) return; // nothing loaded: the new params bake into the next real reload.
|
||||||
|
|
||||||
|
int builtVoiceCount = kDefaultVoiceCount;
|
||||||
|
VoiceMode builtVoiceMode = VoiceMode::Poly;
|
||||||
|
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
|
||||||
|
builtVoiceCount = voiceCount_;
|
||||||
|
builtVoiceMode = voiceMode_;
|
||||||
|
builtMonoTrigger = monoTrigger_;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
// Same Preserve-window derivation as reloadFromBank (kPreserveWindowMs at the host rate).
|
||||||
|
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||||
|
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||||
|
if (preserveWindow < 2) preserveWindow = 2;
|
||||||
|
|
||||||
|
// Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap
|
||||||
|
// is immutable after construction, and under reloadMutex_ nobody can free `cur`.
|
||||||
|
Keymap km = cur->keymap;
|
||||||
|
auto built = std::make_unique<LoadedInstrument>(
|
||||||
|
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||||
|
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||||
|
publishBuiltLocked(std::move(built));
|
||||||
|
}
|
||||||
|
|
||||||
|
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::BankSyncResult
|
||||||
@@ -467,6 +622,11 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
|||||||
// host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
|
// host, or before connect) yields nullopt for both reads, so this no-ops cleanly.
|
||||||
BankSyncResult result;
|
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 -------------------------------------
|
// --- S8: assignment-request consume FIRST -------------------------------------
|
||||||
// Decode the pending assignment request (nullopt when absent/malformed). Resolve its
|
// Decode the pending assignment request (nullopt when absent/malformed). Resolve its
|
||||||
// (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
|
// (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when
|
||||||
@@ -543,24 +703,52 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||||
// REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the
|
// REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments
|
||||||
// whole block (a single atomic acquire), then publish inst->installedAt so the off-
|
// once for the whole block (two atomic acquires), then publish the MINIMUM installedAt
|
||||||
// thread graveyard pruner knows exactly which generation this block is holding.
|
// 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
|
// We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an
|
||||||
// ordering race: reading reloadGeneration_ after live_ could observe a generation
|
// ordering race: reading reloadGeneration_ after the slots could observe a generation
|
||||||
// newer than the pointer we actually hold, causing the pruner to free an instrument
|
// 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
|
// 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
|
// exchange that made the instrument visible.
|
||||||
// instrument that could have been loaded after our acquire above.
|
//
|
||||||
|
// 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);
|
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);
|
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
|
// 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
|
// 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.
|
// 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();
|
const int32 count = data.inputEvents->getEventCount();
|
||||||
for (int32 i = 0; i < count; ++i) {
|
for (int32 i = 0; i < count; ++i) {
|
||||||
Event e;
|
Event e;
|
||||||
@@ -569,12 +757,46 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
|||||||
// A note-on with velocity 0 is a note-off by MIDI convention.
|
// 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);
|
const int vel = static_cast<int>(e.noteOn.velocity * 127.0f + 0.5f);
|
||||||
if (vel <= 0) {
|
if (vel <= 0) {
|
||||||
inst->engine.noteOff(e.noteOn.pitch);
|
if (inst) inst->engine.noteOff(e.noteOn.pitch);
|
||||||
} else {
|
if (drain) drain->engine.noteOff(e.noteOn.pitch);
|
||||||
|
} else if (inst) {
|
||||||
inst->engine.noteOn(e.noteOn.pitch, vel);
|
inst->engine.noteOn(e.noteOn.pitch, vel);
|
||||||
}
|
}
|
||||||
} else if (e.type == Event::kNoteOffEvent) {
|
} 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);
|
||||||
|
} else if (e.type == Event::kLegacyMIDICCOutEvent) {
|
||||||
|
// PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a
|
||||||
|
// VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism
|
||||||
|
// — the type is nominally an output event; DAW-verify, see handoff).
|
||||||
|
// CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR
|
||||||
|
// release tail; Trigger one-shots play through their bounded play length.
|
||||||
|
// CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless
|
||||||
|
// of play mode, including Trigger one-shots that ignore CC 123. This is the
|
||||||
|
// true "panic" for a ringing one-shot (e.g. a full-length capture).
|
||||||
|
// Both clear the mono held stack. Both apply to live AND drain, engine AND preview.
|
||||||
|
// allNotesOff / allSoundsOff / releaseAll / hardStop are RT-safe (no allocation,
|
||||||
|
// bounded scans).
|
||||||
|
const auto cc = static_cast<int>(e.midiCCOut.controlNumber);
|
||||||
|
if (cc == kCtrlAllSoundsOff) {
|
||||||
|
if (inst) {
|
||||||
|
inst->engine.allSoundsOff();
|
||||||
|
inst->preview.hardStop();
|
||||||
|
}
|
||||||
|
if (drain) {
|
||||||
|
drain->engine.allSoundsOff();
|
||||||
|
drain->preview.hardStop();
|
||||||
|
}
|
||||||
|
} else if (cc == kCtrlAllNotesOff) {
|
||||||
|
if (inst) {
|
||||||
|
inst->engine.allNotesOff();
|
||||||
|
inst->preview.releaseAll();
|
||||||
|
}
|
||||||
|
if (drain) {
|
||||||
|
drain->engine.allNotesOff();
|
||||||
|
drain->preview.releaseAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,8 +804,10 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
|||||||
// S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed
|
// 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
|
// 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.
|
// 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
|
// Phase S: preview note-on/off drive the dedicated PREVIEW CARD — a single voice structurally
|
||||||
// no MIDI wire) — off-thread posted, audio-thread consumed, no lock, no allocation.
|
// 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
|
// 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.
|
// is loaded does not re-fire stale on the next instrument load.
|
||||||
{
|
{
|
||||||
@@ -594,16 +818,20 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
|||||||
if (inst) {
|
if (inst) {
|
||||||
const int vel = static_cast<int>((on >> 8) & 0xFF);
|
const int vel = static_cast<int>((on >> 8) & 0xFF);
|
||||||
const int note = static_cast<int>(on & 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (inst) {
|
if (inst || drain) {
|
||||||
const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire);
|
const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire);
|
||||||
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
|
const std::uint16_t offSeq = static_cast<std::uint16_t>(off >> 16);
|
||||||
if (offSeq != 0 && offSeq != previewOffConsumed_) {
|
if (offSeq != 0 && offSeq != previewOffConsumed_) {
|
||||||
previewOffConsumed_ = offSeq;
|
previewOffConsumed_ = offSeq;
|
||||||
inst->engine.noteOff(static_cast<int>(off & 0xFF));
|
// 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 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,9 +867,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
|||||||
if (ch0 && ch1) {
|
if (ch0 && ch1) {
|
||||||
// Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo
|
// 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.
|
// 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; }
|
for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; }
|
||||||
if (inst) {
|
if (inst) {
|
||||||
inst->engine.render(ch0, ch1, static_cast<std::size_t>(frames));
|
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).
|
// Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
|
||||||
for (int32 ch = 2; ch < out.numChannels; ++ch) {
|
for (int32 ch = 2; ch < out.numChannels; ++ch) {
|
||||||
@@ -664,6 +898,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
|||||||
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
|
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
|
||||||
if (inst) {
|
if (inst) {
|
||||||
inst->engine.render(ch0, static_cast<std::size_t>(frames));
|
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;
|
float peak = 0.f;
|
||||||
for (int32 i = 0; i < frames; ++i) {
|
for (int32 i = 0; i < frames; ++i) {
|
||||||
@@ -679,10 +918,12 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Report silence only when nothing is loaded (lets the host optimize when idle).
|
// 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.
|
// With an instrument loaded — or a drain snapshot still ringing out — we clear the
|
||||||
out.silenceFlags = inst ? 0 : ((out.numChannels >= 64)
|
// flag so a ringing voice is not skipped.
|
||||||
? ~0ULL
|
out.silenceFlags = (inst || drain) ? 0
|
||||||
: ((1ULL << out.numChannels) - 1));
|
: ((out.numChannels >= 64)
|
||||||
|
? ~0ULL
|
||||||
|
: ((1ULL << out.numChannels) - 1));
|
||||||
return kResultOk;
|
return kResultOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+112
-37
@@ -35,11 +35,12 @@ namespace reasampler::vst {
|
|||||||
|
|
||||||
class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface)
|
class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||||
|
|
||||||
// One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice
|
// One fully-built, ready-to-play instrument snapshot: the decoded keymap, the voice
|
||||||
// engine that plays it. The engine holds a reference into the keymap, so the two MUST
|
// engine that plays it, and the isolated PREVIEW CARD (Phase S) summed alongside it.
|
||||||
// live and die together at a STABLE address — hence this is heap-allocated and neither
|
// Engine and card both hold references into the keymap, so the three MUST live and die
|
||||||
// copyable nor movable. The audio thread only ever reads it through an atomic pointer;
|
// together at a STABLE address — hence this is heap-allocated and neither copyable nor
|
||||||
// it is built and destroyed off the audio thread.
|
// movable. The audio thread only ever reads it through an atomic pointer; it is built
|
||||||
|
// and destroyed off the audio thread.
|
||||||
//
|
//
|
||||||
// installedAt: the reloadGeneration_ value at which this instrument was atomically
|
// installedAt: the reloadGeneration_ value at which this instrument was atomically
|
||||||
// installed into live_. Set on the reload path before the exchange. process() publishes
|
// installed into live_. Set on the reload path before the exchange. process() publishes
|
||||||
@@ -48,15 +49,25 @@ class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryI
|
|||||||
struct LoadedInstrument {
|
struct LoadedInstrument {
|
||||||
Keymap keymap;
|
Keymap keymap;
|
||||||
VoiceEngine engine;
|
VoiceEngine engine;
|
||||||
|
PreviewCard preview; // Phase S: the isolated preview voice — never part of the pool
|
||||||
std::uint64_t installedAt = 0; // reload generation at which this was installed
|
std::uint64_t installedAt = 0; // reload generation at which this was installed
|
||||||
|
|
||||||
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
||||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||||
std::int64_t preserveWindowFrames = 0)
|
std::int64_t preserveWindowFrames = 0,
|
||||||
|
VoiceMode voiceMode = VoiceMode::Poly,
|
||||||
|
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
|
||||||
: keymap(std::move(km)),
|
: keymap(std::move(km)),
|
||||||
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames),
|
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames,
|
||||||
|
voiceMode, monoTrigger),
|
||||||
|
preview(keymap, preserveWindowFrames),
|
||||||
installedAt(gen) {}
|
installedAt(gen) {}
|
||||||
|
|
||||||
|
// True when nothing in this snapshot is sounding — engine voices AND the preview card.
|
||||||
|
// process() publishes this for the drain slot so the off-thread retirer can park an
|
||||||
|
// idle drain in the graveyard early (FA1-review Major #2). Bounded scan (<= maxVoices).
|
||||||
|
bool fullyIdle() const { return engine.activeVoiceCount() == 0 && !preview.active(); }
|
||||||
|
|
||||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||||
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
||||||
};
|
};
|
||||||
@@ -186,13 +197,28 @@ public:
|
|||||||
std::uint8_t previewVelocity();
|
std::uint8_t previewVelocity();
|
||||||
void setPreviewVelocity(std::uint8_t velocity);
|
void setPreviewVelocity(std::uint8_t velocity);
|
||||||
|
|
||||||
// Fire a one-shot PREVIEW note-on / note-off through the live voice engine (S-VIEW-4), OFF
|
// --- Phase S voice-system parameters (per-instance, persisted in component state v7) ---
|
||||||
// the audio thread (the editor's preview-trigger button drives these on the UI thread). The
|
// Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded
|
||||||
// request is handed to process() via a lock-free single-slot mailbox drained at block start —
|
// by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine
|
||||||
// no allocation, no lock on the audio thread. previewNoteOn plays `note` at the current
|
// OFF-thread via rebuildVoiceEngine (a LIGHT rebuild around the already-decoded keymap; no
|
||||||
// previewVelocity(); previewNoteOff releases it (Gate) — Trigger zones ignore note-off and
|
// bridge read, no WAV re-decode) published through the same tail-preserving drain-slot swap,
|
||||||
// play through. A momentary button (down = on, up = off) reads as a natural key press. This
|
// so changing polyphony / mode / the retrigger toggle never cuts a ringing tail.
|
||||||
// is PLAYBACK ONLY: it never captures, never inserts a timeline item.
|
int voiceCount();
|
||||||
|
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
|
||||||
|
VoiceMode voiceMode();
|
||||||
|
void setVoiceMode(VoiceMode mode);
|
||||||
|
MonoTrigger monoTrigger();
|
||||||
|
void setMonoTrigger(MonoTrigger trigger);
|
||||||
|
|
||||||
|
// Fire a one-shot PREVIEW note-on / note-off through the live instrument's PREVIEW CARD
|
||||||
|
// (S-VIEW-4; Phase S isolation) — a dedicated single voice structurally OUTSIDE the MIDI
|
||||||
|
// pool, so a full pool never drops a preview and a preview never steals a playing voice.
|
||||||
|
// OFF the audio thread (the editor's preview-trigger button drives these on the UI thread).
|
||||||
|
// The request is handed to process() via a lock-free single-slot mailbox drained at block
|
||||||
|
// start — no allocation, no lock on the audio thread. previewNoteOn plays `note` at the
|
||||||
|
// current previewVelocity(); previewNoteOff releases it (Gate) — Trigger zones ignore
|
||||||
|
// note-off and play through. A momentary button (down = on, up = off) reads as a natural
|
||||||
|
// key press. This is PLAYBACK ONLY: it never captures, never inserts a timeline item.
|
||||||
void previewNoteOn(int note);
|
void previewNoteOn(int note);
|
||||||
void previewNoteOff(int note);
|
void previewNoteOff(int note);
|
||||||
|
|
||||||
@@ -202,41 +228,80 @@ private:
|
|||||||
// caller drives restartComponent when appropriate.
|
// caller drives restartComponent when appropriate.
|
||||||
void applyOutputArrangement(ChannelMode mode);
|
void applyOutputArrangement(ChannelMode mode);
|
||||||
|
|
||||||
|
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
|
||||||
|
// CURRENT drain instrument is fully idle (every engine voice + the preview card silent),
|
||||||
|
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
|
||||||
|
// stops costing resident memory as soon as its tails die, instead of squatting in the slot
|
||||||
|
// until the NEXT reload. Off the audio thread only (takes reloadMutex_); driven from
|
||||||
|
// pollBankSync's UI-timer tick (the same cadence that drives reloads — an idle drain with
|
||||||
|
// no editor open simply waits for the next reload/deactivate, exactly the pre-fix bound).
|
||||||
|
// Safe against a racing process(): idleness is monotone (the drain receives no note-ons)
|
||||||
|
// and the published value names the drain's OWN installedAt, so a stale publication about
|
||||||
|
// an OLDER drain can never retire a newer one; the graveyard prune's monotone-generation
|
||||||
|
// proof (see below) covers the free.
|
||||||
|
void retireIdleDrain();
|
||||||
|
|
||||||
|
// Phase S voice-param LIGHT rebuild (voice-review Major #3): rebuild the engine + preview
|
||||||
|
// card around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
|
||||||
|
// filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot
|
||||||
|
// swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the
|
||||||
|
// full reloadFromBank (which re-decodes every zone WAV from disk on the UI thread) was
|
||||||
|
// pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe:
|
||||||
|
// it is immutable after construction and, under reloadMutex_, the live instrument can
|
||||||
|
// neither be swapped nor freed while we read it. When nothing is loaded this is a no-op —
|
||||||
|
// the new params bake into the next real reload. Off the audio thread only.
|
||||||
|
void rebuildVoiceEngine();
|
||||||
|
|
||||||
|
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
|
||||||
|
// process()-published generation, swap `built` into live_, displace the previous live into
|
||||||
|
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
|
||||||
|
// reloadMutex_ held — factored out so reloadFromBank and rebuildVoiceEngine share the ONE
|
||||||
|
// safety-critical swap dance (see the handoff proof below).
|
||||||
|
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
||||||
|
|
||||||
ReaperBridge bridge_;
|
ReaperBridge bridge_;
|
||||||
|
|
||||||
// --- The audio-thread handoff (S4 real-time discipline) -----------------
|
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
|
||||||
// process() atomically loads `live_` at block start and marshals/renders against it —
|
// process() atomically loads `live_` AND `draining_` at block start and marshals/renders
|
||||||
// a single atomic acquire, no lock, no free on the audio thread.
|
// against them — two atomic acquires, no lock, no free on the audio thread.
|
||||||
//
|
//
|
||||||
// reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new
|
// reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new
|
||||||
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
|
// 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
|
// NOT freed and NOT silenced: it moves into `draining_`, where process() keeps
|
||||||
// rapid reloads could otherwise free a pointer process is using. Instead it is parked
|
// rendering its already-sounding voices (and routes note-offs to it) so a reload —
|
||||||
// in `graveyard_` tagged with the reload generation at which it was displaced.
|
// 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
|
// Bounded reclaim: process() publishes the MINIMUM installedAt over the (non-null)
|
||||||
// held instrument was installed) via processGeneration_ — a single atomic store, RT-
|
// pointers it holds this block via processGeneration_ — a single atomic store, RT-safe.
|
||||||
// safe. The reload path prunes graveyard entries where displacedAt <= seen (where seen
|
// The reload path frees graveyard entries whose installedAt < seen (the last published
|
||||||
// is the last published processGeneration_).
|
// value).
|
||||||
//
|
//
|
||||||
// Safety argument: an entry with displacedAt == D was displaced by reload D, which
|
// Safety argument: both slots are monotone in installedAt over time (live_ receives
|
||||||
// simultaneously installed its successor with installedAt == D. process() publishing
|
// successively newer builds; draining_ receives successively newer displaced lives), so
|
||||||
// seen == D means it holds that successor (or a later one). In either case, the
|
// the published minimum is monotone across blocks, and any future process() load yields
|
||||||
// displaced entry is not the pointer process is using, so freeing it is safe. The
|
// installedAt >= seen. An entry only reaches the graveyard by leaving BOTH slots
|
||||||
// pruning condition is therefore <= (not strict <): an entry displaced at exactly the
|
// (single-writer under reloadMutex_), so a graveyard entry with installedAt < seen can
|
||||||
// published generation is also provably unreachable.
|
// 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
|
// 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) /
|
// (typically 0–1 in normal use). Remaining entries drain at setActive(false) /
|
||||||
// terminate(), when the host guarantees process is stopped.
|
// terminate(), when the host guarantees process is stopped.
|
||||||
std::atomic<LoadedInstrument*> live_{nullptr};
|
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> 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)
|
std::atomic<std::uint64_t> processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread)
|
||||||
struct GraveyardEntry {
|
// Phase S: the installedAt of the drain instrument process() last observed FULLY IDLE
|
||||||
std::uint64_t displacedAt = 0; // reloadGeneration_ value when this was displaced
|
// (0 = none / the current drain still sounds). Written relaxed on the audio thread each
|
||||||
std::unique_ptr<LoadedInstrument> instrument;
|
// block; read by retireIdleDrain() off-thread. Naming the generation (not a bool) closes
|
||||||
};
|
// the swap race: a publication about an old drain can never retire its successor.
|
||||||
std::vector<GraveyardEntry> graveyard_; // drained on reclaim + setActive(false) + terminate
|
std::atomic<std::uint64_t> drainIdleGeneration_{0};
|
||||||
|
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||||
|
|
||||||
// The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence).
|
// The single-capture selection id (S10: the ONE picked capture; "" = no pick -> silence).
|
||||||
@@ -281,9 +346,19 @@ private:
|
|||||||
std::mutex previewMutex_;
|
std::mutex previewMutex_;
|
||||||
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
|
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
|
||||||
|
|
||||||
|
// Phase S voice-system parameters (per-instance, persisted in component state v7). Off-thread
|
||||||
|
// only (UI voice deck + getState/setState + reloadFromBank); guarded against a getState/editor
|
||||||
|
// race. Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior. NOT read on the audio
|
||||||
|
// thread — reloadFromBank bakes them into the LoadedInstrument's engine off-thread.
|
||||||
|
std::mutex voiceParamsMutex_;
|
||||||
|
int voiceCount_ = kDefaultVoiceCount;
|
||||||
|
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||||
|
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||||
|
|
||||||
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) ---------
|
// --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) ---------
|
||||||
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
|
// The editor's preview-trigger button posts a note-on/off request from the UI thread; process()
|
||||||
// drains it at block start and drives the live engine. ONE slot per direction, each a packed
|
// drains it at block start and drives the live instrument's PREVIEW CARD (Phase S — never the
|
||||||
|
// MIDI pool). ONE slot per direction, each a packed
|
||||||
// request whose high bits are a monotonically-incrementing sequence so process() detects a NEW
|
// request whose high bits are a monotonically-incrementing sequence so process() detects a NEW
|
||||||
// request by comparing against the last sequence it consumed (never re-firing a stale one). The
|
// request by comparing against the last sequence it consumed (never re-firing a stale one). The
|
||||||
// low 8 bits carry the note (on) / note (off); the on request also carries the velocity in the
|
// low 8 bits carry the note (on) / note (off); the on request also carries the velocity in the
|
||||||
|
|||||||
+32
-4
@@ -606,6 +606,15 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
|||||||
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
|
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
|
||||||
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
|
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
|
||||||
out.push_back(state.previewVelocity);
|
out.push_back(state.previewVelocity);
|
||||||
|
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly,
|
||||||
|
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the
|
||||||
|
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
|
||||||
|
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
|
||||||
|
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
|
||||||
|
: state.voiceCount;
|
||||||
|
out.push_back(static_cast<std::uint8_t>(vc));
|
||||||
|
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
|
||||||
|
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
|
||||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||||
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||||
@@ -681,11 +690,15 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
readZonesPayload(r, out.map, projectRate);
|
readZonesPayload(r, out.map, projectRate);
|
||||||
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
||||||
}
|
}
|
||||||
if (version != kComponentStateVersion) return out; // unknown -> empty
|
if (version != kComponentStateVersion &&
|
||||||
|
version != kSelectionZonesModeMarkerVelV6Version) {
|
||||||
|
return out; // unknown -> empty
|
||||||
|
}
|
||||||
|
|
||||||
// v6: the channel-mode byte, then the 8-byte consumed-assignment marker, then the 1-byte
|
// v6/v7 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
|
||||||
// preview velocity, precede the v3 body. A non-{0,1} mode byte is treated as mono
|
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
|
||||||
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
|
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
|
||||||
|
// instance.
|
||||||
const std::uint8_t modeByte = r.u8();
|
const std::uint8_t modeByte = r.u8();
|
||||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||||
@@ -698,6 +711,21 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
|
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
|
||||||
? previewVel
|
? previewVel
|
||||||
: kPreviewVelocityDefault;
|
: kPreviewVelocityDefault;
|
||||||
|
// v7 (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
|
||||||
|
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior.
|
||||||
|
if (version == kComponentStateVersion) {
|
||||||
|
const std::uint8_t vc = r.u8();
|
||||||
|
const std::uint8_t vm = r.u8();
|
||||||
|
const std::uint8_t mt = r.u8();
|
||||||
|
if (!r.ok) return out; // truncated inside the voice bytes -> empty (defaults hold)
|
||||||
|
// Out-of-range bytes fall back to the field's DEFAULT (the previewVelocity precedent
|
||||||
|
// for a corrupt blob) rather than clamping to an edge the user never chose.
|
||||||
|
out.voiceCount = (vc >= kMinVoiceCount && vc <= kMaxVoiceCount)
|
||||||
|
? static_cast<int>(vc)
|
||||||
|
: kDefaultVoiceCount;
|
||||||
|
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||||
|
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||||
|
}
|
||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
|
|||||||
+28
-11
@@ -447,17 +447,23 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
|||||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||||
// state), never auto-playing sample #1.
|
// state), never auto-playing sample #1.
|
||||||
//
|
//
|
||||||
// Format (envelope v6): 4-byte LE version tag (== 6), then a 1-byte channel-mode field (0 = mono,
|
// Format (envelope v7): 4-byte LE version tag (== 7), then a 1-byte channel-mode field (0 = mono,
|
||||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
||||||
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then a 4-byte LE selection-id length +
|
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
|
||||||
// id bytes, then the CURRENT zones payload (identical to serializePerformance's body — its own
|
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
|
||||||
// self-describing version, see the ZONES-PAYLOAD block). The 1-byte preview velocity is the ONLY
|
// trigger (0 = Retrigger, 1 = Legato), then a 4-byte LE selection-id length + id bytes, then the
|
||||||
// envelope-v6 addition over envelope-v5 — the envelope grew a field, the zones payload is untouched
|
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
|
||||||
// (a PARALLEL track owns zone-record extension under its own versioning; the two version numbers
|
// version, see the ZONES-PAYLOAD block). The three voice bytes are the ONLY envelope-v7 addition
|
||||||
// are independent axes). BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
|
// over envelope-v6 — the envelope grew fields, the zones payload is untouched (a PARALLEL track
|
||||||
// lastConsumedAssignGeneration = 0, and previewVelocity = kPreviewVelocityDefault, preserving
|
// owns zone-record extension under its own versioning; the two version numbers are independent
|
||||||
// current behavior for already-saved instances):
|
// axes — do NOT bump the zones-payload version for an envelope field). An out-of-range voice
|
||||||
// * v6 blob -> {channelMode, lastConsumedAssignGeneration, previewVelocity, selectionId, zones} direct.
|
// byte (a corrupt blob) falls back to the field's default rather than silencing the instance
|
||||||
|
// (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to channelMode =
|
||||||
|
// MONO, lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, and the
|
||||||
|
// Phase-S voice defaults {16 voices, Poly, Retrigger} — which reproduce pre-Phase-S behavior
|
||||||
|
// exactly — preserving current behavior for already-saved instances):
|
||||||
|
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones} direct.
|
||||||
|
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
|
||||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
|
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
|
||||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
|
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
|
||||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
|
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
|
||||||
@@ -485,9 +491,20 @@ struct ComponentState {
|
|||||||
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
|
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
|
||||||
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
|
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
|
||||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||||
|
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||||
|
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
|
||||||
|
// older blob lifting to these plays byte-identically.
|
||||||
|
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||||
|
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||||
|
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||||
};
|
};
|
||||||
|
|
||||||
inline constexpr std::uint32_t kComponentStateVersion = 6;
|
inline constexpr std::uint32_t kComponentStateVersion = 7;
|
||||||
|
|
||||||
|
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
|
||||||
|
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
|
||||||
|
// v6 blob to the voice defaults {16, Poly, Retrigger}.
|
||||||
|
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||||
|
|
||||||
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
|
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
|
||||||
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
|
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||||
|
|||||||
+220
-8
@@ -260,7 +260,8 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||||
double keyTrack, const vst::VelocityCurve& velocityCurve) {
|
double keyTrack, const vst::VelocityCurve& velocityCurve,
|
||||||
|
bool unityVarispeedBypass) {
|
||||||
active_ = true;
|
active_ = true;
|
||||||
releasing_ = false;
|
releasing_ = false;
|
||||||
amplitudeDone_ = false;
|
amplitudeDone_ = false;
|
||||||
@@ -280,6 +281,20 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
|||||||
playMode_ = p.playMode;
|
playMode_ = p.playMode;
|
||||||
pitchEngine_ = p.pitchEngine;
|
pitchEngine_ = p.pitchEngine;
|
||||||
|
|
||||||
|
// FA1 unity bypass, RE-SCOPED (Phase S): 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 ONLY when the caller opted in. At ratio 1.0
|
||||||
|
// the two engines are byte-identical EXCEPT the OLA shifter's structural onset cost (a
|
||||||
|
// half-window delay + Hann fade-in), which buys nothing at unity — but skipping it makes
|
||||||
|
// the root note speak ~25 ms EARLIER than its neighbors, an audible timing step in a
|
||||||
|
// chromatic MIDI line (the FA1-review Major). So: the PREVIEW card (always at root,
|
||||||
|
// latency-critical, no line to be uneven against) passes true; the MIDI VoiceEngine
|
||||||
|
// passes false and keeps one uniform onset across the keyboard.
|
||||||
|
if (unityVarispeedBypass && 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
|
// 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
|
// 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.
|
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
|
||||||
@@ -339,6 +354,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
|||||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Voice::retune(int note, int rootNote, double keyTrack) {
|
||||||
|
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||||
|
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||||
|
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||||
|
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a legato
|
||||||
|
// phrase is one gesture, one strike (classic mono-synth behavior).
|
||||||
|
if (!active_) return;
|
||||||
|
note_ = note;
|
||||||
|
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||||
|
}
|
||||||
|
|
||||||
void Voice::release() {
|
void Voice::release() {
|
||||||
if (!active_) return;
|
if (!active_) return;
|
||||||
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
|
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
|
||||||
@@ -347,6 +373,12 @@ void Voice::release() {
|
|||||||
env_.noteOff();
|
env_.noteOff();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Voice::hardStop() {
|
||||||
|
// CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots
|
||||||
|
// that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation.
|
||||||
|
active_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
double Voice::tickAmplitude() {
|
double Voice::tickAmplitude() {
|
||||||
double amp;
|
double amp;
|
||||||
if (playMode_ == PlayMode::Gate) {
|
if (playMode_ == PlayMode::Gate) {
|
||||||
@@ -500,17 +532,26 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
|||||||
|
|
||||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||||
std::size_t preserveVoiceCap,
|
std::size_t preserveVoiceCap,
|
||||||
std::int64_t preserveWindowFrames)
|
std::int64_t preserveWindowFrames,
|
||||||
: voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap),
|
VoiceMode voiceMode, MonoTrigger monoTrigger)
|
||||||
preserveVoiceCap_(preserveVoiceCap) {
|
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||||
// maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on;
|
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||||
// clamp to a single voice so the engine is always usable (documented degenerate).
|
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||||
//
|
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||||
|
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||||
|
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||||
|
keymap_(keymap),
|
||||||
|
preserveVoiceCap_(preserveVoiceCap),
|
||||||
|
voiceMode_(voiceMode), monoTrigger_(monoTrigger) {
|
||||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||||
// allocation point for the shifter rings across the engine's lifetime.
|
// allocation point for the shifter rings across the engine's lifetime.
|
||||||
|
// MONO: voices_.size() == 1, so the loop below sizes exactly one voice regardless of
|
||||||
|
// maxVoices — the Poly path sizes the whole pool as before.
|
||||||
if (preserveWindowFrames > 1) {
|
if (preserveWindowFrames > 1) {
|
||||||
for (Voice& v : voices_) v.presizePreserveShifters(preserveWindowFrames);
|
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||||
|
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,7 +590,92 @@ std::size_t VoiceEngine::allocateVoice() {
|
|||||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::removeHeld(int note) {
|
||||||
|
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||||
|
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||||
|
// Shift the notes above it down one slot (press order preserved).
|
||||||
|
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||||
|
--heldCount_;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||||
|
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||||
|
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||||
|
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||||
|
if (note < 0 || note > 127) return kNoVoice;
|
||||||
|
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||||
|
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
|
||||||
|
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||||
|
if (zone.sampleIndex >= keymap_.samples.size()) return kNoVoice;
|
||||||
|
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||||
|
|
||||||
|
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||||
|
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||||
|
removeHeld(note);
|
||||||
|
if (heldCount_ < heldStack_.size()) {
|
||||||
|
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||||
|
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||||
|
static_cast<std::uint8_t>(vclamped)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Voice& v = voices_[0];
|
||||||
|
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||||
|
// means another note was already physically held — the exact "takeover within a phrase"
|
||||||
|
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones:
|
||||||
|
// Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot
|
||||||
|
// still ringing after the last key-up was silently RETUNED in place instead of
|
||||||
|
// re-attacked. NOTE: a one-held-note same-note re-press (heldCount_ becomes 1 after the
|
||||||
|
// removeHeld/re-push above — so heldCount_ < 2) re-attacks rather than retuning, which is
|
||||||
|
// the correct fresh-phrase behavior for that edge case.) Same-sample requirement unchanged.
|
||||||
|
if (v.active() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
|
||||||
|
v.playingSample() == &sample) {
|
||||||
|
v.retune(note, zone.rootNote, zone.keyTrack);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// RETRIGGER takeover / first note of a phrase / cross-sample legato: (re)start the voice.
|
||||||
|
v.start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve);
|
||||||
|
v.setStartOrder(nextStartOrder_++);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::monoNoteOff(int note) {
|
||||||
|
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||||
|
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||||
|
if (note < 0 || note > 127) return;
|
||||||
|
removeHeld(note);
|
||||||
|
Voice& v = voices_[0];
|
||||||
|
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||||
|
// note) changes nothing audible.
|
||||||
|
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||||
|
|
||||||
|
if (heldCount_ == 0) {
|
||||||
|
v.release(); // last finger up: gate off (Trigger zones ignore this and play through).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||||
|
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||||
|
const ZoneResolution res = keymap_.resolve(fb.note, fb.velocity);
|
||||||
|
if (!res.matched || keymap_.zones[res.zoneIndex].sampleIndex >= keymap_.samples.size()) {
|
||||||
|
v.release(); // defensive: only resolving notes are pushed, so this shouldn't happen.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||||
|
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||||
|
if (monoTrigger_ == MonoTrigger::Legato && v.playingSample() == &sample) {
|
||||||
|
v.retune(fb.note, zone.rootNote, zone.keyTrack); // glide back, no re-attack
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Retrigger (or cross-sample) fallback: re-strike the fallen-back-to note at its own
|
||||||
|
// original velocity.
|
||||||
|
v.start(fb.note, fb.velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve);
|
||||||
|
v.setStartOrder(nextStartOrder_++);
|
||||||
|
}
|
||||||
|
|
||||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||||
|
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
|
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
|
||||||
|
|
||||||
@@ -578,6 +704,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void VoiceEngine::noteOff(int note) {
|
void VoiceEngine::noteOff(int note) {
|
||||||
|
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||||
std::size_t target = kNoVoice;
|
std::size_t target = kNoVoice;
|
||||||
@@ -595,6 +722,28 @@ void VoiceEngine::noteOff(int note) {
|
|||||||
if (target != kNoVoice) voices_[target].release();
|
if (target != kNoVoice) voices_[target].release();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::allNotesOff() {
|
||||||
|
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||||
|
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||||
|
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||||
|
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||||
|
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||||
|
heldCount_ = 0;
|
||||||
|
for (Voice& v : voices_) {
|
||||||
|
if (v.active()) v.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::allSoundsOff() {
|
||||||
|
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||||
|
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||||
|
// bounded by the pool size.
|
||||||
|
heldCount_ = 0;
|
||||||
|
for (Voice& v : voices_) {
|
||||||
|
v.hardStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||||
@@ -644,4 +793,67 @@ std::size_t VoiceEngine::activeVoiceCount() const {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PreviewCard (Phase S) — the isolated preview voice. See the header contract.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PreviewCard::PreviewCard(const Keymap& keymap, std::int64_t preserveWindowFrames)
|
||||||
|
: keymap_(keymap) {
|
||||||
|
// Construction is off the audio thread (the shell builds the card inside its
|
||||||
|
// LoadedInstrument) — the one allocation point for the preview voice's shifter rings,
|
||||||
|
// mirroring VoiceEngine's constructor. <= 1 leaves them pass-through.
|
||||||
|
if (preserveWindowFrames > 1) voice_.presizePreserveShifters(preserveWindowFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PreviewCard::noteOn(int note, int velocity) {
|
||||||
|
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||||
|
if (!res.matched) return; // out-of-zone: defined no-play
|
||||||
|
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||||
|
if (zone.sampleIndex >= keymap_.samples.size()) return;
|
||||||
|
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||||
|
// One voice, one finger: a new preview replaces the ringing one. The unity-Varispeed
|
||||||
|
// bypass is OPTED IN here (and only here) — the preview fires at the effective root, so
|
||||||
|
// this is its zero-added-latency path (the FA1 fix, re-scoped off the MIDI engine). No
|
||||||
|
// Preserve cap and no stealing interplay: the card is structurally outside the pool.
|
||||||
|
voice_.start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||||
|
/*unityVarispeedBypass=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PreviewCard::noteOff(int note) {
|
||||||
|
// Release only the note that is actually sounding — an off for a replaced (stale) preview
|
||||||
|
// note must not cut the new one.
|
||||||
|
if (voice_.active() && voice_.note() == note) voice_.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PreviewCard::releaseAll() {
|
||||||
|
// CC 123 peer of VoiceEngine::allNotesOff: unconditional release, whatever note rings.
|
||||||
|
// Gate enters release; Trigger plays through (bounded, cannot be stuck). RT-safe.
|
||||||
|
if (voice_.active()) voice_.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PreviewCard::hardStop() {
|
||||||
|
// CC 120 peer of VoiceEngine::allSoundsOff: immediate silence, no release ramp.
|
||||||
|
// Silences Trigger one-shots that releaseAll() cannot stop. RT-safe.
|
||||||
|
voice_.hardStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PreviewCard::render(AudioSample* out, std::size_t frameCount) {
|
||||||
|
if (out == nullptr || frameCount == 0 || !voice_.active()) return;
|
||||||
|
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||||
|
if (!voice_.active()) break;
|
||||||
|
out[f] += voice_.renderFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PreviewCard::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||||
|
if (left == nullptr || right == nullptr || frameCount == 0 || !voice_.active()) return;
|
||||||
|
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||||
|
if (!voice_.active()) break;
|
||||||
|
AudioSample l = 0.0f, r = 0.0f;
|
||||||
|
voice_.renderFrameStereo(l, r);
|
||||||
|
left[f] += l;
|
||||||
|
right[f] += r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+154
-3
@@ -17,6 +17,7 @@
|
|||||||
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
|
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
|
||||||
// core does no file I/O — it is handed decoded sample frames and produces audio frames.
|
// core does no file I/O — it is handed decoded sample frames and produces audio frames.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -35,6 +36,30 @@ namespace reasampler {
|
|||||||
// itself never branches on it — the mode only picks which render overload the shell drives.
|
// itself never branches on it — the mode only picks which render overload the shell drives.
|
||||||
enum class ChannelMode { Mono, Stereo };
|
enum class ChannelMode { Mono, Stereo };
|
||||||
|
|
||||||
|
// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's
|
||||||
|
// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE
|
||||||
|
// priority over a held-note stack (classic mono synth: a new note takes the voice over; the
|
||||||
|
// release of the top note falls back to the most-recent still-held note). A PERFORMANCE
|
||||||
|
// choice the instrument owns (component state), never a bank fact. Default Poly preserves
|
||||||
|
// current behavior.
|
||||||
|
enum class VoiceMode { Poly, Mono };
|
||||||
|
|
||||||
|
// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable).
|
||||||
|
// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps
|
||||||
|
// the envelope running when a note is taken over while another is held — pitch moves without
|
||||||
|
// a re-attack (and the fallback on top-note release glides back the same way). Legato applies
|
||||||
|
// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts
|
||||||
|
// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample
|
||||||
|
// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger.
|
||||||
|
enum class MonoTrigger { Retrigger, Legato };
|
||||||
|
|
||||||
|
// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count.
|
||||||
|
// One spelling shared by the engine, the component-state (de)serializer, and the editor's
|
||||||
|
// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool.
|
||||||
|
inline constexpr int kMinVoiceCount = 1;
|
||||||
|
inline constexpr int kMaxVoiceCount = 32;
|
||||||
|
inline constexpr int kDefaultVoiceCount = 16;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
|
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
|
||||||
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
|
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
|
||||||
@@ -379,14 +404,35 @@ public:
|
|||||||
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
|
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
|
||||||
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
|
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
|
||||||
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
|
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
|
||||||
|
// `unityVarispeedBypass` (Phase S, re-scoping FA1): when TRUE, a Preserve voice started at
|
||||||
|
// UNITY shift (baseRatio_ == 1.0, pitch env off) is demoted to the Varispeed read path — at
|
||||||
|
// unity the two engines are byte-identical except the OLA shifter's structural half-window
|
||||||
|
// onset delay, which buys nothing when there is no shift to preserve duration against. The
|
||||||
|
// PREVIEW card opts in (it fires at the root, so this is its zero-added-latency path); the
|
||||||
|
// MIDI VoiceEngine does NOT (default false) — a chromatic line must not step ~25 ms faster
|
||||||
|
// at the root note than one semitone away (the FA1-review timing-step finding).
|
||||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||||
double keyTrack = 1.0,
|
double keyTrack = 1.0,
|
||||||
const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat());
|
const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat(),
|
||||||
|
bool unityVarispeedBypass = false);
|
||||||
|
|
||||||
|
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the
|
||||||
|
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack.
|
||||||
|
// Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate,
|
||||||
|
// Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees
|
||||||
|
// the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample
|
||||||
|
// takeover must restart the voice instead (see MonoTrigger).
|
||||||
|
void retune(int note, int rootNote, double keyTrack = 1.0);
|
||||||
|
|
||||||
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
||||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||||
void release();
|
void release();
|
||||||
|
|
||||||
|
// HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless
|
||||||
|
// of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot
|
||||||
|
// instantly (which release() cannot do). RT-safe: no allocation, no lock.
|
||||||
|
void hardStop();
|
||||||
|
|
||||||
// True while this voice is producing (or about to produce) sound.
|
// True while this voice is producing (or about to produce) sound.
|
||||||
bool active() const { return active_; }
|
bool active() const { return active_; }
|
||||||
// The note this voice was started on (for note-off routing). Meaningless if idle.
|
// The note this voice was started on (for note-off routing). Meaningless if idle.
|
||||||
@@ -397,8 +443,15 @@ public:
|
|||||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||||
bool releasing() const { return releasing_; }
|
bool releasing() const { return releasing_; }
|
||||||
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||||
// meaningful while active().
|
// meaningful while active(). NOTE (Phase S re-scope of FA1): the unity-shift demotion to
|
||||||
|
// Varispeed is now OPT-IN via start()'s unityVarispeedBypass — only the preview card takes
|
||||||
|
// it; a MIDI Preserve voice keeps its shifter at every note so a chromatic line has one
|
||||||
|
// uniform onset (no ~25 ms step at the root).
|
||||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||||
|
// The SampleData this voice is playing (nullptr when never started). The engine's mono
|
||||||
|
// legato path compares it against the new note's resolved sample — a same-sample takeover
|
||||||
|
// retunes; a cross-sample one restarts. Identity only; callers never mutate through it.
|
||||||
|
const SampleData* playingSample() const { return sample_; }
|
||||||
|
|
||||||
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
|
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
|
||||||
// audio thread (this allocates). The engine calls it once at construction so start() — which
|
// audio thread (this allocates). The engine calls it once at construction so start() — which
|
||||||
@@ -497,8 +550,17 @@ public:
|
|||||||
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
|
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
|
||||||
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
|
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
|
||||||
// are unaffected.
|
// are unaffected.
|
||||||
|
//
|
||||||
|
// `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single
|
||||||
|
// voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||||
|
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a same-sample
|
||||||
|
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The
|
||||||
|
// engine's config is immutable — a mode/count change rebuilds the engine off-thread through
|
||||||
|
// the processor's drain-slot reload, so ringing tails survive the swap.
|
||||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0);
|
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||||
|
VoiceMode voiceMode = VoiceMode::Poly,
|
||||||
|
MonoTrigger monoTrigger = MonoTrigger::Retrigger);
|
||||||
|
|
||||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||||
@@ -511,6 +573,19 @@ public:
|
|||||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||||
void noteOff(int note);
|
void noteOff(int note);
|
||||||
|
|
||||||
|
// CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice
|
||||||
|
// (Gate voices enter their AHDSR release tail; Trigger one-shots ignore release and play
|
||||||
|
// through their bounded play length). This is the mono stack's ONLY reset path — a phantom
|
||||||
|
// entry left by a lost note-off would otherwise be resurrected by the fallback and sustain
|
||||||
|
// forever with no key held. RT-safe (no allocation, bounded by maxVoices).
|
||||||
|
void allNotesOff();
|
||||||
|
|
||||||
|
// CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no
|
||||||
|
// release ramp), clears the MONO held stack, and silences even Trigger one-shots that would
|
||||||
|
// ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior.
|
||||||
|
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
|
||||||
|
void allSoundsOff();
|
||||||
|
|
||||||
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
|
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
|
||||||
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
|
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
|
||||||
// this never touches memory it does not own and NEVER allocates). This is the
|
// this never touches memory it does not own and NEVER allocates). This is the
|
||||||
@@ -552,10 +627,86 @@ private:
|
|||||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||||
std::size_t activePreserveVoices() const;
|
std::size_t activePreserveVoices() const;
|
||||||
|
|
||||||
|
// --- MONO mode (Phase S): last-note priority over a held-note stack ------------
|
||||||
|
// The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most
|
||||||
|
// recent = the sounding note while the voice is gated). An out-of-zone note never joins
|
||||||
|
// (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing
|
||||||
|
// a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no
|
||||||
|
// allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback
|
||||||
|
// re-strikes the fallen-back-to note at ITS original velocity.
|
||||||
|
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||||
|
|
||||||
|
// Mono note-on: push to the stack and take the voice over (legato retune on a same-sample
|
||||||
|
// takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone
|
||||||
|
// or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8).
|
||||||
|
// The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter,
|
||||||
|
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||||
|
std::size_t monoNoteOn(int note, int velocity);
|
||||||
|
// Mono note-off: pop from the stack; if the released note was sounding, fall back to the
|
||||||
|
// most-recent still-held note (retrigger or legato per monoTrigger_), else release.
|
||||||
|
void monoNoteOff(int note);
|
||||||
|
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||||
|
void removeHeld(int note);
|
||||||
|
|
||||||
std::vector<Voice> voices_;
|
std::vector<Voice> voices_;
|
||||||
const Keymap& keymap_;
|
const Keymap& keymap_;
|
||||||
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
|
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
|
||||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||||
|
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||||
|
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||||
|
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||||
|
std::size_t heldCount_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PREVIEW VOICE CARD (Phase S). A dedicated single voice ENTIRELY ISOLATED from the MIDI
|
||||||
|
// VoiceEngine pool: the editor's preview trigger routes ONLY here, host MIDI ONLY to the
|
||||||
|
// engine, and the two are summed by the shell — neither can steal from, drop, or cap the
|
||||||
|
// other (the FA1 "preview dropped when voices are full" bug is structurally impossible).
|
||||||
|
//
|
||||||
|
// The card plays the loaded zone through its REAL params (the same Keymap resolution and
|
||||||
|
// Voice machinery — you hear the actual sound), but with the FA1 unity-Varispeed bypass
|
||||||
|
// OPTED IN: the preview fires at the effective root (unity shift), where the Preserve
|
||||||
|
// shifter's half-window onset delay buys nothing, so the preview speaks on frame one. A
|
||||||
|
// transposed preview (if ever fired off-root) keeps the genuine shifter path — the
|
||||||
|
// shifters are pre-sized at construction (off-thread), so noteOn stays RT-safe.
|
||||||
|
//
|
||||||
|
// PLAYBACK ONLY (load-bearing principle): the card never captures, never writes the bank,
|
||||||
|
// never inserts a timeline item. Pure — no VST3/REAPER types; the shell owns the mailbox
|
||||||
|
// cadence and the output summation.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PreviewCard {
|
||||||
|
public:
|
||||||
|
// `keymap` must outlive the card (same lifetime contract as VoiceEngine — both live on
|
||||||
|
// the shell's LoadedInstrument next to the Keymap they read). `preserveWindowFrames`
|
||||||
|
// pre-sizes the voice's Preserve shifters off-thread (0/1 = pass-through), mirroring the
|
||||||
|
// engine's constructor, so an off-root Preserve preview never allocates at note-on.
|
||||||
|
explicit PreviewCard(const Keymap& keymap, std::int64_t preserveWindowFrames = 0);
|
||||||
|
|
||||||
|
// Fire the preview note (RT-safe: no allocation). A new preview replaces the ringing one
|
||||||
|
// (single voice — the card is one finger, not a pool). Out-of-zone is a defined no-play.
|
||||||
|
void noteOn(int note, int velocity);
|
||||||
|
// Release the preview IF `note` is the one sounding (a stale off for a replaced note is a
|
||||||
|
// no-op). Gate zones enter release; Trigger zones ignore note-off and play through.
|
||||||
|
void noteOff(int note);
|
||||||
|
// CC 123 peer of VoiceEngine::allNotesOff: release the ringing preview UNCONDITIONALLY,
|
||||||
|
// whatever note it is on. Gate enters release; Trigger plays through (bounded). RT-safe.
|
||||||
|
void releaseAll();
|
||||||
|
// CC 120 peer of VoiceEngine::allSoundsOff: HARD-STOP the preview immediately (no release
|
||||||
|
// ramp, silences Trigger one-shots too). RT-safe.
|
||||||
|
void hardStop();
|
||||||
|
|
||||||
|
bool active() const { return voice_.active(); }
|
||||||
|
|
||||||
|
// Sum the card's contribution into the caller's buffer(s), ADDING (mirror of the engine's
|
||||||
|
// RT render contract — no allocation, no lock; null/zero-count is a no-op).
|
||||||
|
void render(AudioSample* out, std::size_t frameCount);
|
||||||
|
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Voice voice_;
|
||||||
|
const Keymap& keymap_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -1105,6 +1105,121 @@ static void testComponentStateV6TruncatedVelocity() {
|
|||||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v7 component state: the Phase S voice-system fields (count / mode / trigger) -------------
|
||||||
|
|
||||||
|
static void testComponentStateVoiceSystemRoundTrip() {
|
||||||
|
// Non-default values on all three fields prove the bytes are read back, not defaulted; the
|
||||||
|
// envelope neighbours (mode, marker, velocity, selection, zones) ride alongside intact.
|
||||||
|
ComponentState s;
|
||||||
|
s.selectionId = "pick";
|
||||||
|
s.channelMode = ChannelMode::Stereo;
|
||||||
|
s.lastConsumedAssignGeneration = 42;
|
||||||
|
s.previewVelocity = 99;
|
||||||
|
s.voiceCount = 5;
|
||||||
|
s.voiceMode = VoiceMode::Mono;
|
||||||
|
s.monoTrigger = MonoTrigger::Legato;
|
||||||
|
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
|
CHECK(back.voiceCount == 5);
|
||||||
|
CHECK(back.voiceMode == VoiceMode::Mono);
|
||||||
|
CHECK(back.monoTrigger == MonoTrigger::Legato);
|
||||||
|
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||||
|
CHECK(back.lastConsumedAssignGeneration == 42);
|
||||||
|
CHECK(back.previewVelocity == 99);
|
||||||
|
CHECK(back.selectionId == "pick");
|
||||||
|
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateVoiceDefaultsRoundTrip() {
|
||||||
|
// A default-constructed state carries {16, Poly, Retrigger} — the pre-Phase-S behavior —
|
||||||
|
// and round-trips it. Locks the constants the engine + editor share.
|
||||||
|
const ComponentState back =
|
||||||
|
deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
|
||||||
|
CHECK(back.voiceCount == kDefaultVoiceCount);
|
||||||
|
CHECK(kDefaultVoiceCount == 16 && kMinVoiceCount == 1 && kMaxVoiceCount == 32);
|
||||||
|
CHECK(back.voiceMode == VoiceMode::Poly);
|
||||||
|
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateVoiceCountExtremesRoundTrip() {
|
||||||
|
// Both range edges survive the single-byte field exactly.
|
||||||
|
for (int vc : {kMinVoiceCount, kMaxVoiceCount}) {
|
||||||
|
ComponentState s;
|
||||||
|
s.voiceCount = vc;
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
|
CHECK(back.voiceCount == vc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateVoiceCountWriterClamps() {
|
||||||
|
// The WRITER never emits an out-of-range byte: above-max clamps to max; a nonsensical
|
||||||
|
// below-min value (a programming error upstream) falls back to the default.
|
||||||
|
ComponentState hi;
|
||||||
|
hi.voiceCount = 99;
|
||||||
|
CHECK(deserializeComponentState(serializeComponentState(hi), 44100.0).voiceCount ==
|
||||||
|
kMaxVoiceCount);
|
||||||
|
ComponentState lo;
|
||||||
|
lo.voiceCount = 0;
|
||||||
|
CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).voiceCount ==
|
||||||
|
kDefaultVoiceCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV6LiftsVoiceDefaults() {
|
||||||
|
// A GENUINE v6 blob (version tag 6: mode, marker, velocity, id, zones — NO voice bytes)
|
||||||
|
// lifts to the Phase S voice defaults {16, Poly, Retrigger}, its other fields intact.
|
||||||
|
// Hand-built (serializeComponentState now emits v7, so it cannot make a v6 blob). This
|
||||||
|
// proves an already-saved pre-Phase-S instance restores playing exactly as it did.
|
||||||
|
std::vector<std::uint8_t> v6;
|
||||||
|
v6.push_back(6); v6.push_back(0); v6.push_back(0); v6.push_back(0); // version 6
|
||||||
|
v6.push_back(1); // channel mode = stereo
|
||||||
|
for (int i = 0; i < 8; ++i) v6.push_back(0); // marker = 0
|
||||||
|
v6.push_back(111); // preview velocity
|
||||||
|
const std::string id = "saved";
|
||||||
|
v6.push_back(static_cast<std::uint8_t>(id.size())); v6.push_back(0); v6.push_back(0); v6.push_back(0);
|
||||||
|
v6.insert(v6.end(), id.begin(), id.end());
|
||||||
|
v6.push_back(0); v6.push_back(0); v6.push_back(0); v6.push_back(0); // zone count 0
|
||||||
|
const ComponentState back = deserializeComponentState(v6, 44100.0);
|
||||||
|
CHECK(back.voiceCount == kDefaultVoiceCount);
|
||||||
|
CHECK(back.voiceMode == VoiceMode::Poly);
|
||||||
|
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
|
||||||
|
CHECK(back.previewVelocity == 111); // the v6 byte still honored
|
||||||
|
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||||
|
CHECK(back.selectionId == "saved");
|
||||||
|
CHECK(back.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV7CorruptVoiceBytesFallBack() {
|
||||||
|
// Out-of-range voice bytes in a v7 blob fall back to each field's DEFAULT (the
|
||||||
|
// previewVelocity corrupt-byte precedent) — a corrupt blob never silences or distorts the
|
||||||
|
// instance to an edge the user never chose. Build v7 by serializing, then vandalize the
|
||||||
|
// three voice bytes in place (offsets: 4 version + 1 mode + 8 marker + 1 velocity = 14).
|
||||||
|
ComponentState s;
|
||||||
|
s.voiceCount = 7;
|
||||||
|
s.voiceMode = VoiceMode::Mono;
|
||||||
|
s.monoTrigger = MonoTrigger::Legato;
|
||||||
|
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
||||||
|
bytes[14] = 0; // voice count 0: below kMinVoiceCount
|
||||||
|
bytes[15] = 7; // voice mode: not a legal {0,1} value
|
||||||
|
bytes[16] = 9; // mono trigger: not a legal {0,1} value
|
||||||
|
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||||
|
CHECK(back.voiceCount == kDefaultVoiceCount);
|
||||||
|
CHECK(back.voiceMode == VoiceMode::Poly); // non-1 mode byte -> Poly default
|
||||||
|
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV7TruncatedVoiceBytes() {
|
||||||
|
// A v7 blob cut INSIDE the three voice bytes -> empty, defaults holding (bounded read).
|
||||||
|
std::vector<std::uint8_t> t{7, 0, 0, 0, 1}; // version 7, mode byte
|
||||||
|
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker
|
||||||
|
t.push_back(64); // velocity byte
|
||||||
|
t.push_back(16); // voice count only —
|
||||||
|
const ComponentState back = deserializeComponentState(t, 44100.0); // mode/trigger cut
|
||||||
|
CHECK(back.voiceCount == kDefaultVoiceCount);
|
||||||
|
CHECK(back.voiceMode == VoiceMode::Poly);
|
||||||
|
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
|
||||||
|
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
|
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
|
||||||
//
|
//
|
||||||
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
|
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
|
||||||
@@ -1419,6 +1534,50 @@ static void testVelocityCurveResolvesToZone() {
|
|||||||
CHECK(r.zones[0].velocityCurve.equals(vst::VelocityCurve::linear()));
|
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() {
|
static void testVelocityCurveV6BackCompatLiftsToFlat() {
|
||||||
// A v6 PAYLOAD blob (marker + version 6 + full play tail + keyTrack, but NO velocity-curve field)
|
// 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
|
// lifts every zone to VelocityCurve::flat() (R10-F1 Option A — flat y=1). This is the DELIBERATE
|
||||||
@@ -1822,6 +1981,7 @@ int main() {
|
|||||||
testVelocityCurveRoundTrip();
|
testVelocityCurveRoundTrip();
|
||||||
testVelocityCurveThroughComponentEnvelope();
|
testVelocityCurveThroughComponentEnvelope();
|
||||||
testVelocityCurveResolvesToZone();
|
testVelocityCurveResolvesToZone();
|
||||||
|
testVelocityCurveEndToEndThroughReloadComposition();
|
||||||
testVelocityCurveV6BackCompatLiftsToFlat();
|
testVelocityCurveV6BackCompatLiftsToFlat();
|
||||||
testPlayParamsV2BackCompatLiftsToDefaults();
|
testPlayParamsV2BackCompatLiftsToDefaults();
|
||||||
testPlayParamsThroughComponentEnvelope();
|
testPlayParamsThroughComponentEnvelope();
|
||||||
@@ -1863,6 +2023,13 @@ int main() {
|
|||||||
testComponentStateV5LiftsVelocityToMid();
|
testComponentStateV5LiftsVelocityToMid();
|
||||||
testComponentStateV4LiftsVelocityToMid();
|
testComponentStateV4LiftsVelocityToMid();
|
||||||
testComponentStateV6TruncatedVelocity();
|
testComponentStateV6TruncatedVelocity();
|
||||||
|
testComponentStateVoiceSystemRoundTrip();
|
||||||
|
testComponentStateVoiceDefaultsRoundTrip();
|
||||||
|
testComponentStateVoiceCountExtremesRoundTrip();
|
||||||
|
testComponentStateVoiceCountWriterClamps();
|
||||||
|
testComponentStateV6LiftsVoiceDefaults();
|
||||||
|
testComponentStateV7CorruptVoiceBytesFallBack();
|
||||||
|
testComponentStateV7TruncatedVoiceBytes();
|
||||||
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
||||||
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
||||||
testReconcileKeepsOnlySelectedFullRangeZone();
|
testReconcileKeepsOnlySelectedFullRangeZone();
|
||||||
|
|||||||
+626
-2
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
#include "../src/vst/sampler_core.h"
|
#include "../src/vst/sampler_core.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -1291,18 +1292,155 @@ static void testPreserveGateStereoLoopComposes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
|
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
|
||||||
|
// Since the Phase S re-scope EVERY engine Preserve voice (root included) runs the shifter and
|
||||||
|
// counts toward the cap — the unity demotion is preview-card-only (see the Phase S section).
|
||||||
static void testPreserveVoiceCap() {
|
static void testPreserveVoiceCap() {
|
||||||
SampleData s = dcSample(2000, 60);
|
SampleData s = dcSample(2000, 60);
|
||||||
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
||||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
// 8 voices total, Preserve cap of 2.
|
// 8 voices total, Preserve cap of 2.
|
||||||
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
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); // 1st Preserve voice
|
||||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
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 (re-scoped by Phase S) — the Preserve unity-Varispeed bypass now belongs to the PREVIEW
|
||||||
|
// CARD ONLY. The MIDI engine keeps the shifter at EVERY Preserve note so a chromatic line has
|
||||||
|
// one uniform onset (the FA1-review ~25 ms root-note timing-step finding); the card — always
|
||||||
|
// fired at the effective root, latency-critical, with no line to be uneven against — opts in
|
||||||
|
// and speaks on frame one.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The ENGINE'S root-note Preserve voice now keeps the OLA path: frame 0 is the shifter's fill
|
||||||
|
// (near-silent), full level once the ring fills — the SAME onset as its transposed neighbors.
|
||||||
|
// Pre-re-scope this voice was demoted and spoke at 1.0 on frame 0.
|
||||||
|
static void testPreserveUnityEngineVoiceKeepsUniformOnset() {
|
||||||
|
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(60, 127); // at root: unity shift — NO demotion in the MIDI engine
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 1500);
|
||||||
|
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); // shifter onset, exactly like a transposed note
|
||||||
|
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); // and the ring fills to full level
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PREVIEW CARD at unity speaks on frame ONE — the FA1 latency fix, now scoped to the card.
|
||||||
|
static void testPreviewCardUnitySpeaksImmediately() {
|
||||||
|
SampleData s = dcSample(2000, 60);
|
||||||
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||||
|
card.noteOn(60, 127); // at root: unity shift -> demoted inside the card, zero onset delay
|
||||||
|
std::vector<AudioSample> buf(4, 0.0f);
|
||||||
|
card.render(buf.data(), buf.size());
|
||||||
|
CHECK(approx(buf[0], 1.0, 1e-6)); // the DC sample, on the very first frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyTrack 0 collapses EVERY note to unity — an off-root preview also demotes, speaks at once.
|
||||||
|
static void testPreviewCardKeyTrackZeroAlsoSpeaksImmediately() {
|
||||||
|
SampleData s = dcSample(2000, 60);
|
||||||
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
km.zones[0].keyTrack = 0.0; // no tracking: all keys play root pitch (unity)
|
||||||
|
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||||
|
card.noteOn(67, 127);
|
||||||
|
std::vector<AudioSample> buf(4, 0.0f);
|
||||||
|
card.render(buf.data(), buf.size());
|
||||||
|
CHECK(approx(buf[0], 1.0, 1e-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A TRANSPOSED preview keeps the genuine OLA path — the card's demotion is unity-ONLY.
|
||||||
|
static void testPreviewCardTransposedKeepsShifter() {
|
||||||
|
SampleData s = dcSample(4000, 60);
|
||||||
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||||
|
card.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
|
||||||
|
std::vector<AudioSample> buf(8, 0.0f);
|
||||||
|
card.render(buf.data(), buf.size());
|
||||||
|
double early = 0.0;
|
||||||
|
for (std::size_t i = 0; i < 8; ++i) {
|
||||||
|
early = (std::max)(early, static_cast<double>(std::fabs(buf[i])));
|
||||||
|
}
|
||||||
|
CHECK(early < 0.1); // shifter fill — duration preservation kept for off-root previews
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase S re-scope consequence: a ROOT-note engine Preserve voice keeps its shifter, so it
|
||||||
|
// COUNTS toward the Preserve cap like any other (pre-re-scope it was demoted and exempt).
|
||||||
|
static void testPreserveUnityVoiceCountsTowardCap() {
|
||||||
|
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); // root: a genuine Preserve voice now
|
||||||
|
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
|
||||||
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
||||||
CHECK(eng.activeVoiceCount() == 2);
|
CHECK(eng.activeVoiceCount() == 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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). ---
|
// --- 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
|
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
|
||||||
@@ -1353,6 +1491,457 @@ static void testZeroAdsrIsInstantSustain() {
|
|||||||
for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9));
|
for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase S — parameterized voice count, MONO mode (last-note held stack, Retrigger/Legato),
|
||||||
|
// and the isolated PREVIEW CARD.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// A DC sample at `level` with a flat (instant, fully-open) envelope — rendered output equals
|
||||||
|
// level * velocity gain, so WHICH sample is sounding is directly observable in the mix.
|
||||||
|
static SampleData dcLevelSample(std::size_t frames, float level, int rootNote) {
|
||||||
|
SampleData s;
|
||||||
|
s.frames.assign(frames, level);
|
||||||
|
s.rootNote = rootNote;
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-zone keymap with DISTINCT DC levels (0.25 / 0.75) so the mono tests can read which zone
|
||||||
|
// holds the voice off the rendered value: zone A = notes [40,59] root 50 -> 0.25; zone B =
|
||||||
|
// notes [60,80] root 70 -> 0.75.
|
||||||
|
static Keymap twoLevelKeymap() {
|
||||||
|
Keymap km;
|
||||||
|
km.samples.push_back(dcLevelSample(200000, 0.25f, 50));
|
||||||
|
km.samples.push_back(dcLevelSample(200000, 0.75f, 70));
|
||||||
|
KeyZone a; a.lowNote = 40; a.highNote = 59; a.rootNote = 50; a.sampleIndex = 0;
|
||||||
|
KeyZone b; b.lowNote = 60; b.highNote = 80; b.rootNote = 70; b.sampleIndex = 1;
|
||||||
|
km.zones.push_back(a);
|
||||||
|
km.zones.push_back(b);
|
||||||
|
return km;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rendered value on the next frame — one-frame probe of "what is sounding right now".
|
||||||
|
static double probeFrame(VoiceEngine& eng) {
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 1);
|
||||||
|
return static_cast<double>(out[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MONO last-note priority: a new note TAKES the single voice; releasing the top note falls
|
||||||
|
// back to the most-recent still-held note; releasing the last note gates off. Also: mono uses
|
||||||
|
// ONE voice regardless of the pool size.
|
||||||
|
static void testMonoLastNotePriorityAndFallback() {
|
||||||
|
Keymap km = twoLevelKeymap();
|
||||||
|
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
CHECK(eng.noteOn(50, 127) == 0); // zone A sounds
|
||||||
|
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||||
|
CHECK(eng.noteOn(70, 127) == 0); // zone B TAKES the voice (last-note priority)
|
||||||
|
CHECK(eng.activeVoiceCount() == 1); // mono: one voice even with 4 in the pool
|
||||||
|
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||||
|
eng.noteOff(70); // top released -> FALLBACK to still-held 50
|
||||||
|
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||||
|
eng.noteOff(50); // last finger up -> gate off (release 0 = instant)
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releasing a LOWER held note (not the sounding one) changes nothing audible; the released
|
||||||
|
// note also leaves the stack, so the final note-off truly empties it.
|
||||||
|
static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
|
||||||
|
Keymap km = twoLevelKeymap();
|
||||||
|
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
eng.noteOn(50, 127);
|
||||||
|
eng.noteOn(70, 127); // 70 sounds, 50 held beneath
|
||||||
|
eng.noteOff(50); // releasing the buried note: inaudible
|
||||||
|
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||||
|
eng.noteOff(70); // 50 already left the stack -> silence, no fallback
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-pressing a HELD note moves it to the top of the stack (it sounds again), and the note
|
||||||
|
// beneath becomes the fallback.
|
||||||
|
static void testMonoRepressHeldNoteMovesToTop() {
|
||||||
|
Keymap km = twoLevelKeymap();
|
||||||
|
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
eng.noteOn(50, 127);
|
||||||
|
eng.noteOn(70, 127);
|
||||||
|
CHECK(eng.noteOn(50, 127) == 0); // re-press while held: back on top
|
||||||
|
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||||
|
eng.noteOff(50); // falls back to 70 (now the most recent held)
|
||||||
|
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||||
|
eng.noteOff(70);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A RETRIGGER fallback re-strikes the fallen-back-to note at ITS ORIGINAL velocity (kept per
|
||||||
|
// held note on the stack), not the departing note's.
|
||||||
|
static void testMonoRetriggerFallbackUsesOriginalVelocity() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
km.zones[0].velocityCurve = vst::VelocityCurve::linear(); // gain = velocity/127
|
||||||
|
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
eng.noteOn(60, 32); // soft first note
|
||||||
|
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
|
||||||
|
eng.noteOn(64, 127); // loud takeover
|
||||||
|
CHECK(approx(probeFrame(eng), 1.0, 1e-6));
|
||||||
|
eng.noteOff(64); // fallback re-strikes 60 at ITS velocity (32)
|
||||||
|
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// An OUT-OF-ZONE note in mono is a defined no-play: it consumes nothing, never joins the
|
||||||
|
// stack (so it can never take the voice back on a fallback), and its note-off is inert.
|
||||||
|
static void testMonoOutOfZoneNeverJoinsStack() {
|
||||||
|
Keymap km = twoLevelKeymap(); // zones cover [40,59] + [60,80] only
|
||||||
|
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
eng.noteOn(70, 127);
|
||||||
|
CHECK(eng.noteOn(20, 127) == VoiceEngine::kNoVoice); // out of every zone
|
||||||
|
CHECK(eng.activeVoiceCount() == 1);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); // 70 undisturbed
|
||||||
|
eng.noteOff(20); // inert
|
||||||
|
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||||
|
eng.noteOff(70);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
// RETRIGGER restarts the amplitude envelope on a mono takeover: mid-attack level drops back
|
||||||
|
// to the ramp's origin when the new note takes the voice.
|
||||||
|
static void testMonoRetriggerRestartsEnvelope() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
s.play.adsr.attackFrames = 100; // slow linear attack: level at frame i = i/100
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 50); // mid-attack: level ~0.49 at frame 49
|
||||||
|
CHECK(approx(out[49], 0.49, 1e-6));
|
||||||
|
eng.noteOn(62, 127); // takeover: envelope RESTARTS
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // back at the attack origin
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEGATO keeps the envelope running through a same-sample takeover: pitch moves, NO re-attack.
|
||||||
|
static void testMonoLegatoContinuesEnvelope() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
s.play.adsr.attackFrames = 100;
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 50);
|
||||||
|
CHECK(approx(out[49], 0.49, 1e-6));
|
||||||
|
eng.noteOn(62, 127); // legato takeover: envelope KEEPS running
|
||||||
|
CHECK(approx(probeFrame(eng), 0.50, 1e-6)); // frame 50 of the SAME attack ramp
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEGATO retunes without restarting the read head, and the velocity gain stays the FIRST
|
||||||
|
// note's (a legato phrase is one gesture, one strike). Observed on a ramp sample: values
|
||||||
|
// continue from the current read position at the NEW pitch ratio; a soft second strike does
|
||||||
|
// not duck the level.
|
||||||
|
static void testMonoLegatoRetunesWithoutReadRestart() {
|
||||||
|
SampleData s;
|
||||||
|
s.frames.resize(200000);
|
||||||
|
for (std::size_t i = 0; i < s.frames.size(); ++i) {
|
||||||
|
s.frames[i] = static_cast<float>(i); // ramp: output value == read position
|
||||||
|
}
|
||||||
|
s.rootNote = 60;
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(60, 127); // unity: read advances 1/frame, full gain
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 10);
|
||||||
|
CHECK(approx(out[9], 9.0, 1e-4));
|
||||||
|
eng.noteOn(72, 1); // legato to +1 octave at a WHISPER velocity
|
||||||
|
CHECK(approx(probeFrame(eng), 10.0, 1e-3)); // read CONTINUES at 10 — no restart, gain kept
|
||||||
|
CHECK(approx(probeFrame(eng), 12.0, 1e-3)); // and now advances at ratio 2 (the new pitch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEGATO applies only to a SAME-SAMPLE takeover: crossing into a zone playing a DIFFERENT
|
||||||
|
// sample restarts the voice (one read head cannot glide between two PCM streams).
|
||||||
|
static void testMonoLegatoCrossSampleRestarts() {
|
||||||
|
Keymap km = twoLevelKeymap();
|
||||||
|
km.samples[1].play.adsr.attackFrames = 100; // zone B has a slow attack to expose a restart
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(50, 127); // zone A (flat env): 0.25 at once
|
||||||
|
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||||
|
eng.noteOn(70, 127); // cross-sample: RESTART (attack from 0), no retune
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // zone B's fresh attack origin — not 0.25 held over
|
||||||
|
}
|
||||||
|
|
||||||
|
// LEGATO after the last note was RELEASED re-attacks: a releasing voice's note has left the
|
||||||
|
// stack, so the next press is a fresh phrase, not a takeover.
|
||||||
|
static void testMonoLegatoAfterReleaseReattacks() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
s.play.adsr.attackFrames = 100;
|
||||||
|
s.play.adsr.releaseFrames = 1000; // long release keeps the voice audibly ringing
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 150); // through the attack: at full level
|
||||||
|
eng.noteOff(60); // release begins (stack now empty)
|
||||||
|
out.clear();
|
||||||
|
eng.render(out, 10);
|
||||||
|
eng.noteOn(62, 127); // a NEW phrase: re-attacks even in Legato
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // fresh attack origin, not the ringing level
|
||||||
|
}
|
||||||
|
|
||||||
|
// MONO does not apply the S16 Preserve cap: a single voice runs at most one shifter — a
|
||||||
|
// Preserve->Preserve takeover must never be dropped by the cap.
|
||||||
|
static void testMonoIgnoresPreserveCap() {
|
||||||
|
SampleData s = dcSample(4000, 60);
|
||||||
|
s.play.pitchEngine = PitchEngine::Preserve;
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(4, km, /*preserveCap=*/1, /*window=*/256,
|
||||||
|
VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
CHECK(eng.noteOn(62, 127) == 0); // 1st Preserve note: at the cap
|
||||||
|
CHECK(eng.noteOn(64, 127) == 0); // takeover NOT dropped (poly cap would drop it)
|
||||||
|
CHECK(eng.activeVoiceCount() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ramp sample (output value == read position) so a re-attack (read restarts at 0) is
|
||||||
|
// directly distinguishable from a legato retune (read continues) on the rendered value.
|
||||||
|
static SampleData rampSample(std::size_t frames, int rootNote) {
|
||||||
|
SampleData s;
|
||||||
|
s.frames.resize(frames);
|
||||||
|
for (std::size_t i = 0; i < frames; ++i) s.frames[i] = static_cast<float>(i);
|
||||||
|
s.rootNote = rootNote;
|
||||||
|
s.play.adsr = flatAdsr();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
|
||||||
|
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
|
||||||
|
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
|
||||||
|
// silently RETUNED it in place. The correct predicate is the HELD-STACK depth: with no other
|
||||||
|
// key down, the next note is a fresh phrase and must restart the read head.
|
||||||
|
static void testMonoLegatoTriggerReattacksAfterKeyUp() {
|
||||||
|
SampleData s = rampSample(200000, 60);
|
||||||
|
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(60, 127); // unity: read advances 1/frame
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 10);
|
||||||
|
eng.noteOff(60); // Trigger ignores the gate: keeps ringing...
|
||||||
|
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // ...read head still advancing past 10
|
||||||
|
eng.noteOn(62, 127); // NO key held -> fresh phrase: RE-ATTACK
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (a retune would read ~11)
|
||||||
|
// And it is genuinely playing from the top at the new pitch (ratio 2^(2/12) ~ 1.1225),
|
||||||
|
// not merely silent: the next frame reads at the advanced position.
|
||||||
|
CHECK(approx(probeFrame(eng), std::pow(2.0, 2.0 / 12.0), 1e-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Companion boundary: with another key STILL physically held, a same-sample Trigger takeover
|
||||||
|
// under Legato still RETUNES (read continues) — the held-stack predicate matches the old
|
||||||
|
// behavior everywhere except the ringing-but-unheld case above.
|
||||||
|
static void testMonoLegatoTriggerHeldKeyStillRetunes() {
|
||||||
|
SampleData s = rampSample(200000, 60);
|
||||||
|
s.play.playMode = PlayMode::Trigger;
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 10);
|
||||||
|
eng.noteOn(62, 127); // 60 still held -> legato takeover
|
||||||
|
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // read CONTINUES at 10 — no re-attack
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
|
||||||
|
static void testAllNotesOffReleasesPolyVoices() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(4, km);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
eng.noteOn(62, 127);
|
||||||
|
eng.noteOn(64, 127);
|
||||||
|
CHECK(eng.activeVoiceCount() == 3);
|
||||||
|
eng.allNotesOff();
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9)); // all gated off (release 0 = instant)
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry
|
||||||
|
// (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
|
||||||
|
static void testAllNotesOffClearsMonoHeldStack() {
|
||||||
|
Keymap km = twoLevelKeymap();
|
||||||
|
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom)
|
||||||
|
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack
|
||||||
|
eng.allNotesOff(); // PANIC
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback
|
||||||
|
// restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever).
|
||||||
|
eng.noteOn(70, 127);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||||
|
eng.noteOff(70);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAJOR-2 companion: the preview card's unconditional releaseAll (the panic peer).
|
||||||
|
static void testPreviewCardReleaseAll() {
|
||||||
|
Keymap km = twoLevelKeymap();
|
||||||
|
PreviewCard card(km);
|
||||||
|
card.noteOn(70, 127);
|
||||||
|
CHECK(card.active());
|
||||||
|
card.releaseAll(); // no note argument: quiets whatever rings
|
||||||
|
std::vector<AudioSample> buf(1, 0.0f);
|
||||||
|
card.render(buf.data(), buf.size());
|
||||||
|
CHECK(approx(buf[0], 0.0, 1e-9));
|
||||||
|
CHECK(!card.active());
|
||||||
|
}
|
||||||
|
|
||||||
|
// CC 120 (allSoundsOff) hard-stops a ringing TRIGGER one-shot that would otherwise play to
|
||||||
|
// its bounded playEnd (minutes on a full-length capture). This is the primary repro: allNotesOff
|
||||||
|
// (CC 123) is a NO-OP on a Trigger voice — only allSoundsOff provides the actual hard stop.
|
||||||
|
static void testAllSoundsOffStopsTriggerOneShot() {
|
||||||
|
// A Trigger sample with a very long play length (all-1 DC, flat velocity). After noteOn the
|
||||||
|
// voice is active and ringing; allSoundsOff must silence it immediately.
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
s.play.playMode = PlayMode::Trigger;
|
||||||
|
s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(1, km);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
CHECK(eng.activeVoiceCount() == 1);
|
||||||
|
// CC 123 (release) must be a NO-OP on a Trigger voice — the one-shot plays through.
|
||||||
|
eng.allNotesOff();
|
||||||
|
CHECK(eng.activeVoiceCount() == 1); // still ringing (Trigger ignores release)
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 1);
|
||||||
|
CHECK(out[0] > 0.5f); // still sounding
|
||||||
|
// CC 120 (hard-stop) must silence it instantly.
|
||||||
|
eng.allSoundsOff();
|
||||||
|
CHECK(eng.activeVoiceCount() == 0); // immediately idle
|
||||||
|
out.clear();
|
||||||
|
eng.render(out, 1);
|
||||||
|
CHECK(approx(out[0], 0.0, 1e-9)); // silent
|
||||||
|
}
|
||||||
|
|
||||||
|
// CC 123 (allNotesOff) still releases Gate voices — the existing release behavior is unchanged.
|
||||||
|
static void testAllNotesOffStillReleasesGateVoices() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
// Default Gate mode, instant release (releaseFrames 0).
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(4, km);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
eng.noteOn(62, 127);
|
||||||
|
CHECK(eng.activeVoiceCount() == 2);
|
||||||
|
eng.allNotesOff();
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 1);
|
||||||
|
CHECK(approx(out[0], 0.0, 1e-9)); // Gate with 0-release: instant silence
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MONO LEGATO same-note re-press (one-held-note edge case): with only that note on the
|
||||||
|
// stack, heldCount_ after the re-push is 1 (not >= 2), so it falls through to re-attack
|
||||||
|
// rather than retune. This is the correct fresh-phrase behavior documented in the comment.
|
||||||
|
static void testMonoLegatoSameNoteRepressReattacks() {
|
||||||
|
SampleData s = rampSample(200000, 60);
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||||
|
eng.noteOn(60, 127); // first press; read starts at 0
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
eng.render(out, 10); // advance the read head to ~10
|
||||||
|
// Re-press the SAME note while it is the only held note: heldCount_ after removeHeld+push = 1
|
||||||
|
// -> does NOT satisfy heldCount_ >= 2 -> re-attack (not a legato retune).
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (re-attack, not retune)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GREEN: out-of-range notes are rejected at BOTH mono entry points. The held stack stores
|
||||||
|
// uint8, so an unguarded off for note 256 (== 0 mod 256) would alias-evict held note 0 —
|
||||||
|
// losing its fallback. Note-ons out of [0,127] are a defined no-play.
|
||||||
|
static void testMonoOutOfRangeNotesRejected() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||||
|
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
|
||||||
|
CHECK(eng.noteOn(-1, 127) == VoiceEngine::kNoVoice);
|
||||||
|
CHECK(eng.activeVoiceCount() == 0);
|
||||||
|
eng.noteOn(0, 127); // hold the aliasing target (note 0)
|
||||||
|
eng.noteOn(62, 127); // 62 takes the voice; 0 held beneath
|
||||||
|
eng.noteOff(256); // MUST NOT alias-evict held note 0
|
||||||
|
eng.noteOff(-256); // likewise for the negative wrap
|
||||||
|
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // 62 undisturbed
|
||||||
|
eng.noteOff(62); // falls back to STILL-HELD note 0
|
||||||
|
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // (alias-evicted pre-fix -> silence here)
|
||||||
|
eng.noteOff(0);
|
||||||
|
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user-parameterized polyphony bound: an N-voice engine holds exactly N simultaneous
|
||||||
|
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
|
||||||
|
static void testVoiceCountBoundsPolyphony() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine e3(3, km);
|
||||||
|
CHECK(e3.maxVoices() == 3);
|
||||||
|
e3.noteOn(60, 127);
|
||||||
|
e3.noteOn(62, 127);
|
||||||
|
e3.noteOn(64, 127);
|
||||||
|
CHECK(e3.activeVoiceCount() == 3);
|
||||||
|
e3.noteOn(65, 127); // 4th: steals within the pool
|
||||||
|
CHECK(e3.activeVoiceCount() == 3);
|
||||||
|
|
||||||
|
VoiceEngine e1(1, km);
|
||||||
|
e1.noteOn(60, 127);
|
||||||
|
e1.noteOn(62, 127);
|
||||||
|
CHECK(e1.activeVoiceCount() == 1); // 1-voice pool: every note steals the one voice
|
||||||
|
|
||||||
|
VoiceEngine e0(0, km);
|
||||||
|
CHECK(e0.maxVoices() == 1); // documented degenerate: clamped to 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// PREVIEW-CARD ISOLATION: the card never consumes a pool voice, a FULL pool never drops a
|
||||||
|
// preview, and pool stealing never touches the ringing preview. The two sum independently.
|
||||||
|
static void testPreviewCardIsolatedFromPool() {
|
||||||
|
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||||
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||||
|
VoiceEngine eng(2, km);
|
||||||
|
PreviewCard card(km);
|
||||||
|
eng.noteOn(60, 127);
|
||||||
|
eng.noteOn(62, 127); // the pool is now FULL
|
||||||
|
card.noteOn(64, 127); // preview fires anyway — its own voice
|
||||||
|
CHECK(eng.activeVoiceCount() == 2); // no pool voice consumed
|
||||||
|
CHECK(card.active());
|
||||||
|
std::vector<AudioSample> buf(4, 0.0f);
|
||||||
|
eng.render(buf.data(), buf.size()); // engine sums 2 voices...
|
||||||
|
card.render(buf.data(), buf.size()); // ...card ADDS its own on top
|
||||||
|
CHECK(approx(buf[0], 3.0, 1e-6));
|
||||||
|
eng.noteOn(64, 127); // pool steals INTERNALLY...
|
||||||
|
CHECK(eng.activeVoiceCount() == 2);
|
||||||
|
CHECK(card.active()); // ...the preview is untouched
|
||||||
|
card.noteOff(64); // flat release: card gates off instantly
|
||||||
|
std::vector<AudioSample> buf2(1, 0.0f);
|
||||||
|
card.render(buf2.data(), buf2.size());
|
||||||
|
CHECK(approx(buf2[0], 0.0, 1e-9));
|
||||||
|
CHECK(eng.activeVoiceCount() == 2); // and the pool never noticed
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card is ONE voice: a new preview replaces the ringing one, a STALE note-off (for the
|
||||||
|
// replaced note) is a no-op, and an out-of-zone preview is a defined no-play.
|
||||||
|
static void testPreviewCardReplaceStaleOffAndOutOfZone() {
|
||||||
|
Keymap km = twoLevelKeymap(); // zones [40,59] + [60,80]
|
||||||
|
PreviewCard card(km);
|
||||||
|
card.noteOn(50, 127);
|
||||||
|
card.noteOn(70, 127); // replaces the first preview
|
||||||
|
std::vector<AudioSample> buf(1, 0.0f);
|
||||||
|
card.render(buf.data(), buf.size());
|
||||||
|
CHECK(approx(buf[0], 0.75, 1e-6)); // zone B is what rings
|
||||||
|
card.noteOff(50); // STALE off for the replaced note: no-op
|
||||||
|
CHECK(card.active());
|
||||||
|
card.noteOff(70); // the sounding note's off gates it (release 0)
|
||||||
|
std::vector<AudioSample> buf2(1, 0.0f);
|
||||||
|
card.render(buf2.data(), buf2.size());
|
||||||
|
CHECK(approx(buf2[0], 0.0, 1e-9));
|
||||||
|
card.noteOn(20, 127); // out of every zone: defined no-play
|
||||||
|
CHECK(!card.active());
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testChromaticSingleRoot();
|
testChromaticSingleRoot();
|
||||||
testZonedRangesBoundaries();
|
testZonedRangesBoundaries();
|
||||||
@@ -1409,10 +1998,45 @@ int main() {
|
|||||||
testPreserveGateStereoLoopComposes();
|
testPreserveGateStereoLoopComposes();
|
||||||
testPreserveVoiceCap();
|
testPreserveVoiceCap();
|
||||||
|
|
||||||
|
// FA1 (re-scoped by Phase S) — the unity bypass is preview-card-only; the engine keeps a
|
||||||
|
// uniform Preserve onset. Velocity under Preserve unchanged.
|
||||||
|
testPreserveUnityEngineVoiceKeepsUniformOnset();
|
||||||
|
testPreviewCardUnitySpeaksImmediately();
|
||||||
|
testPreviewCardKeyTrackZeroAlsoSpeaksImmediately();
|
||||||
|
testPreviewCardTransposedKeepsShifter();
|
||||||
|
testPreserveTransposedVoiceKeepsOlaPath();
|
||||||
|
testPreserveUnityVoiceCountsTowardCap();
|
||||||
|
testVelocityCurveAppliesUnderPreserve();
|
||||||
|
|
||||||
// S12 review fix — per-zone A/D/S/R reaches the voice envelope.
|
// S12 review fix — per-zone A/D/S/R reaches the voice envelope.
|
||||||
testPerZoneAdsrReachesVoiceEnvelope();
|
testPerZoneAdsrReachesVoiceEnvelope();
|
||||||
testZeroAdsrIsInstantSustain();
|
testZeroAdsrIsInstantSustain();
|
||||||
|
|
||||||
|
// Phase S — voice count, MONO mode (held stack + Retrigger/Legato), preview card.
|
||||||
|
testMonoLastNotePriorityAndFallback();
|
||||||
|
testMonoReleaseOfLowerHeldNoteIsInaudible();
|
||||||
|
testMonoRepressHeldNoteMovesToTop();
|
||||||
|
testMonoRetriggerFallbackUsesOriginalVelocity();
|
||||||
|
testMonoOutOfZoneNeverJoinsStack();
|
||||||
|
testMonoRetriggerRestartsEnvelope();
|
||||||
|
testMonoLegatoContinuesEnvelope();
|
||||||
|
testMonoLegatoRetunesWithoutReadRestart();
|
||||||
|
testMonoLegatoCrossSampleRestarts();
|
||||||
|
testMonoLegatoAfterReleaseReattacks();
|
||||||
|
testMonoIgnoresPreserveCap();
|
||||||
|
testMonoLegatoTriggerReattacksAfterKeyUp();
|
||||||
|
testMonoLegatoTriggerHeldKeyStillRetunes();
|
||||||
|
testAllNotesOffReleasesPolyVoices();
|
||||||
|
testAllNotesOffClearsMonoHeldStack();
|
||||||
|
testPreviewCardReleaseAll();
|
||||||
|
testAllSoundsOffStopsTriggerOneShot();
|
||||||
|
testAllNotesOffStillReleasesGateVoices();
|
||||||
|
testMonoLegatoSameNoteRepressReattacks();
|
||||||
|
testMonoOutOfRangeNotesRejected();
|
||||||
|
testVoiceCountBoundsPolyphony();
|
||||||
|
testPreviewCardIsolatedFromPool();
|
||||||
|
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||||
|
|
||||||
if (g_fail == 0) {
|
if (g_fail == 0) {
|
||||||
std::printf("all sampler_core tests passed\n");
|
std::printf("all sampler_core tests passed\n");
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user