Phase S voices: user-set polyphony, mono stack (retrig|legato), isolated preview card, idle-drain retirement; unity bypass preview-only (v7)

This commit is contained in:
2026-07-27 21:29:30 -04:00
parent d9321eaf3a
commit 885b7f29a2
10 changed files with 1176 additions and 98 deletions
+68 -15
View File
@@ -35,11 +35,12 @@ namespace reasampler::vst {
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
// engine that plays it. The engine holds a reference into the keymap, so the two MUST
// live and die together at a STABLE address — hence this is heap-allocated and neither
// copyable nor movable. The audio thread only ever reads it through an atomic pointer;
// it is built and destroyed off the audio thread.
// One fully-built, ready-to-play instrument snapshot: the decoded keymap, the voice
// engine that plays it, and the isolated PREVIEW CARD (Phase S) summed alongside it.
// Engine and card both hold references into the keymap, so the three MUST live and die
// together at a STABLE address — hence this is heap-allocated and neither copyable nor
// 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
// 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 {
Keymap keymap;
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
LoadedInstrument(Keymap km, std::size_t maxVoices,
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)),
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames),
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames,
voiceMode, monoTrigger),
preview(keymap, preserveWindowFrames),
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& operator=(const LoadedInstrument&) = delete;
};
@@ -186,13 +197,27 @@ public:
std::uint8_t previewVelocity();
void setPreviewVelocity(std::uint8_t velocity);
// Fire a one-shot PREVIEW note-on / note-off through the live voice engine (S-VIEW-4), 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.
// --- Phase S voice-system parameters (per-instance, persisted in component state v7) ---
// Read/written on the UI thread (the editor's voice deck) and by getState/setState; guarded
// by voiceParamsMutex_. NOT read on the audio thread — each setter rebuilds the VoiceEngine
// OFF-thread through reloadFromBank's drain-slot swap, so changing polyphony / mode / the
// retrigger toggle never cuts a ringing tail (the same path FA1 added for curve edits).
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 previewNoteOff(int note);
@@ -202,6 +227,19 @@ private:
// caller drives restartComponent when appropriate.
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();
ReaperBridge bridge_;
// --- The audio-thread handoff (S4 real-time discipline, FA1 drain slot) --
@@ -239,6 +277,11 @@ private:
std::atomic<LoadedInstrument*> draining_{nullptr}; // displaced instrument still rendering its tails
std::atomic<std::uint64_t> reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process)
std::atomic<std::uint64_t> processGeneration_{0}; // min installedAt held by process (written on audio thread, read off-thread)
// Phase S: the installedAt of the drain instrument process() last observed FULLY IDLE
// (0 = none / the current drain still sounds). Written relaxed on the audio thread each
// 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::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
@@ -284,9 +327,19 @@ private:
std::mutex previewMutex_;
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) ---------
// 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 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