From 885b7f29a2c27ecf6395f85c0a018ba9a8155cc7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 21:29:30 -0400 Subject: [PATCH] Phase S voices: user-set polyphony, mono stack (retrig|legato), isolated preview card, idle-drain retirement; unity bypass preview-only (v7) --- src/vst/reasampler_editor.cpp | 131 ++++++++++ src/vst/reasampler_editor.h | 8 + src/vst/reasampler_processor.cpp | 154 ++++++++++-- src/vst/reasampler_processor.h | 83 +++++-- src/vst/sample_map.cpp | 36 ++- src/vst/sample_map.h | 39 ++- src/vst/sampler_core.cpp | 166 ++++++++++++- src/vst/sampler_core.h | 135 ++++++++++- tests/test_sample_map.cpp | 122 ++++++++++ tests/test_sampler_core.cpp | 400 ++++++++++++++++++++++++++++--- 10 files changed, 1176 insertions(+), 98 deletions(-) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index 231015b..32d7664 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -6,6 +6,7 @@ #include #include +#include // snprintf (Phase S voice-count readout) #include #include #include @@ -63,6 +64,7 @@ constexpr UINT kSyncTimerIntervalMs = 500; constexpr int kTitleHeight = 26; constexpr int kHeroWaveformHeight = 150; // the enlarged Sample-face hero (was a 72px strip) 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 kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons @@ -161,6 +163,9 @@ void ReaSamplerEditor::refreshFromBank() { selectedId_ = processor_->selectedSampleId(); map_ = processor_->performanceMap(); channelMode_ = processor_->channelMode(); + voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot + voiceMode_ = processor_->voiceMode(); + monoTrigger_ = processor_->monoTrigger(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; // Drop a filter that names a bank no longer present. if (!activeFilterBankId_.empty()) { @@ -728,6 +733,7 @@ struct SampleBands { Rect hero; // the hero waveform + S-VIEW-3 envelope overlay Rect velCurve; // the S-VIEW-10 velocity-curve editor box (empty when suppressed) 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) }; 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)); b.cluster = Rect{0, y, w, 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}; return b; } @@ -868,6 +880,43 @@ ChannelToggleRects channelToggleRects(const Rect& area) { 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. // 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 @@ -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); } + // --- 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) -------------------- paintControls(bmp, bands.control, zone); } @@ -1873,6 +1971,39 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { 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 // (grab / Alt-delete / add-at-cursor), so materialize the one-zone site first (the // mirror of the control strip's ensureSampleZone path). diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index cf64810..73d57fb 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -347,6 +347,14 @@ private: PerformanceMap map_; // the opt-in zones (empty = no zones) 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) --- View view_ = View::kSample; // default face is the loaded-sample home (S-VIEW-1) std::string activeFilterBankId_; // "" = All; else a bank id from banks_ diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index e147081..8cd228e 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -33,17 +33,12 @@ namespace reasampler::vst { namespace { -// Tier-0 fixed instrument shape (Tier 2 makes these editable). A gentle amp envelope so -// notes neither click on nor cut off abruptly; sustain at unity (velocity does the -// dynamics), a short release for a natural tail. Times are in seconds, converted to -// frames against the live sample rate at build time. -constexpr std::size_t kMaxVoices = 16; - // S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is -// materially heavier than a Varispeed voice. Below the Varispeed polyphony bound so a chord of -// Preserve notes stays within the RT budget; a Preserve note-on past the cap is dropped rather +// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather // than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice -// cost — see the handoff CPU note. 8 is a conservative half of kMaxVoices pending DAW profiling. +// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the +// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays +// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. constexpr std::size_t kPreserveVoiceCap = 8; // Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on @@ -219,6 +214,15 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { std::lock_guard lock(previewMutex_); previewVelocity_ = cs.previewVelocity; } + // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, + // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the + // reload below so the rebuilt engine is born with the saved polyphony/mode. + { + std::lock_guard lock(voiceParamsMutex_); + voiceCount_ = cs.voiceCount; + voiceMode_ = cs.voiceMode; + monoTrigger_ = cs.monoTrigger; + } // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; @@ -240,6 +244,13 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker } state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity + { + // Phase S: persist the voice-system parameters (component state v7). + std::lock_guard lock(voiceParamsMutex_); + state_out.voiceCount = voiceCount_; + state_out.voiceMode = voiceMode_; + state_out.monoTrigger = monoTrigger_; + } const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), @@ -288,6 +299,55 @@ void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { previewVelocity_ = velocity; } +int ReaSamplerProcessor::voiceCount() { + std::lock_guard 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 lock(voiceParamsMutex_); + if (voiceCount_ == count) return; // no-op: don't churn a rebuild + voiceCount_ = count; + } + // Rebuild the engine OFF-thread through the drain-slot swap (the FA1 machinery): the + // displaced instrument keeps rendering its ringing tails, so a polyphony change never + // cuts a sounding note. Same contract for the mode/trigger setters below. + reloadFromBank(); +} + +VoiceMode ReaSamplerProcessor::voiceMode() { + std::lock_guard lock(voiceParamsMutex_); + return voiceMode_; +} + +void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { + { + std::lock_guard lock(voiceParamsMutex_); + if (voiceMode_ == mode) return; + voiceMode_ = mode; + } + reloadFromBank(); +} + +MonoTrigger ReaSamplerProcessor::monoTrigger() { + std::lock_guard lock(voiceParamsMutex_); + return monoTrigger_; +} + +void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { + { + std::lock_guard lock(voiceParamsMutex_); + if (monoTrigger_ == trigger) return; + monoTrigger_ = trigger; + } + reloadFromBank(); +} + void ReaSamplerProcessor::previewNoteOn(int note) { if (note < 0) note = 0; if (note > 127) note = 127; @@ -375,6 +435,17 @@ std::string ReaSamplerProcessor::reloadFromBank() { // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). // Read once under its mutex, off the audio thread, before the decode loop. const ChannelMode mode = channelMode(); + // Phase S: snapshot the voice-system parameters once — they are baked into the built + // engine's construction (the engine's config is immutable; a later change rebuilds). + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } std::string resolvedId; std::unique_ptr built; @@ -440,8 +511,8 @@ std::string ReaSamplerProcessor::reloadFromBank() { kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); if (preserveWindow < 2) preserveWindow = 2; built = std::make_unique( - std::move(km), kMaxVoices, gen, kPreserveVoiceCap, - preserveWindow); + std::move(km), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); } } @@ -473,6 +544,33 @@ std::string ReaSamplerProcessor::reloadFromBank() { return resolvedId; } +void ReaSamplerProcessor::retireIdleDrain() { + // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or + // it still sounds" — the common case costs one relaxed load and no mutex. + const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); + if (idleGen == 0) return; + std::lock_guard 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(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& e) { + return e->installedAt < seen; + }), + graveyard_.end()); +} + ReaSamplerProcessor::BankSyncResult ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call @@ -480,6 +578,11 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. BankSyncResult result; + // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer + // cadence that drives reloads — an edited-away instrument stops costing memory as soon + // as its tails die instead of squatting in the drain slot until the next reload. + retireIdleDrain(); + // --- S8: assignment-request consume FIRST ------------------------------------- // Decode the pending assignment request (nullopt when absent/malformed). Resolve its // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when @@ -587,6 +690,15 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } processGeneration_.store(heldGen, std::memory_order_release); + // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine + // voice AND its preview card silent) by naming its OWN installedAt (0 = no drain / still + // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no + // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply + // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. + drainIdleGeneration_.store( + (drain && drain->fullyIdle()) ? drain->installedAt : 0, + std::memory_order_relaxed); + // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps // events at block granularity (no per-event sample-offset split) — audible timing is // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. @@ -616,8 +728,10 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last // one we consumed; fire it once, then latch the sequence so the same request never re-fires. - // Preview note-on/off drive the SAME voice engine as host MIDI (a preview is just a note with - // no MIDI wire) — off-thread posted, audio-thread consumed, no lock, no allocation. + // Phase S: preview note-on/off drive the dedicated PREVIEW CARD — a single voice structurally + // OUTSIDE the MIDI pool, so a full pool can never drop a preview and a preview can never + // steal a playing MIDI voice (the FA1-review isolation fix). Host MIDI routes ONLY to the + // engine (above); the card is summed alongside it in the render below. // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument // is loaded does not re-fire stale on the next instrument load. { @@ -628,7 +742,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { if (inst) { const int vel = static_cast((on >> 8) & 0xFF); const int note = static_cast(on & 0xFF); - if (vel > 0) inst->engine.noteOn(note, vel); + if (vel > 0) inst->preview.noteOn(note, vel); } } } @@ -637,11 +751,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { const std::uint16_t offSeq = static_cast(off >> 16); if (offSeq != 0 && offSeq != previewOffConsumed_) { previewOffConsumed_ = offSeq; - // Route the preview note-off to BOTH engines (mirror of the host note-off): a + // Route the preview note-off to BOTH cards (mirror of the host note-off): a // preview held across a reload — e.g. a curve edit committed mid-press — must - // release the old-snapshot voice now draining, not just the (empty) live engine. - if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); - if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); + // release the old-snapshot card now draining, not just the (fresh) live one. + if (inst) inst->preview.noteOff(static_cast(off & 0xFF)); + if (drain) drain->preview.noteOff(static_cast(off & 0xFF)); } } @@ -681,9 +795,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } if (inst) { inst->engine.render(ch0, ch1, static_cast(frames)); + inst->preview.render(ch0, ch1, static_cast(frames)); } if (drain) { drain->engine.render(ch0, ch1, static_cast(frames)); + drain->preview.render(ch0, ch1, static_cast(frames)); } // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). for (int32 ch = 2; ch < out.numChannels; ++ch) { @@ -706,9 +822,11 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; if (inst) { inst->engine.render(ch0, static_cast(frames)); + inst->preview.render(ch0, static_cast(frames)); } if (drain) { drain->engine.render(ch0, static_cast(frames)); + drain->preview.render(ch0, static_cast(frames)); } float peak = 0.f; for (int32 i = 0; i < frames; ++i) { diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index 13fc758..526390c 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -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 draining_{nullptr}; // displaced instrument still rendering its tails std::atomic reloadGeneration_{0}; // incremented by each reload (off-thread, under reloadMutex_; read atomically by process) std::atomic 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 drainIdleGeneration_{0}; std::vector> 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 diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index 961507c..dbcc2b5 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -606,6 +606,15 @@ std::vector serializeComponentState(const ComponentState& state) { // 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). 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(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 — // unlike the v1 selection blob where the id ran to end-of-stream). putU32le(out, static_cast(state.selectionId.size())); @@ -681,11 +690,15 @@ ComponentState deserializeComponentState(const std::vector& bytes, readZonesPayload(r, out.map, projectRate); 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 - // preview velocity, precede the v3 body. A non-{0,1} mode byte is treated as mono - // (conservative default) rather than rejected — a corrupt mode never silences the instance. + // v6/v7 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, + // then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated + // as mono (conservative default) rather than rejected — a corrupt mode never silences the + // instance. const std::uint8_t modeByte = r.u8(); if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; @@ -698,6 +711,21 @@ ComponentState deserializeComponentState(const std::vector& bytes, out.previewVelocity = (previewVel >= 1 && previewVel <= 127) ? previewVel : 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(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(); out.selectionId = r.str(idLen); if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 14980eb..c08d419 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -447,17 +447,23 @@ PerformanceMap deserializePerformance(const std::vector& bytes, // instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // 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-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then a 4-byte LE selection-id length + -// id bytes, then the CURRENT zones payload (identical to serializePerformance's body — its own -// self-describing version, see the ZONES-PAYLOAD block). The 1-byte preview velocity is the ONLY -// envelope-v6 addition over envelope-v5 — the envelope grew a field, the zones payload is untouched -// (a PARALLEL track owns zone-record extension under its own versioning; the two version numbers -// are independent axes). BACK-COMPAT on read (every older blob lifts to channelMode = MONO, -// lastConsumedAssignGeneration = 0, and previewVelocity = kPreviewVelocityDefault, preserving -// current behavior for already-saved instances): -// * v6 blob -> {channelMode, lastConsumedAssignGeneration, previewVelocity, selectionId, zones} direct. +// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system +// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono +// trigger (0 = Retrigger, 1 = Legato), then a 4-byte LE selection-id length + id bytes, then the +// CURRENT zones payload (identical to serializePerformance's body — its own self-describing +// version, see the ZONES-PAYLOAD block). The three voice bytes are the ONLY envelope-v7 addition +// over envelope-v6 — the envelope grew fields, the zones payload is untouched (a PARALLEL track +// owns zone-record extension under its own versioning; the two version numbers are independent +// axes — do NOT bump the zones-payload version for an envelope field). An out-of-range voice +// 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). // * 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. @@ -485,9 +491,20 @@ struct ComponentState { // of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's // chosen strike velocity across saves. Defaults to 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 // preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity. diff --git a/src/vst/sampler_core.cpp b/src/vst/sampler_core.cpp index 8031090..ca3f07d 100644 --- a/src/vst/sampler_core.cpp +++ b/src/vst/sampler_core.cpp @@ -260,7 +260,8 @@ void Voice::presizePreserveShifters(std::int64_t windowFrames) { } 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; releasing_ = false; amplitudeDone_ = false; @@ -280,16 +281,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote playMode_ = p.playMode; pitchEngine_ = p.pitchEngine; - // FA1 (preview latency): a UNITY-SHIFT Preserve voice — note at the effective root - // (baseRatio_ == 1.0, exact per keyTrackedRatio) with the pitch envelope off — is demoted to - // the Varispeed read path for this voice. At ratio 1.0 the two engines are byte-identical - // EXCEPT the OLA shifter's structural onset cost: a half-window (~25 ms at the 50 ms product - // window) delay plus a Hann fade-in, and a full-window warm() silence pass on the audio - // thread at note-on. None of that buys anything at unity (there is no shift to preserve - // duration against), so the demoted voice reads the source directly and speaks on frame one. - // The preview trigger fires at the root, so this is the preview's zero-added-latency path; - // transposed Preserve notes keep the shifter (its latency is inherent to OLA). - if (pitchEngine_ == PitchEngine::Preserve && baseRatio_ == 1.0 && !p.pitchEnv.enabled) { + // 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; } @@ -352,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. } +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() { if (!active_) return; // TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length. @@ -513,9 +526,11 @@ void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) { VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap, std::size_t preserveVoiceCap, - std::int64_t preserveWindowFrames) + std::int64_t preserveWindowFrames, + VoiceMode voiceMode, MonoTrigger monoTrigger) : voices_(maxVoices == 0 ? 1 : maxVoices), keymap_(keymap), - preserveVoiceCap_(preserveVoiceCap) { + preserveVoiceCap_(preserveVoiceCap), + voiceMode_(voiceMode), monoTrigger_(monoTrigger) { // maxVoices == 0 would mean "no polyphony at all", which cannot service a note-on; // clamp to a single voice so the engine is always usable (documented degenerate). // @@ -562,7 +577,80 @@ std::size_t VoiceEngine::allocateVoice() { 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(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) { + 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(note), + static_cast(vclamped)}; + } + + Voice& v = voices_[0]; + // LEGATO takeover: another note is sounding (active + not releasing — a releasing voice's + // note has left the stack, so a fresh phrase after release always re-attacks) AND the new + // note resolves to the SAME sample. Retune in place: pitch moves, no re-attack. + if (v.active() && !v.releasing() && 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) { + removeHeld(note); + Voice& v = voices_[0]; + // Releasing a note that is not the sounding one (a lower held note, an already-released + // note, or a note the stack overflowed past) 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) { + if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity); const ZoneResolution res = keymap_.resolve(note, velocity); if (!res.matched) return kNoVoice; // out-of-zone: defined no-play. @@ -591,6 +679,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) { } void VoiceEngine::noteOff(int note) { + if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; } // 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. std::size_t target = kNoVoice; @@ -657,4 +746,55 @@ std::size_t VoiceEngine::activeVoiceCount() const { 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::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 diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index e4df3ac..4adf04b 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -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 #include #include #include @@ -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 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 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 diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 5a73102..f5e2aa8 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1105,6 +1105,121 @@ static void testComponentStateV6TruncatedVelocity() { 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 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(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 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 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) ---------------- // // The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and @@ -1908,6 +2023,13 @@ int main() { testComponentStateV5LiftsVelocityToMid(); testComponentStateV4LiftsVelocityToMid(); testComponentStateV6TruncatedVelocity(); + testComponentStateVoiceSystemRoundTrip(); + testComponentStateVoiceDefaultsRoundTrip(); + testComponentStateVoiceCountExtremesRoundTrip(); + testComponentStateVoiceCountWriterClamps(); + testComponentStateV6LiftsVoiceDefaults(); + testComponentStateV7CorruptVoiceBytesFallBack(); + testComponentStateV7TruncatedVoiceBytes(); testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); testReconcileKeepsOnlySelectedFullRangeZone(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 4ea02bb..4663a0f 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -1292,8 +1292,8 @@ static void testPreserveGateStereoLoopComposes() { } // --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. --- -// Uses TRANSPOSED notes only: a note at the root demotes to the Varispeed path (FA1 unity -// bypass) and deliberately does not count toward the cap — see the demotion test below. +// 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() { SampleData s = dcSample(2000, 60); s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) @@ -1307,35 +1307,78 @@ static void testPreserveVoiceCap() { } // --------------------------------------------------------------------------- -// FA1 — Preserve unity bypass (preview latency) + velocity under the Preserve engine. +// 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. // --------------------------------------------------------------------------- -// A Preserve voice started at UNITY shift (note == effective root, pitch env off) must speak on -// frame ONE — the FA1 latency fix. Pre-fix, the note ran through the OLA shifter, whose warm()d -// ring delays onset by a half window (~25 ms at the product 50 ms window): frame 0 was silence. -// The demoted voice reads the source directly (bit-identical to Varispeed at ratio 1.0). -static void testPreserveUnityVoiceSpeaksImmediately() { - SampleData s = dcSample(2000, 60); +// 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 -> demoted, zero onset delay + eng.noteOn(60, 127); // at root: unity shift — NO demotion in the MIDI engine std::vector out; - eng.render(out, 4); - CHECK(approx(out[0], 1.0, 1e-6)); // the DC sample, on the very first frame + eng.render(out, 1500); + double early = 0.0; + for (std::size_t i = 0; i < 8; ++i) { + early = (std::max)(early, static_cast(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(std::fabs(out[i]))); + } + CHECK(late > 0.9); // and the ring fills to full level } -// keyTrack 0 collapses EVERY note to unity — an off-root note also demotes and speaks at once. -static void testPreserveKeyTrackZeroAlsoSpeaksImmediately() { +// 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 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) - VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512); - eng.noteOn(67, 127); - std::vector out; - eng.render(out, 4); - CHECK(approx(out[0], 1.0, 1e-6)); + PreviewCard card(km, /*preserveWindowFrames=*/512); + card.noteOn(67, 127); + std::vector 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 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(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 @@ -1364,18 +1407,17 @@ static void testPreserveTransposedVoiceKeepsOlaPath() { CHECK(late > 0.9); } -// A unity-demoted voice does NOT count toward the Preserve cap (it runs no shifter — it costs -// Varispeed CPU, not OLA CPU), so root-note notes never starve transposed Preserve polyphony. -static void testPreserveUnityVoiceDoesNotConsumeCap() { +// 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); // unity -> demoted, cap untouched - CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st genuine Preserve voice - CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap) - CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd genuine Preserve DROPPED - CHECK(eng.activeVoiceCount() == 3); // demoted + two Preserve + 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.activeVoiceCount() == 2); } // FA1 bug 3a regression, in the DAW's ACTUAL configuration: the velocity curve must drive the @@ -1449,6 +1491,283 @@ static void testZeroAdsrIsInstantSustain() { 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 out; + eng.render(out, 1); + return static_cast(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 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 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(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 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 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); +} + +// 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 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 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 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 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() { testChromaticSingleRoot(); testZonedRangesBoundaries(); @@ -1505,17 +1824,36 @@ int main() { testPreserveGateStereoLoopComposes(); testPreserveVoiceCap(); - // FA1 — Preserve unity bypass (preview latency) + velocity under Preserve. - testPreserveUnityVoiceSpeaksImmediately(); - testPreserveKeyTrackZeroAlsoSpeaksImmediately(); + // 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(); - testPreserveUnityVoiceDoesNotConsumeCap(); + testPreserveUnityVoiceCountsTowardCap(); testVelocityCurveAppliesUnderPreserve(); // S12 review fix — per-zone A/D/S/R reaches the voice envelope. testPerZoneAdsrReachesVoiceEnvelope(); testZeroAdsrIsInstantSustain(); + // Phase S — voice count, MONO mode (held stack + Retrigger/Legato), preview card. + testMonoLastNotePriorityAndFallback(); + testMonoReleaseOfLowerHeldNoteIsInaudible(); + testMonoRepressHeldNoteMovesToTop(); + testMonoRetriggerFallbackUsesOriginalVelocity(); + testMonoOutOfZoneNeverJoinsStack(); + testMonoRetriggerRestartsEnvelope(); + testMonoLegatoContinuesEnvelope(); + testMonoLegatoRetunesWithoutReadRestart(); + testMonoLegatoCrossSampleRestarts(); + testMonoLegatoAfterReleaseReattacks(); + testMonoIgnoresPreserveCap(); + testVoiceCountBoundsPolyphony(); + testPreviewCardIsolatedFromPool(); + testPreviewCardReplaceStaleOffAndOutOfZone(); + if (g_fail == 0) { std::printf("all sampler_core tests passed\n"); return 0;