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:
+129
-6
@@ -17,6 +17,7 @@
|
||||
// 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.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
@@ -35,6 +36,30 @@ namespace reasampler {
|
||||
// itself never branches on it — the mode only picks which render overload the shell drives.
|
||||
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
|
||||
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
|
||||
@@ -379,9 +404,25 @@ public:
|
||||
// 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
|
||||
// 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,
|
||||
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
|
||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||
@@ -397,11 +438,15 @@ public:
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active(). NOTE (FA1): a Preserve ZONE voice started at unity shift
|
||||
// (note == effective root, pitch env off) is demoted to Varispeed at start() — it runs no
|
||||
// shifter, speaks with zero onset delay, and deliberately does not count toward the
|
||||
// Preserve cap (it costs Varispeed CPU, not shifter CPU).
|
||||
// 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_; }
|
||||
// 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
|
||||
// audio thread (this allocates). The engine calls it once at construction so start() — which
|
||||
@@ -500,8 +545,17 @@ public:
|
||||
// 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)
|
||||
// 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,
|
||||
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
|
||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||
@@ -555,10 +609,79 @@ private:
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
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.
|
||||
// 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_;
|
||||
const Keymap& keymap_;
|
||||
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"
|
||||
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);
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user