self-contained playback — ComponentState v10 SampleRefs (path+intrinsics owned by the instance), reloadInstrument decodes bank-free, heal timer + poll-to-play removed; bank is a browser source
This commit is contained in:
+1
-1
@@ -49,7 +49,7 @@ inline constexpr const char* kProjExtGuidKey = "project_guid";
|
|||||||
// bumps on every bank-content mutation that changes what a live instance would PLAY (capture
|
// bumps on every bank-content mutation that changes what a live instance would PLAY (capture
|
||||||
// add, re-capture-in-place, sample remove, move/copy affecting banks, ingest import). The VST3
|
// add, re-capture-in-place, sample remove, move/copy affecting banks, ingest import). The VST3
|
||||||
// instrument READS it off the audio thread on a UI-timer cadence and, when the value differs
|
// instrument READS it off the audio thread on a UI-timer cadence and, when the value differs
|
||||||
// from what it last saw, calls reloadFromBank() so a recapture/ingest refreshes playing
|
// from what it last saw, calls reloadInstrument() so a recapture/ingest refreshes playing
|
||||||
// instances hands-free (the S9 change-detection trigger). WIRE-SHARED (instrument reads it);
|
// instances hands-free (the S9 change-detection trigger). WIRE-SHARED (instrument reads it);
|
||||||
// the instrument never WRITES it (the extension owns it, same read-only-over-bank rule as the
|
// the instrument never WRITES it (the extension owns it, same read-only-over-bank rule as the
|
||||||
// assignment request). Additive to the persist blob — an absent stamp reads as generation 0
|
// assignment request). Additive to the persist blob — an absent stamp reads as generation 0
|
||||||
|
|||||||
+2
-2
@@ -432,7 +432,7 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
|
|||||||
//
|
//
|
||||||
// LOAD-BEARING (CLAUDE.md): this adds ONE FX instance to the user's existing selected track.
|
// LOAD-BEARING (CLAUDE.md): this adds ONE FX instance to the user's existing selected track.
|
||||||
// It NEVER inserts a timeline item and NEVER creates a track. Persist ordering is critical —
|
// It NEVER inserts a timeline item and NEVER creates a track. Persist ordering is critical —
|
||||||
// the fresh instance's setState -> reloadFromBank reads the bank from project ext-state, so
|
// the fresh instance's setState -> reloadInstrument reads the bank from project ext-state, so
|
||||||
// the sample MUST be persisted (generation bumped when something new landed) BEFORE
|
// the sample MUST be persisted (generation bumped when something new landed) BEFORE
|
||||||
// loadInstrumentOntoTrack adds the FX, or the instance cannot resolve the sampleId.
|
// loadInstrumentOntoTrack adds the FX, or the instance cannot resolve the sampleId.
|
||||||
// Undo-wrapped: persist + FX-add + inject = one Ctrl-Z.
|
// Undo-wrapped: persist + FX-add + inject = one Ctrl-Z.
|
||||||
@@ -500,7 +500,7 @@ void doImportFromMediaExplorer() {
|
|||||||
const std::vector<std::uint8_t> preset = buildInstrumentDropPreset(r.sampleId);
|
const std::vector<std::uint8_t> preset = buildInstrumentDropPreset(r.sampleId);
|
||||||
|
|
||||||
// One undo point for the whole gesture. Persist happens INSIDE the block and BEFORE the
|
// One undo point for the whole gesture. Persist happens INSIDE the block and BEFORE the
|
||||||
// FX add so the new instance's setState -> reloadFromBank sees the just-persisted sample.
|
// FX add so the new instance's setState -> reloadInstrument sees the just-persisted sample.
|
||||||
// The generation is bumped only when something NEW landed (a dedup collapse mutated nothing,
|
// The generation is bumped only when something NEW landed (a dedup collapse mutated nothing,
|
||||||
// so it needs neither a bump nor a persist to resolve — the sample is already in ext-state).
|
// so it needs neither a bump nor a persist to resolve — the sample is already in ext-state).
|
||||||
// If saveToActiveProject() no-ops (unsaved project), close with an empty label + zero flag so
|
// If saveToActiveProject() no-ops (unsaved project), close with an empty label + zero flag so
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@
|
|||||||
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
|
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
|
||||||
//
|
//
|
||||||
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
|
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
|
||||||
// effects (reloadFromBank, setSelectedSampleId); this module owns only the yes/no maths so
|
// effects (reloadInstrument, setSelectedSampleId); this module owns only the yes/no maths so
|
||||||
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
|
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
|
||||||
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
|
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ struct AssignConsumeDecision {
|
|||||||
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
|
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
|
||||||
// advance the marker to the request's generation.
|
// advance the marker to the request's generation.
|
||||||
//
|
//
|
||||||
// The shell then: if apply, setSelectedSampleId + reloadFromBank; always persist
|
// The shell then: if apply, setSelectedSampleId + reloadInstrument; always persist
|
||||||
// consumedGeneration into component state when it advanced.
|
// consumedGeneration into component state when it advanced.
|
||||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||||
std::int64_t lastConsumed, bool resolves,
|
std::int64_t lastConsumed, bool resolves,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ HitTarget hitTest(const EditorLayout& layout, int x, int y);
|
|||||||
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows
|
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows
|
||||||
// below the title bar; clicking a row selects that sample. This is the pure geometry:
|
// below the title bar; clicking a row selects that sample. This is the pure geometry:
|
||||||
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
|
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
|
||||||
// shell draws the names and routes the click into the processor's reloadFromBank.
|
// shell draws the names and routes the click into the processor's reloadInstrument.
|
||||||
|
|
||||||
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
|
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
|
||||||
inline constexpr int kSampleRowHeight = 22;
|
inline constexpr int kSampleRowHeight = 22;
|
||||||
|
|||||||
@@ -243,11 +243,14 @@ void ReaSamplerEditor::onSyncTimer() {
|
|||||||
|
|
||||||
void ReaSamplerEditor::commitAndReload() {
|
void ReaSamplerEditor::commitAndReload() {
|
||||||
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
|
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
|
||||||
// the instrument off the audio thread (reloadFromBank bakes them into the live Keymap).
|
// the instrument off the audio thread (reloadInstrument bakes them into the live Keymap).
|
||||||
|
// pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank
|
||||||
|
// blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the
|
||||||
|
// moment the instance becomes self-contained for that sample.
|
||||||
if (!processor_) return;
|
if (!processor_) return;
|
||||||
processor_->setSelectedSampleId(selectedId_);
|
processor_->setSelectedSampleId(selectedId_);
|
||||||
processor_->setPerformanceMap(map_);
|
processor_->setPerformanceMap(map_);
|
||||||
processor_->reloadFromBank();
|
processor_->reloadInstrument();
|
||||||
// GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's
|
// GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's
|
||||||
// channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode
|
// channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode
|
||||||
// the engine actually decoded with.
|
// the engine actually decoded with.
|
||||||
@@ -273,18 +276,22 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
|||||||
SetupMarkers m;
|
SetupMarkers m;
|
||||||
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
|
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
|
||||||
// for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic
|
// for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic
|
||||||
// from the live bank blob (the same path selectSample uses); the override lives in map_.
|
// from the live bank blob (the same path selectSample uses); when that is not readable
|
||||||
|
// (extension absent / not yet parsed) the instance-OWNED ref carries the same intrinsics
|
||||||
|
// (pS fallback). The override lives in map_.
|
||||||
if (processor_) {
|
if (processor_) {
|
||||||
|
std::optional<SelectedSample> sel;
|
||||||
auto banksJson =
|
auto banksJson =
|
||||||
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||||
if (banksJson) {
|
if (banksJson) sel = selectSample(*banksJson, selectedId_);
|
||||||
if (auto sel = selectSample(*banksJson, selectedId_)) {
|
if (!sel) {
|
||||||
if (sel->loop.hasLoop) {
|
const SampleRefs refs = processor_->sampleRefs();
|
||||||
m.hasLoop = true;
|
if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r;
|
||||||
m.loopStart = sel->loop.start;
|
}
|
||||||
m.loopEnd = sel->loop.end;
|
if (sel && sel->loop.hasLoop) {
|
||||||
}
|
m.hasLoop = true;
|
||||||
}
|
m.loopStart = sel->loop.start;
|
||||||
|
m.loopEnd = sel->loop.end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The override (loop + start) on a zone for the picked id supersedes the intrinsic.
|
// The override (loop + start) on a zone for the picked id supersedes the intrinsic.
|
||||||
@@ -719,6 +726,15 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
|
|||||||
if (banksJson) {
|
if (banksJson) {
|
||||||
if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath;
|
if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath;
|
||||||
}
|
}
|
||||||
|
if (relativePath.empty()) {
|
||||||
|
// pS fallback: the bank blob is not readable (extension absent / not yet parsed)
|
||||||
|
// or the id went stale there — the instance-OWNED ref still carries the path, so
|
||||||
|
// a self-contained instance draws its loaded sound's waveform regardless.
|
||||||
|
const SampleRefs refs = processor_->sampleRefs();
|
||||||
|
if (const SelectedSample* r = findRef(refs, sampleId)) {
|
||||||
|
relativePath = r->relativePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!relativePath.empty()) {
|
if (!relativePath.empty()) {
|
||||||
const std::string projectDir = processor_->bridge().activeProjectDir();
|
const std::string projectDir = processor_->bridge().activeProjectDir();
|
||||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed
|
// drag-state machine hit-testing via the pure resolvers). Peak thumbnails are computed
|
||||||
// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached —
|
// shell-side from the decoded WAV (bank_model's Sample carries no envelope) and cached —
|
||||||
// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the
|
// the mirror of bank_panel::thumbnailFor. Every edit commits OFF the audio thread via the
|
||||||
// processor's reloadFromBank (RT path untouched).
|
// processor's reloadInstrument (RT path untouched).
|
||||||
//
|
//
|
||||||
// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks
|
// Subclasses CPluginView for the IPlugView boilerplate; overrides the attach/remove hooks
|
||||||
// to create/destroy the child window and onSize to resize it.
|
// to create/destroy the child window and onSize to resize it.
|
||||||
|
|||||||
+151
-237
@@ -22,16 +22,9 @@
|
|||||||
#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
|
#include "master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp)
|
||||||
#include "reasampler_editor.h"
|
#include "reasampler_editor.h"
|
||||||
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there)
|
||||||
#include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser
|
#include "sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained)
|
||||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||||
|
|
||||||
#ifdef _WIN32
|
|
||||||
#ifndef NOMINMAX
|
|
||||||
#define NOMINMAX
|
|
||||||
#endif
|
|
||||||
#include <windows.h> // SetTimer/KillTimer (the reopen-heal retry's main-thread timer)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
using namespace Steinberg;
|
using namespace Steinberg;
|
||||||
using namespace Steinberg::Vst;
|
using namespace Steinberg::Vst;
|
||||||
|
|
||||||
@@ -91,99 +84,16 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef _WIN32
|
|
||||||
// --- Reopen-heal retry timer (the non-editor reload trigger; see the header) -----------
|
|
||||||
// HWND-less Win32 thread timer: SetTimer(nullptr, ...) queues WM_TIMER on the CALLING
|
|
||||||
// thread's message queue and the TIMERPROC fires from its pump — REAPER's main thread,
|
|
||||||
// where every reload path already runs (setState, setActive, the editor, this timer).
|
|
||||||
// A TIMERPROC carries no user data, so a tiny id->instance registry maps a fired timer
|
|
||||||
// back to its processor. Set/kill/fire all happen on the pumping thread; the mutex is
|
|
||||||
// defensive against an exotic host threading setState from elsewhere (in which case
|
|
||||||
// SetTimer would not fire there anyway and we degrade to the pre-fix editor-open heal).
|
|
||||||
constexpr UINT kHealRetryIntervalMs = 250; // fast enough to catch the tail of project load
|
|
||||||
constexpr int kHealRetryMax = 40; // ~10 s, then stop churning (e.g. missing WAV)
|
|
||||||
|
|
||||||
std::mutex g_healRegistryMutex;
|
|
||||||
std::vector<std::pair<std::uintptr_t, ReaSamplerProcessor*>> g_healRegistry;
|
|
||||||
|
|
||||||
void CALLBACK healTimerProc(HWND, UINT, UINT_PTR id, DWORD) {
|
|
||||||
ReaSamplerProcessor* target = nullptr;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(g_healRegistryMutex);
|
|
||||||
for (const auto& entry : g_healRegistry) {
|
|
||||||
if (entry.first == static_cast<std::uintptr_t>(id)) {
|
|
||||||
target = entry.second;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!target) {
|
|
||||||
// Orphan fire (the processor disarmed/destroyed between queue and dispatch):
|
|
||||||
// stop the timer here — nobody else holds this id anymore.
|
|
||||||
KillTimer(nullptr, id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The registry lock is RELEASED before the tick: healTick -> reloadFromBank takes
|
|
||||||
// reloadMutex_ then (via arm/disarm) the registry mutex — one consistent order.
|
|
||||||
target->healTick();
|
|
||||||
}
|
|
||||||
#endif // _WIN32
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void ReaSamplerProcessor::armHealRetry() {
|
|
||||||
#ifdef _WIN32
|
|
||||||
// Main thread. Idempotent: an already-armed timer keeps its running countdown (the
|
|
||||||
// retry ticks call reloadFromBank, which calls back here on every failed rebuild).
|
|
||||||
if (healTimerId_ != 0) return;
|
|
||||||
const UINT_PTR id = SetTimer(nullptr, 0, kHealRetryIntervalMs, &healTimerProc);
|
|
||||||
if (id == 0) return; // no message pump on this thread / OS refusal: editor-open heal remains
|
|
||||||
healTimerId_ = static_cast<std::uintptr_t>(id);
|
|
||||||
healRetriesLeft_ = kHealRetryMax;
|
|
||||||
std::lock_guard<std::mutex> lock(g_healRegistryMutex);
|
|
||||||
g_healRegistry.emplace_back(healTimerId_, this);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void ReaSamplerProcessor::disarmHealRetry() {
|
|
||||||
#ifdef _WIN32
|
|
||||||
// Main thread. The common (already-disarmed) path costs one compare — this is called
|
|
||||||
// at the end of every successful reload.
|
|
||||||
if (healTimerId_ == 0) return;
|
|
||||||
KillTimer(nullptr, static_cast<UINT_PTR>(healTimerId_));
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(g_healRegistryMutex);
|
|
||||||
g_healRegistry.erase(
|
|
||||||
std::remove_if(g_healRegistry.begin(), g_healRegistry.end(),
|
|
||||||
[this](const auto& entry) { return entry.second == this; }),
|
|
||||||
g_healRegistry.end());
|
|
||||||
}
|
|
||||||
healTimerId_ = 0;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
void ReaSamplerProcessor::healTick() {
|
|
||||||
// Main thread (the heal timer's TIMERPROC). One bounded retry: reloadFromBank re-reads
|
|
||||||
// the bank over the bridge and itself disarms this timer when it builds an instrument
|
|
||||||
// (or the restored intent is gone). If it stays armed, count the budget down and give
|
|
||||||
// up at zero — a genuinely-missing WAV must not poll ext-state forever.
|
|
||||||
if (healTimerId_ == 0) return; // raced a disarm between queue and dispatch
|
|
||||||
--healRetriesLeft_;
|
|
||||||
reloadFromBank();
|
|
||||||
if (healTimerId_ != 0 && healRetriesLeft_ <= 0) disarmHealRetry();
|
|
||||||
}
|
|
||||||
|
|
||||||
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||||
// The host owns the returned reference. Cast up to the combined interface the SDK
|
// The host owns the returned reference. Cast up to the combined interface the SDK
|
||||||
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
|
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
|
||||||
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
|
return static_cast<IAudioProcessor*>(new ReaSamplerProcessor());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Out-of-line so unique_ptr<ReaSamplerEmbed> sees the complete type here. The heal-retry
|
// Out-of-line so unique_ptr<ReaSamplerEmbed> sees the complete type here.
|
||||||
// disarm is defensive (terminate already disarms per the VST3 lifecycle): it removes this
|
ReaSamplerProcessor::~ReaSamplerProcessor() = default;
|
||||||
// instance from the timer registry so a host that skips terminate can never leave a fired
|
|
||||||
// TIMERPROC holding a dangling pointer.
|
|
||||||
ReaSamplerProcessor::~ReaSamplerProcessor() { disarmHealRetry(); }
|
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) {
|
tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) {
|
||||||
// S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for
|
// S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for
|
||||||
@@ -225,11 +135,9 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
||||||
// process() is not running at terminate. Stop the reopen-heal retry timer first (its
|
// process() is not running at terminate: free the live + draining instruments and
|
||||||
// tick would reload into a dying instance), then free the live + draining instruments
|
// drain the graveyard. Take the pointers out of the atomics first so nothing else
|
||||||
// and drain the graveyard. Take the pointers out of the atomics first so nothing else
|
|
||||||
// races them.
|
// races them.
|
||||||
disarmHealRetry();
|
|
||||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||||
delete live_.exchange(nullptr);
|
delete live_.exchange(nullptr);
|
||||||
delete draining_.exchange(nullptr);
|
delete draining_.exchange(nullptr);
|
||||||
@@ -244,20 +152,16 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
|||||||
// no reload could free while active). The build/drain are off the audio thread —
|
// no reload could free while active). The build/drain are off the audio thread —
|
||||||
// setActive is a main/UI-thread call.
|
// setActive is a main/UI-thread call.
|
||||||
if (state) {
|
if (state) {
|
||||||
// NOTE: this is also the non-editor reload trigger's anchor — if this reload runs
|
// Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED
|
||||||
// before the extension's PROJEXTSTATE is parseable, reloadFromBank arms the bounded
|
// sample refs — it needs no bank read, so it plays regardless of whether the
|
||||||
// heal-retry timer itself (see the header), so a restored instance played via host
|
// extension's PROJEXTSTATE has parsed yet (or the extension exists at all).
|
||||||
// MIDI with the editor never opened still comes up sounding.
|
reloadInstrument();
|
||||||
reloadFromBank();
|
|
||||||
} else {
|
} else {
|
||||||
// An inactive instance has nothing to heal into — stop the retry; the reactivation
|
|
||||||
// reload above re-arms it if the bank is still not parseable then.
|
|
||||||
disarmHealRetry();
|
|
||||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||||
// process is guaranteed stopped: free EVERYTHING. The live instrument too — its
|
// process is guaranteed stopped: free EVERYTHING. The live instrument too — its
|
||||||
// voices are frozen mid-flight, and if it survived deactivation the reactivate
|
// voices are frozen mid-flight, and if it survived deactivation the reactivate
|
||||||
// reload would displace it into the DRAIN slot, resurrecting stale sustained
|
// reload would displace it into the DRAIN slot, resurrecting stale sustained
|
||||||
// voices as ghosts. Reactivation rebuilds from scratch (reloadFromBank above),
|
// voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above),
|
||||||
// so nothing is lost by clearing here.
|
// so nothing is lost by clearing here.
|
||||||
delete live_.exchange(nullptr);
|
delete live_.exchange(nullptr);
|
||||||
delete draining_.exchange(nullptr);
|
delete draining_.exchange(nullptr);
|
||||||
@@ -288,7 +192,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
// from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only
|
// from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only
|
||||||
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
|
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
|
||||||
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
|
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
|
||||||
// silent empty state (no first-sample fallback in reloadFromBank).
|
// silent empty state (no first-sample fallback in reloadInstrument).
|
||||||
// Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
|
// Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
|
||||||
// the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
|
// the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
|
||||||
// before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
|
// before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
|
||||||
@@ -301,7 +205,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
// editor shows is the sample the engine plays" for already-affected projects; authored
|
// editor shows is the sample the engine plays" for already-affected projects; authored
|
||||||
// Zone-view maps (any narrow key range) pass through untouched.
|
// Zone-view maps (any narrow key range) pass through untouched.
|
||||||
PerformanceMap restored = cs.map;
|
PerformanceMap restored = cs.map;
|
||||||
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadFromBank run unconditionally on load
|
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load
|
||||||
setPerformanceMap(restored);
|
setPerformanceMap(restored);
|
||||||
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||||
// stale assign_request (the user may have manually changed the selection after the assign).
|
// stale assign_request (the user may have manually changed the selection after the assign).
|
||||||
@@ -335,8 +239,17 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
|||||||
// deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks
|
// deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks
|
||||||
// it up at the next block start.
|
// it up at the next block start.
|
||||||
setMasterGainLinear(cs.masterGainLinear);
|
setMasterGainLinear(cs.masterGainLinear);
|
||||||
|
// pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the
|
||||||
|
// reload so it decodes straight from them — no bank read required to play. A pre-v10
|
||||||
|
// blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob
|
||||||
|
// becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift),
|
||||||
|
// after which the next save is self-contained.
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(refsMutex_);
|
||||||
|
sampleRefs_ = cs.sampleRefs;
|
||||||
|
}
|
||||||
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
// Rebuild from the restored state (off-thread — setState is a load-time call).
|
||||||
reloadFromBank();
|
reloadInstrument();
|
||||||
return kResultOk;
|
return kResultOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +282,13 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
|||||||
state_out.monoTrigger = monoTrigger_;
|
state_out.monoTrigger = monoTrigger_;
|
||||||
}
|
}
|
||||||
state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8)
|
state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8)
|
||||||
|
// pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to
|
||||||
|
// decode + play with no extension present. Filtered (on the snapshot copy, the member is
|
||||||
|
// untouched) to exactly what the instance currently plays, so the table cannot grow with
|
||||||
|
// browsing history.
|
||||||
|
state_out.sampleRefs = sampleRefs();
|
||||||
|
retainRefs(state_out.sampleRefs,
|
||||||
|
referencedSampleIds(state_out.selectionId, state_out.map));
|
||||||
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
|
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
|
||||||
if (!bytes.empty()) {
|
if (!bytes.empty()) {
|
||||||
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||||
@@ -398,6 +318,11 @@ void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
|
|||||||
performanceMap_ = map;
|
performanceMap_ = map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SampleRefs ReaSamplerProcessor::sampleRefs() {
|
||||||
|
std::lock_guard<std::mutex> lock(refsMutex_);
|
||||||
|
return sampleRefs_;
|
||||||
|
}
|
||||||
|
|
||||||
ChannelMode ReaSamplerProcessor::channelMode() {
|
ChannelMode ReaSamplerProcessor::channelMode() {
|
||||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||||
return channelMode_;
|
return channelMode_;
|
||||||
@@ -513,7 +438,7 @@ void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
|
|||||||
// The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no
|
// The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no
|
||||||
// restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread
|
// restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread
|
||||||
// (mono = downmix, stereo = L/R split) and the RT path just keeps rendering.
|
// (mono = downmix, stereo = L/R split) and the RT path just keeps rendering.
|
||||||
reloadFromBank();
|
reloadInstrument();
|
||||||
}
|
}
|
||||||
|
|
||||||
tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
|
tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
|
||||||
@@ -530,7 +455,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements(
|
|||||||
return kResultFalse;
|
return kResultFalse;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string ReaSamplerProcessor::reloadFromBank() {
|
std::string ReaSamplerProcessor::reloadInstrument() {
|
||||||
// OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
|
// OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
|
||||||
// the retired-slot free is single-writer. This mutex is NEVER taken on the audio
|
// the retired-slot free is single-writer. This mutex is NEVER taken on the audio
|
||||||
// thread — process() only touches the atomic.
|
// thread — process() only touches the atomic.
|
||||||
@@ -540,10 +465,27 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
// with it before publishing. Under reloadMutex_ no other reload races here.
|
// with it before publishing. Under reloadMutex_ no other reload races here.
|
||||||
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
|
||||||
// 1. Read the live bank + resolve the project dir over the bridge (allocates,
|
// 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of
|
||||||
// calls REAPER — fine here, off-thread).
|
// truth for what to decode. The live bank blob, WHEN readable, is folded into the
|
||||||
std::optional<std::string> banksJson =
|
// table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in
|
||||||
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
// mechanism and the S9 recapture sync in one — but its absence changes NOTHING
|
||||||
|
// below: a project restored before the extension's PROJEXTSTATE parses (or with
|
||||||
|
// the extension absent entirely) resolves + plays from the persisted refs. The
|
||||||
|
// project dir comes from REAPER itself (EnumProjects), not from the extension.
|
||||||
|
const std::string selId = selectedSampleId();
|
||||||
|
const PerformanceMap map = performanceMap();
|
||||||
|
const std::vector<std::string> ids = referencedSampleIds(selId, map);
|
||||||
|
SampleRefs refs;
|
||||||
|
{
|
||||||
|
std::optional<std::string> banksJson =
|
||||||
|
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
||||||
|
std::lock_guard<std::mutex> rl(refsMutex_);
|
||||||
|
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
|
||||||
|
// Hygiene: the owned table tracks exactly what the instance currently plays, so a
|
||||||
|
// de-referenced sample's entry drops here (never grows with browsing history).
|
||||||
|
retainRefs(sampleRefs_, ids);
|
||||||
|
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
|
||||||
|
}
|
||||||
const std::string projectDir = bridge_.activeProjectDir();
|
const std::string projectDir = bridge_.activeProjectDir();
|
||||||
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
|
// The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel).
|
||||||
// Read once under its mutex, off the audio thread, before the decode loop. The single-
|
// Read once under its mutex, off the audio thread, before the decode loop. The single-
|
||||||
@@ -563,115 +505,93 @@ std::string ReaSamplerProcessor::reloadFromBank() {
|
|||||||
|
|
||||||
std::string resolvedId;
|
std::string resolvedId;
|
||||||
std::unique_ptr<LoadedInstrument> built;
|
std::unique_ptr<LoadedInstrument> built;
|
||||||
|
Keymap km;
|
||||||
|
bool haveKeymap = false;
|
||||||
|
|
||||||
if (banksJson) {
|
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
|
||||||
// 2. Tier 1 first: if the instrument's performance map is non-empty, resolve its
|
// zones against the OWNED refs (an id with no ref drops cleanly), decode each
|
||||||
// zones against the live bank (STALE ids drop cleanly), decode each zone's WAV
|
// zone's WAV off-thread, and build the ZONED keymap. Each surviving zone plays
|
||||||
// off-thread, and build the ZONED keymap. Each surviving zone plays its bank
|
// its sample repitched from its effective root note (override > ref intrinsic >
|
||||||
// sample repitched from its effective root note (override > bank intrinsic > C4).
|
// C4). A zone whose WAV fails to decode — a MISSING FILE included — is dropped
|
||||||
// A zone whose WAV fails to decode is dropped (not the whole map).
|
// (not the whole map): the defined no-play, no crash, no retry loop.
|
||||||
const PerformanceMap map = performanceMap();
|
if (!map.empty()) {
|
||||||
Keymap km;
|
const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map);
|
||||||
bool haveKeymap = false;
|
if (!resolved.zones.empty()) {
|
||||||
|
std::vector<DecodedZonePcm> decoded;
|
||||||
if (!map.empty()) {
|
std::vector<ResolvedZone> kept;
|
||||||
const ResolvedPerformance resolved = resolvePerformance(*banksJson, map);
|
decoded.reserve(resolved.zones.size());
|
||||||
if (!resolved.zones.empty()) {
|
kept.reserve(resolved.zones.size());
|
||||||
std::vector<DecodedZonePcm> decoded;
|
for (const ResolvedZone& rz : resolved.zones) {
|
||||||
std::vector<ResolvedZone> kept;
|
|
||||||
decoded.reserve(resolved.zones.size());
|
|
||||||
kept.reserve(resolved.zones.size());
|
|
||||||
for (const ResolvedZone& rz : resolved.zones) {
|
|
||||||
std::optional<DecodedZonePcm> pcm =
|
|
||||||
decodeRelative(projectDir, rz.relativePath, mode);
|
|
||||||
if (!pcm) continue; // unreadable WAV -> drop this zone
|
|
||||||
kept.push_back(rz);
|
|
||||||
decoded.push_back(std::move(*pcm));
|
|
||||||
}
|
|
||||||
km = buildZonedKeymap(kept, decoded);
|
|
||||||
haveKeymap = !km.zones.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
|
|
||||||
// deliberately-selected capture chromatically across the whole keyboard. This is
|
|
||||||
// the default face — one picked capture, repitched from its root. NO first-
|
|
||||||
// sample fallback: an EMPTY selection (or a stale id) resolves to nullopt in
|
|
||||||
// selectSample, so an un-picked instrument stays SILENT (the editor shows its
|
|
||||||
// "pick a capture" empty state) rather than auto-playing sample #1 (S10 policy
|
|
||||||
// reversal of the S4 convenience default).
|
|
||||||
if (!haveKeymap) {
|
|
||||||
std::optional<SelectedSample> sel =
|
|
||||||
selectSample(*banksJson, selectedSampleId());
|
|
||||||
if (sel) {
|
|
||||||
// GA auto-default: channelModeFor computes the mode from the loaded capture's
|
|
||||||
// REQUESTED channel count (always 2 for extension captures; mono only for
|
|
||||||
// ingest-imported mono files). An unknown count (0) or explicit user choice
|
|
||||||
// returns the current mode unchanged. Decode-only: the output bus is fixed
|
|
||||||
// stereo, so no bus work follows a flip.
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
|
||||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
|
||||||
channelModeExplicit_);
|
|
||||||
mode = channelMode_;
|
|
||||||
}
|
|
||||||
std::optional<DecodedZonePcm> pcm =
|
std::optional<DecodedZonePcm> pcm =
|
||||||
decodeRelative(projectDir, sel->relativePath, mode);
|
decodeRelative(projectDir, rz.relativePath, mode);
|
||||||
if (pcm) {
|
if (!pcm) continue; // unreadable/missing WAV -> drop this zone
|
||||||
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
kept.push_back(rz);
|
||||||
sel->rootNote, sel->loop,
|
decoded.push_back(std::move(*pcm));
|
||||||
std::move(pcm->framesR));
|
}
|
||||||
haveKeymap = true;
|
km = buildZonedKeymap(kept, decoded);
|
||||||
resolvedId = selectedSampleId(); // the concrete pick that resolved
|
haveKeymap = !km.zones.empty();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Single-capture fast path (S10): an empty performance map plays the ONE
|
||||||
|
// deliberately-selected capture chromatically across the whole keyboard, resolved
|
||||||
|
// against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a
|
||||||
|
// selection with no ref) resolves to nothing, so an un-picked instrument stays
|
||||||
|
// SILENT (the editor shows its "pick a capture" empty state) rather than
|
||||||
|
// auto-playing sample #1 (S10 policy reversal of the S4 convenience default).
|
||||||
|
if (!haveKeymap) {
|
||||||
|
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||||
|
// GA auto-default: channelModeFor computes the mode from the loaded capture's
|
||||||
|
// REQUESTED channel count (always 2 for extension captures; mono only for
|
||||||
|
// ingest-imported mono files). An unknown count (0) or explicit user choice
|
||||||
|
// returns the current mode unchanged. Decode-only: the output bus is fixed
|
||||||
|
// stereo, so no bus work follows a flip.
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||||
|
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||||
|
channelModeExplicit_);
|
||||||
|
mode = channelMode_;
|
||||||
|
}
|
||||||
|
std::optional<DecodedZonePcm> pcm =
|
||||||
|
decodeRelative(projectDir, sel->relativePath, mode);
|
||||||
|
if (pcm) {
|
||||||
|
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
||||||
|
sel->rootNote, sel->loop,
|
||||||
|
std::move(pcm->framesR));
|
||||||
|
haveKeymap = true;
|
||||||
|
resolvedId = selId; // the concrete pick that resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (haveKeymap) {
|
if (haveKeymap) {
|
||||||
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
|
// Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs).
|
||||||
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
|
// Every voice's shifter is pre-sized to this off-thread here, so process()-time
|
||||||
// note-on never allocates. Floored at 2 so a valid window is always a real ring
|
// note-on never allocates. Floored at 2 so a valid window is always a real ring
|
||||||
// (which also covers a pathological host rate <= 0 — no rate literal needed).
|
// (which also covers a pathological host rate <= 0 — no rate literal needed).
|
||||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||||
if (preserveWindow < 2) preserveWindow = 2;
|
if (preserveWindow < 2) preserveWindow = 2;
|
||||||
built = std::make_unique<LoadedInstrument>(
|
built = std::make_unique<LoadedInstrument>(
|
||||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the
|
// 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the
|
||||||
// DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices —
|
// DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices —
|
||||||
// a reload never cuts a sounding note; the next note-on plays the new state. The
|
// a reload never cuts a sounding note; the next note-on plays the new state. The
|
||||||
// instrument evicted FROM the drain slot (two reloads old) goes to the graveyard
|
// instrument evicted FROM the drain slot (two reloads old) goes to the graveyard
|
||||||
// (process may still be mid-block reading it). A null `built` (no bank / unreadable
|
// (process may still be mid-block reading it). A null `built` (no ref / unreadable
|
||||||
// WAV) installs silence while the displaced tails still ring out via the drain.
|
// WAV) installs silence while the displaced tails still ring out via the drain.
|
||||||
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
|
// `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted
|
||||||
// pointer is re-owned by the graveyard.
|
// pointer is re-owned by the graveyard.
|
||||||
//
|
|
||||||
const bool loaded = static_cast<bool>(built);
|
|
||||||
publishBuiltLocked(std::move(built));
|
publishBuiltLocked(std::move(built));
|
||||||
|
|
||||||
// Reopen-heal retry, the NON-editor trigger (see the header): this reload built NOTHING
|
|
||||||
// despite restored intent (a selection or zones) while the bridge is connected — during
|
|
||||||
// project load that almost always means the extension's PROJEXTSTATE block is not yet
|
|
||||||
// parseable — so arm the bounded main-thread retry timer. Every other outcome disarms:
|
|
||||||
// the timer only lives while there is something to heal. (Leaf-mutex order holds:
|
|
||||||
// reloadMutex_ -> registry mutex, matching the TIMERPROC which releases the registry
|
|
||||||
// before ticking into this function.)
|
|
||||||
const bool intent = !selectedSampleId().empty() || !performanceMap().empty();
|
|
||||||
if (!loaded && intent && bridge_.isConnected()) {
|
|
||||||
armHealRetry();
|
|
||||||
} else {
|
|
||||||
disarmHealRetry();
|
|
||||||
}
|
|
||||||
return resolvedId;
|
return resolvedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
||||||
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
// REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by
|
||||||
// reloadFromBank and rebuildVoiceEngine — the one safety-critical swap dance.
|
// reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance.
|
||||||
//
|
//
|
||||||
// Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is
|
// Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is
|
||||||
// the minimum installedAt process() published over the pointers it holds. Both
|
// the minimum installedAt process() published over the pointers it holds. Both
|
||||||
@@ -711,7 +631,7 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
// Same Preserve-window derivation as reloadFromBank (kPreserveWindowMs at the host rate).
|
// Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate).
|
||||||
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
||||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||||
if (preserveWindow < 2) preserveWindow = 2;
|
if (preserveWindow < 2) preserveWindow = 2;
|
||||||
@@ -740,7 +660,7 @@ void ReaSamplerProcessor::retireIdleDrain() {
|
|||||||
draining_.store(nullptr, std::memory_order_release);
|
draining_.store(nullptr, std::memory_order_release);
|
||||||
graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
|
graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
|
||||||
// Prune what is now provably unreachable — the same monotone-generation proof as the
|
// 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
|
// reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be
|
||||||
// held by process() now or ever again. The just-parked drain frees here immediately when
|
// 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.
|
// process() has already published past it; otherwise on the next reload/retire/deactivate.
|
||||||
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
||||||
@@ -804,7 +724,7 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
|||||||
|
|
||||||
if (decision.apply) {
|
if (decision.apply) {
|
||||||
// Apply the assignment as this instance's own selection (the same path a user card-pick
|
// Apply the assignment as this instance's own selection (the same path a user card-pick
|
||||||
// takes) — the instrument updates its OWN state, never the bank. reloadFromBank below
|
// takes) — the instrument updates its OWN state, never the bank. reloadInstrument below
|
||||||
// rebuilds against the new selection, so skip a redundant reload here.
|
// rebuilds against the new selection, so skip a redundant reload here.
|
||||||
setSelectedSampleId(decision.sampleId);
|
setSelectedSampleId(decision.sampleId);
|
||||||
// Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone
|
// Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone
|
||||||
@@ -819,8 +739,9 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
|||||||
|
|
||||||
// --- S9: bank-generation change-detection -------------------------------------
|
// --- S9: bank-generation change-detection -------------------------------------
|
||||||
// Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll
|
// Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll
|
||||||
// (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — setState
|
// (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload —
|
||||||
// already loaded the current bank, so a redundant reload on open would only churn. A later
|
// setState already loaded the instrument from its OWNED refs (pS), so a redundant reload
|
||||||
|
// on open would only churn. A later
|
||||||
// generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the
|
// generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the
|
||||||
// reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced).
|
// reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced).
|
||||||
std::int64_t currentGen = kBankGenerationAbsent;
|
std::int64_t currentGen = kBankGenerationAbsent;
|
||||||
@@ -832,36 +753,29 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
|||||||
!firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
|
!firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
|
||||||
lastSeenBankGeneration_ = currentGen;
|
lastSeenBankGeneration_ = currentGen;
|
||||||
|
|
||||||
// REOPEN HEAL: the baseline-without-reload assumption above fails when the setState-time
|
// LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones)
|
||||||
// reload ran BEFORE the extension's PROJEXTSTATE was parseable during project load — the
|
// but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had
|
||||||
// bridge read came back empty, so live_ installed SILENCE while the restored state (a
|
// nothing to decode unless the bank happened to be readable already. Reload on this
|
||||||
// selection or zones) says something SHOULD be loaded. Without this, the swallowed
|
// editor tick until the lift lands: reloadInstrument folds the bank blob into the refs
|
||||||
// baseline left the instrument (preview AND host MIDI) dead until some param change
|
// when readable, after which the table is non-empty and this never fires again (the
|
||||||
// forced a reload. Detect the mismatch on the first poll and reload; a deliberately
|
// next save is then self-contained). A deliberately-empty instance has no intent and
|
||||||
// empty instance (no selection, no zones) never churns, and a load that legitimately
|
// never churns; a lift whose bank stays unreadable (or whose id went stale) retries a
|
||||||
// failed (missing WAV) costs one redundant, harmless reload on editor open.
|
// cheap null publish on the editor cadence only. This is a MIGRATION convenience for
|
||||||
bool healReload = false;
|
// old projects, NOT a playback dependency — a v10 blob plays from its refs with no
|
||||||
if (firstPoll && live_.load(std::memory_order_acquire) == nullptr) {
|
// poll at all (pS).
|
||||||
healReload = !selectedSampleId().empty() || !performanceMap().empty();
|
bool legacyLift = false;
|
||||||
|
if (!genChanged && !result.applied && sampleRefs().empty()) {
|
||||||
|
legacyLift = !selectedSampleId().empty() || !performanceMap().empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (genChanged || result.applied || healReload) {
|
if (genChanged || result.applied || legacyLift) {
|
||||||
reloadFromBank(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
|
reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard)
|
||||||
// Report the reload (S9 change or reopen heal) distinctly from an S8 apply so the
|
// Report the reload distinctly from an S8 apply so the editor re-snapshots its bank
|
||||||
// editor re-snapshots its bank view.
|
// view. A legacy lift counts only when it actually landed an instrument (otherwise
|
||||||
result.reloaded = genChanged || healReload;
|
// every retry tick would churn the editor's caches for nothing).
|
||||||
}
|
result.reloaded =
|
||||||
|
genChanged ||
|
||||||
// Heal RETRY: if the heal reload above STILL left nothing live (the first poll was
|
(legacyLift && live_.load(std::memory_order_acquire) != nullptr);
|
||||||
// itself too early — the bank blob still absent/unparseable), reset the sentinel so
|
|
||||||
// the NEXT tick re-arms the first-poll heal instead of spending it one-shot. Bounded
|
|
||||||
// by the intent check inside the heal (a deliberately-empty instance never sets
|
|
||||||
// healReload, so never re-arms). This also heals a bank whose generation counter was
|
|
||||||
// never bumped: `bankGenerationChanged` is a plain != against a counter that stays 0
|
|
||||||
// for such a bank (0 != 0 never fires), so the generation path alone could never
|
|
||||||
// recover it.
|
|
||||||
if (healReload && live_.load(std::memory_order_acquire) == nullptr) {
|
|
||||||
lastSeenBankGeneration_ = -1;
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,19 @@
|
|||||||
// state (the selected sample), and the IEditController seat so createView() can hand the
|
// state (the selected sample), and the IEditController seat so createView() can hand the
|
||||||
// host our IPlugView LICE editor.
|
// host our IPlugView LICE editor.
|
||||||
//
|
//
|
||||||
|
// SELF-CONTAINED PLAYBACK (pS architecture correction). The instance OWNS its sample: the
|
||||||
|
// component state persists, per referenced bank sample, the project-relative WAV path +
|
||||||
|
// decode intrinsics (SampleRefs), and reloadInstrument decodes straight from that table.
|
||||||
|
// The extension's bank blob is a BROWSER SOURCE that opportunistically refreshes the refs
|
||||||
|
// when readable — NEVER a runtime requirement for playback. A project restored before the
|
||||||
|
// extension's PROJEXTSTATE parses (or with the extension absent) plays on load; the old
|
||||||
|
// reopen-heal timer + poll-to-play machinery that papered over the bank dependency is gone.
|
||||||
|
//
|
||||||
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
|
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
|
||||||
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — bridge ext-state
|
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — ref resolve, WAV
|
||||||
// read, WAV decode, path resolve, keymap build, VoiceEngine construction — all happens
|
// decode, keymap build, VoiceEngine construction — all happens OFF the audio thread
|
||||||
// OFF the audio thread (reloadFromBank, driven from the main/UI thread) and is handed to
|
// (reloadInstrument, driven from the main/UI thread) and is handed to process via a
|
||||||
// process via a single atomic pointer swap. See the LoadedInstrument handoff below.
|
// single atomic pointer swap. See the LoadedInstrument handoff below.
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -131,27 +139,35 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Called by the editor (main/UI thread) when the user picks a sample, and internally
|
// Called by the editor (main/UI thread) when the user picks a sample, and internally
|
||||||
// on load. Reads the live bank over the bridge, resolves+decodes the selected WAV
|
// on load. SELF-CONTAINED (pS): resolves the selection/zones against the instance-OWNED
|
||||||
// OFF the audio thread, and publishes the built instrument to process() via an
|
// SampleRefs table, decodes each WAV OFF the audio thread, and publishes the built
|
||||||
// atomic swap. Safe to call with no bridge / no bank (leaves silence). Returns the
|
// instrument to process() via an atomic swap — NO bank read is required for playback.
|
||||||
// resolved selection id ("" if nothing was loaded) for the editor to reflect.
|
// When the live bank blob IS readable it is first folded into the refs table
|
||||||
std::string reloadFromBank();
|
// (refreshRefsFromBank), which is both the browser's copy-the-ref-in mechanism and the
|
||||||
|
// S9 live-recapture sync. A missing/unreadable WAV is the defined no-play (silence, no
|
||||||
|
// retry). Returns the resolved selection id ("" if nothing was loaded) for the editor.
|
||||||
|
std::string reloadInstrument();
|
||||||
|
|
||||||
// The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor
|
// The result of a bank-sync poll (S9/S8): what pollBankSync did this tick, so the editor
|
||||||
// can react (repaint / re-snapshot its own view) only when something actually changed.
|
// can react (repaint / re-snapshot its own view) only when something actually changed.
|
||||||
struct BankSyncResult {
|
struct BankSyncResult {
|
||||||
bool reloaded = false; // the bank generation changed -> reloadFromBank ran
|
// The bank generation changed (or a pre-v10 legacy lift landed an instrument) ->
|
||||||
|
// reloadInstrument ran and the editor should re-snapshot its bank view.
|
||||||
|
bool reloaded = false;
|
||||||
bool applied = false; // a new assignment request was applied -> selection changed
|
bool applied = false; // a new assignment request was applied -> selection changed
|
||||||
};
|
};
|
||||||
|
|
||||||
// Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF
|
// Poll the S9 bank-generation counter and the S8 assignment request over the bridge, OFF
|
||||||
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). Semantics:
|
// THE AUDIO THREAD (the editor's UI timer drives this — NEVER process()). This is an
|
||||||
// * S9: if the bank generation differs from what we last saw, call reloadFromBank() so a
|
// EDITOR/BROWSER sync path — playback never depends on it (pS). Semantics:
|
||||||
// recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
|
// * S9: if the bank generation differs from what we last saw, call reloadInstrument() so
|
||||||
|
// a recapture/ingest refreshes playback hands-free (atomic swap, glitch-free).
|
||||||
// * S8: if a NEW (generation > last consumed) assignment request names a resolvable
|
// * S8: if a NEW (generation > last consumed) assignment request names a resolvable
|
||||||
// sample AND this instance is the target (isFocusedTarget), apply it as the selection
|
// sample AND this instance is the target (isFocusedTarget), apply it as the selection
|
||||||
// and reload; an unresolvable request is DROPPED silently (marker advanced, no change);
|
// and reload; an unresolvable request is DROPPED silently (marker advanced, no change);
|
||||||
// a non-target instance neither applies nor advances its marker.
|
// a non-target instance neither applies nor advances its marker.
|
||||||
|
// * LEGACY LIFT: a pre-v10 blob restored with intent but no refs retries the (cheap)
|
||||||
|
// bank read until the blob is parseable, then reloads ONCE to copy the refs in.
|
||||||
// The consumed marker advances in component state (marked dirty via the host handler) so a
|
// The consumed marker advances in component state (marked dirty via the host handler) so a
|
||||||
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
|
// re-open does not re-apply. `isFocusedTarget` is the shell's thundering-herd policy input
|
||||||
// (the editor passes true only for the instance whose editor is open — see the handoff).
|
// (the editor passes true only for the instance whose editor is open — see the handoff).
|
||||||
@@ -162,7 +178,7 @@ public:
|
|||||||
// editor borrows it (outlives the editor).
|
// editor borrows it (outlives the editor).
|
||||||
ReaperBridge& bridge() { return bridge_; }
|
ReaperBridge& bridge() { return bridge_; }
|
||||||
|
|
||||||
// The live host sample rate latched from setupProcessing (the SAME rate reloadFromBank
|
// The live host sample rate latched from setupProcessing (the SAME rate reloadInstrument
|
||||||
// resolves seconds->frames against). The editor's S-VIEW-3 envelope overlay reads it to place
|
// resolves seconds->frames against). The editor's S-VIEW-3 envelope overlay reads it to place
|
||||||
// its wall-clock seconds on the same time base the voice engine plays them over. 0.0 before
|
// its wall-clock seconds on the same time base the voice engine plays them over. 0.0 before
|
||||||
// setupProcessing runs (the editor guards). Read on the UI thread; a plain load — sampleRate_
|
// setupProcessing runs (the editor guards). Read on the UI thread; a plain load — sampleRate_
|
||||||
@@ -177,14 +193,14 @@ public:
|
|||||||
|
|
||||||
// The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written
|
// The performance map (Tier 1: the zoned keymap the instrument owns; D-B). Read/written
|
||||||
// by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the
|
// by the editor on the UI thread; snapshotted under performanceMutex_. NEVER read on the
|
||||||
// audio thread — reloadFromBank bakes it into the LoadedInstrument's Keymap off-thread.
|
// audio thread — reloadInstrument bakes it into the LoadedInstrument's Keymap off-thread.
|
||||||
PerformanceMap performanceMap();
|
PerformanceMap performanceMap();
|
||||||
void setPerformanceMap(const PerformanceMap& map);
|
void setPerformanceMap(const PerformanceMap& map);
|
||||||
|
|
||||||
// The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread
|
// The per-instance channel mode (S7, D-E: mono | stereo). Read/written on the UI thread
|
||||||
// (the editor toggle) and read off-thread by getState/reloadFromBank; guarded by
|
// (the editor toggle) and read off-thread by getState/reloadInstrument; guarded by
|
||||||
// channelModeMutex_. NEVER read on the audio thread — process() renders against the host's
|
// channelModeMutex_. NEVER read on the audio thread — process() renders against the host's
|
||||||
// negotiated output channel count, and reloadFromBank bakes the mode into the decode.
|
// negotiated output channel count, and reloadInstrument bakes the mode into the decode.
|
||||||
// GA fix: the mode is a DECODE policy only (downmix vs L/R split). The output bus is a
|
// GA fix: the mode is a DECODE policy only (downmix vs L/R split). The output bus is a
|
||||||
// FIXED stereo bus — mono mode renders dual-mono through it (centered) — so a mode change
|
// FIXED stereo bus — mono mode renders dual-mono through it (centered) — so a mode change
|
||||||
// never renegotiates host I/O (the mono<->stereo bus flip's live pin remap was the
|
// never renegotiates host I/O (the mono<->stereo bus flip's live pin remap was the
|
||||||
@@ -240,32 +256,12 @@ public:
|
|||||||
void previewNoteOn(int note);
|
void previewNoteOn(int note);
|
||||||
void previewNoteOff(int note);
|
void previewNoteOff(int note);
|
||||||
|
|
||||||
// Reopen-heal retry tick — the target of the module-internal Win32 heal timer (see
|
// The instance-owned sample refs (pS self-contained playback): a snapshot copy for the
|
||||||
// armHealRetry below), NOT host-facing. Public only because the file-static TIMERPROC
|
// editor (waveform/loop-intrinsic fallback when the bank blob is not readable). UI
|
||||||
// in the .cpp must reach it. Main thread; re-runs reloadFromBank (which disarms the
|
// thread; guarded by refsMutex_.
|
||||||
// timer itself on success) and disarms when the bounded retry budget runs out.
|
SampleRefs sampleRefs();
|
||||||
void healTick();
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// --- Reopen-heal retry (the NON-editor reload trigger) --------------------------
|
|
||||||
// A project-restored instance with intent (a selection or zones) can come up SILENT:
|
|
||||||
// REAPER runs track-FX setState before the extension's PROJEXTSTATE block is parsed,
|
|
||||||
// so the setState-time reload reads an empty bank. The editor's WM_TIMER poll heals
|
|
||||||
// that — but only if the user opens the editor; an instance played via host MIDI with
|
|
||||||
// the editor never attached stayed silent indefinitely. Mechanism: reloadFromBank
|
|
||||||
// itself detects "built NOTHING despite restored intent, bridge connected" (off the
|
|
||||||
// audio thread — it just tried) and arms a BOUNDED, HWND-less Win32 retry timer
|
|
||||||
// (SetTimer + TIMERPROC: fires on the arming thread's message pump — REAPER's main
|
|
||||||
// thread, where every reload path already runs). Each tick re-runs reloadFromBank,
|
|
||||||
// which disarms on success or when the intent is gone; the bound stops the churn for
|
|
||||||
// an instance whose WAV is genuinely missing. A deliberately-empty instance never
|
|
||||||
// arms (no intent). process() is untouched — fully RT-safe. Main-thread only.
|
|
||||||
// No-ops on non-Windows builds (the VST target is Windows-only).
|
|
||||||
void armHealRetry();
|
|
||||||
void disarmHealRetry();
|
|
||||||
std::uintptr_t healTimerId_ = 0; // 0 = not armed (main thread only)
|
|
||||||
int healRetriesLeft_ = 0; // remaining timer-tick retries (main thread only)
|
|
||||||
|
|
||||||
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
|
// Phase S drain retirement (FA1-review Major #2): if process() has published that the
|
||||||
// CURRENT drain instrument is fully idle (every engine voice silent),
|
// CURRENT drain instrument is fully idle (every engine voice silent),
|
||||||
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
|
// move it out of the drain slot into the graveyard and prune — so an edited-away snapshot
|
||||||
@@ -283,7 +279,7 @@ private:
|
|||||||
// around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
|
// around a COPY of the LIVE instrument's already-decoded Keymap — no bridge read, no
|
||||||
// filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot
|
// filesystem, no WAV re-decode — and publish through the same tail-preserving drain-slot
|
||||||
// swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the
|
// swap as a full reload. A polyphony/mode/trigger change touches no audio data, so the
|
||||||
// full reloadFromBank (which re-decodes every zone WAV from disk on the UI thread) was
|
// full reloadInstrument (which re-decodes every zone WAV from disk on the UI thread) was
|
||||||
// pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe:
|
// pure waste — a visible UI stall on a many-zone instrument. Copying the keymap is safe:
|
||||||
// it is immutable after construction and, under reloadMutex_, the live instrument can
|
// it is immutable after construction and, under reloadMutex_, the live instrument can
|
||||||
// neither be swapped nor freed while we read it. When nothing is loaded this is a no-op —
|
// neither be swapped nor freed while we read it. When nothing is loaded this is a no-op —
|
||||||
@@ -293,7 +289,7 @@ private:
|
|||||||
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
|
// Publish `built` (null = install silence) into live_: prune the graveyard by the last
|
||||||
// process()-published generation, swap `built` into live_, displace the previous live into
|
// process()-published generation, swap `built` into live_, displace the previous live into
|
||||||
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
|
// the drain slot, and park the drain-evicted instrument in the graveyard. REQUIRES
|
||||||
// reloadMutex_ held — factored out so reloadFromBank and rebuildVoiceEngine share the ONE
|
// reloadMutex_ held — factored out so reloadInstrument and rebuildVoiceEngine share the ONE
|
||||||
// safety-critical swap dance (see the handoff proof below).
|
// safety-critical swap dance (see the handoff proof below).
|
||||||
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
|
||||||
|
|
||||||
@@ -303,7 +299,7 @@ private:
|
|||||||
// process() atomically loads `live_` AND `draining_` at block start and marshals/renders
|
// process() atomically loads `live_` AND `draining_` at block start and marshals/renders
|
||||||
// against them — two atomic acquires, no lock, no free on the audio thread.
|
// against them — two atomic acquires, no lock, no free on the audio thread.
|
||||||
//
|
//
|
||||||
// reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new
|
// reloadInstrument() (off-thread, serialized by reloadMutex_) builds a new
|
||||||
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
|
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
|
||||||
// NOT freed and NOT silenced: it moves into `draining_`, where process() keeps
|
// NOT freed and NOT silenced: it moves into `draining_`, where process() keeps
|
||||||
// rendering its already-sounding voices (and routes note-offs to it) so a reload —
|
// rendering its already-sounding voices (and routes note-offs to it) so a reload —
|
||||||
@@ -349,16 +345,25 @@ private:
|
|||||||
std::string selectedSampleId_;
|
std::string selectedSampleId_;
|
||||||
|
|
||||||
// The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only;
|
// The performance map (Tier 1: the instrument's owned zoned keymap). Off-thread only;
|
||||||
// guarded against a getState/editor race. NOT read on the audio thread — reloadFromBank
|
// guarded against a getState/editor race. NOT read on the audio thread — reloadInstrument
|
||||||
// bakes it into the LoadedInstrument's Keymap under the reload lock.
|
// bakes it into the LoadedInstrument's Keymap under the reload lock.
|
||||||
std::mutex performanceMutex_;
|
std::mutex performanceMutex_;
|
||||||
PerformanceMap performanceMap_;
|
PerformanceMap performanceMap_;
|
||||||
|
|
||||||
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadFromBank);
|
// The instance-OWNED sample refs (pS self-contained playback): the path + intrinsics
|
||||||
|
// per referenced bank sample that setState restores, reloadInstrument resolves/decodes
|
||||||
|
// from, and getState persists (v10). Refreshed opportunistically from the bank blob
|
||||||
|
// when it is readable; NEVER a bank dependency for playback. Off-thread only (UI +
|
||||||
|
// load/save + reload); guarded against a getState/reload race. NOT read on the audio
|
||||||
|
// thread.
|
||||||
|
std::mutex refsMutex_;
|
||||||
|
SampleRefs sampleRefs_;
|
||||||
|
|
||||||
|
// The per-instance channel mode (S7). Off-thread only (UI + getState + reloadInstrument);
|
||||||
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
|
// guarded against a getState/editor race. Default Mono preserves pre-S7 behavior. NOT read
|
||||||
// on the audio thread — process renders against the host's negotiated output channel count.
|
// on the audio thread — process renders against the host's negotiated output channel count.
|
||||||
// channelModeExplicit_ (GA, persisted v9): false = the mode is an un-touched default that
|
// channelModeExplicit_ (GA, persisted v9): false = the mode is an un-touched default that
|
||||||
// reloadFromBank may auto-default from the loaded capture's channel count; true = the user
|
// reloadInstrument may auto-default from the loaded capture's channel count; true = the user
|
||||||
// deliberately toggled the mode (setChannelMode latches it) and it is never fought.
|
// deliberately toggled the mode (setChannelMode latches it) and it is never fought.
|
||||||
std::mutex channelModeMutex_;
|
std::mutex channelModeMutex_;
|
||||||
ChannelMode channelMode_ = ChannelMode::Mono;
|
ChannelMode channelMode_ = ChannelMode::Mono;
|
||||||
@@ -375,17 +380,12 @@ private:
|
|||||||
// The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync
|
// The bank generation this instance last SAW (S9 reader). UI/timer-thread only (pollBankSync
|
||||||
// is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a
|
// is the sole reader/writer) — no mutex needed, and it is NOT persisted. Initialized to a
|
||||||
// -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the
|
// -1 SENTINEL (no real generation can be negative — parseBankGeneration yields >= 0) so the
|
||||||
// FIRST poll after an editor open BASELINES the seen value without a redundant reload (setState
|
// FIRST poll after an editor open BASELINES the seen value without a redundant reload
|
||||||
// already loaded the current bank); a subsequent generation CHANGE then drives the reload.
|
// (setState already loaded the instrument from the OWNED refs); a subsequent generation
|
||||||
// REOPEN HEAL exception: when that setState-time load LEFT NOTHING LIVE despite restored
|
// CHANGE then drives the reload. Since pS there is NO reopen-heal here: playback never
|
||||||
// intent (a selection or zones) — the project-load ordering can run setState before the
|
// depends on this poll — a v10 blob plays from its own refs at setState time. The only
|
||||||
// extension's PROJEXTSTATE block is parseable, so the bridge read came back empty — the
|
// poll-driven reload besides a generation change is the pre-v10 LEGACY LIFT (see
|
||||||
// first poll reloads instead of silently baselining, or the instrument would stay silent
|
// pollBankSync). NOT read on the audio thread.
|
||||||
// until some param change forced a reload. If that heal reload STILL leaves nothing live,
|
|
||||||
// pollBankSync resets this back to the -1 sentinel so the next tick re-arms the heal —
|
|
||||||
// the retry is bounded by the intent check (a deliberately-empty instance never heals),
|
|
||||||
// and it also covers a bank whose generation counter was never bumped (0 != 0 can never
|
|
||||||
// fire the generation path). NOT read on the audio thread.
|
|
||||||
std::int64_t lastSeenBankGeneration_ = -1;
|
std::int64_t lastSeenBankGeneration_ = -1;
|
||||||
|
|
||||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
|
// S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the
|
||||||
@@ -397,9 +397,9 @@ private:
|
|||||||
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
|
std::uint8_t previewVelocity_ = kPreviewVelocityDefault;
|
||||||
|
|
||||||
// Phase S voice-system parameters (per-instance, persisted in component state v7). Off-thread
|
// 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
|
// only (UI voice deck + getState/setState + reloadInstrument); guarded against a getState/editor
|
||||||
// race. Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior. NOT read on the audio
|
// 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.
|
// thread — reloadInstrument bakes them into the LoadedInstrument's engine off-thread.
|
||||||
std::mutex voiceParamsMutex_;
|
std::mutex voiceParamsMutex_;
|
||||||
int voiceCount_ = kDefaultVoiceCount;
|
int voiceCount_ = kDefaultVoiceCount;
|
||||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||||
@@ -435,7 +435,7 @@ private:
|
|||||||
|
|
||||||
// Latched from setupProcessing so setActive/reload can size against it. Read
|
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||||
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
|
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
|
||||||
// before any audio, and reloadFromBank guards on it before use.
|
// before any audio, and reloadInstrument guards on it before use.
|
||||||
double sampleRate_ = 0.0;
|
double sampleRate_ = 0.0;
|
||||||
Steinberg::int32 maxBlockSize_ = 4096;
|
Steinberg::int32 maxBlockSize_ = 4096;
|
||||||
|
|
||||||
|
|||||||
+142
-23
@@ -40,6 +40,32 @@ SelectedSample distill(const Sample& s) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the
|
||||||
|
// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's
|
||||||
|
// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone.
|
||||||
|
// Shared so the two resolution paths cannot drift.
|
||||||
|
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
|
||||||
|
ResolvedZone rz;
|
||||||
|
rz.relativePath = ref.relativePath;
|
||||||
|
rz.lowNote = z.lowNote;
|
||||||
|
rz.highNote = z.highNote;
|
||||||
|
// Effective root: override beats intrinsic (distill already defaulted an empty
|
||||||
|
// intrinsic to middle C).
|
||||||
|
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
|
||||||
|
// S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state —
|
||||||
|
// carried straight through and applied at play time.
|
||||||
|
rz.keyTrack = z.keyTrack;
|
||||||
|
rz.velocityCurve = z.velocityCurve;
|
||||||
|
// Effective loop / start (S11): the per-zone override wins over the intrinsic; absent
|
||||||
|
// -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B).
|
||||||
|
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
|
||||||
|
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||||
|
// S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap
|
||||||
|
// resolves them to frames.
|
||||||
|
rz.play = z.play;
|
||||||
|
return rz;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||||
@@ -69,6 +95,62 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
|
|||||||
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
|
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Instance-owned sample references (pS self-contained playback) -------------
|
||||||
|
|
||||||
|
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
|
||||||
|
if (sampleId.empty()) return nullptr;
|
||||||
|
for (const SampleRefEntry& e : refs) {
|
||||||
|
if (e.sampleId == sampleId) return &e.ref;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||||
|
const PerformanceMap& map) {
|
||||||
|
std::vector<std::string> ids;
|
||||||
|
const auto addUnique = [&ids](const std::string& id) {
|
||||||
|
if (id.empty()) return;
|
||||||
|
for (const std::string& have : ids) {
|
||||||
|
if (have == id) return;
|
||||||
|
}
|
||||||
|
ids.push_back(id);
|
||||||
|
};
|
||||||
|
addUnique(selectionId);
|
||||||
|
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||||
|
const std::vector<std::string>& ids) {
|
||||||
|
if (ids.empty() || banksJson.empty()) return;
|
||||||
|
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||||
|
if (!book) return; // malformed blob -> no-op (the instance keeps its own copies)
|
||||||
|
for (const std::string& id : ids) {
|
||||||
|
const Sample* found = nullptr;
|
||||||
|
for (const Bank& b : book->banks()) {
|
||||||
|
if (const Sample* s = b.index.query(id)) { found = s; break; }
|
||||||
|
}
|
||||||
|
if (!found) continue; // bank miss: NEVER strips a ref — the instance owns its copy
|
||||||
|
const SelectedSample distilled = distill(*found);
|
||||||
|
bool updated = false;
|
||||||
|
for (SampleRefEntry& e : refs) {
|
||||||
|
if (e.sampleId == id) { e.ref = distilled; updated = true; break; }
|
||||||
|
}
|
||||||
|
if (!updated) refs.push_back(SampleRefEntry{id, distilled});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) {
|
||||||
|
refs.erase(std::remove_if(refs.begin(), refs.end(),
|
||||||
|
[&ids](const SampleRefEntry& e) {
|
||||||
|
for (const std::string& id : ids) {
|
||||||
|
if (id == e.sampleId) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
refs.end());
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
|
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
|
||||||
std::vector<SampleChoice> out;
|
std::vector<SampleChoice> out;
|
||||||
if (banksJson.empty()) return out;
|
if (banksJson.empty()) return out;
|
||||||
@@ -219,28 +301,24 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
|||||||
out.droppedSampleIds.push_back(z.sampleId);
|
out.droppedSampleIds.push_back(z.sampleId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ResolvedZone rz;
|
// Distill the bank Sample to the same intrinsics shape the refs table carries, then
|
||||||
rz.relativePath = found->relativePath;
|
// run the SHARED fold — so the bank path and the refs path resolve identically.
|
||||||
rz.lowNote = z.lowNote;
|
out.zones.push_back(foldZone(z, distill(*found)));
|
||||||
rz.highNote = z.highNote;
|
}
|
||||||
// Effective root: override beats bank intrinsic beats middle-C default.
|
return out;
|
||||||
rz.rootNote = z.rootOverride ? *z.rootOverride
|
}
|
||||||
: (found->rootNote ? *found->rootNote : 60);
|
|
||||||
// S-VIEW-6: the key-tracking scalar is instrument state (not a bank fact) — carried
|
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||||
// straight through to the resolved zone and applied in the repitch math at play time.
|
const PerformanceMap& map) {
|
||||||
rz.keyTrack = z.keyTrack;
|
ResolvedPerformance out;
|
||||||
// S-VIEW-9: the velocity->amp curve is likewise instrument state — carried through and
|
for (const PerformanceZone& z : map.zones) {
|
||||||
// eval'd at Voice::start to set the voice's amp gain from the note-on velocity.
|
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
|
||||||
rz.velocityCurve = z.velocityCurve;
|
out.zones.push_back(foldZone(z, *r));
|
||||||
// Effective loop / start (S11): the instrument's per-zone override wins over the
|
} else {
|
||||||
// bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is
|
// No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the
|
||||||
// never mutated — this only shapes what the core plays for THIS instance (D-B).
|
// zone cleanly + report — the same shape as the bank path's stale-id policy.
|
||||||
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
|
out.droppedSampleIds.push_back(z.sampleId);
|
||||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
}
|
||||||
// S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument
|
|
||||||
// state, not resolved against the bank); buildZonedKeymap resolves them to frames.
|
|
||||||
rz.play = z.play;
|
|
||||||
out.zones.push_back(std::move(rz));
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -641,6 +719,24 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
|||||||
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
|
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
|
||||||
// channel count); 1 = the user deliberately toggled the mode (never fought).
|
// channel count); 1 = the user deliberately toggled the mode (never fought).
|
||||||
out.push_back(state.channelModeExplicit ? 1 : 0);
|
out.push_back(state.channelModeExplicit ? 1 : 0);
|
||||||
|
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs
|
||||||
|
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see
|
||||||
|
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
|
||||||
|
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
|
||||||
|
// written), channelCount.
|
||||||
|
putU32le(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||||
|
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||||
|
putU32le(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
||||||
|
out.insert(out.end(), e.sampleId.begin(), e.sampleId.end());
|
||||||
|
putU32le(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
|
||||||
|
out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end());
|
||||||
|
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.rootNote)));
|
||||||
|
out.push_back(e.ref.loop.hasLoop ? 1 : 0);
|
||||||
|
putU64le(out, asU64(e.ref.loop.start));
|
||||||
|
putU64le(out, asU64(e.ref.loop.end));
|
||||||
|
putU32le(out,
|
||||||
|
static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
|
||||||
|
}
|
||||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||||
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||||
@@ -717,13 +813,14 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
||||||
}
|
}
|
||||||
if (version != kComponentStateVersion &&
|
if (version != kComponentStateVersion &&
|
||||||
|
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
||||||
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
||||||
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
||||||
version != kSelectionZonesModeMarkerVelV6Version) {
|
version != kSelectionZonesModeMarkerVelV6Version) {
|
||||||
return out; // unknown -> empty
|
return out; // unknown -> empty
|
||||||
}
|
}
|
||||||
|
|
||||||
// v6/v7/v8/v9 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
|
// v6..v10 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
|
// 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
|
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
|
||||||
// instance.
|
// instance.
|
||||||
@@ -774,6 +871,28 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
||||||
out.channelModeExplicit = (explicitByte == 1);
|
out.channelModeExplicit = (explicitByte == 1);
|
||||||
}
|
}
|
||||||
|
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it —
|
||||||
|
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve
|
||||||
|
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that
|
||||||
|
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway).
|
||||||
|
if (version >= kSelectionZonesRefsV10Version) {
|
||||||
|
const std::uint32_t refCount = r.u32();
|
||||||
|
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
||||||
|
SampleRefEntry e;
|
||||||
|
const std::uint32_t refIdLen = r.u32();
|
||||||
|
e.sampleId = r.str(refIdLen);
|
||||||
|
const std::uint32_t pathLen = r.u32();
|
||||||
|
e.ref.relativePath = r.str(pathLen);
|
||||||
|
e.ref.rootNote = r.i32();
|
||||||
|
e.ref.loop.hasLoop = (r.u8() != 0);
|
||||||
|
e.ref.loop.start = r.i64();
|
||||||
|
e.ref.loop.end = r.i64();
|
||||||
|
e.ref.channelCount = r.i32();
|
||||||
|
if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest
|
||||||
|
out.sampleRefs.push_back(std::move(e));
|
||||||
|
}
|
||||||
|
if (!r.ok) return out;
|
||||||
|
}
|
||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
|
|||||||
+75
-9
@@ -63,13 +63,54 @@ std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
|||||||
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
|
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
|
||||||
// instance's current mode, and whether the user has explicitly toggled the mode, return
|
// instance's current mode, and whether the user has explicitly toggled the mode, return
|
||||||
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
|
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
|
||||||
// leaves the current mode unchanged. Used by reloadFromBank in the single-capture path.
|
// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path.
|
||||||
// * isExplicit == true -> current (user's choice stands)
|
// * isExplicit == true -> current (user's choice stands)
|
||||||
// * channelCount == 0 -> current (unknown, skip)
|
// * channelCount == 0 -> current (unknown, skip)
|
||||||
// * channelCount >= 2 -> Stereo
|
// * channelCount >= 2 -> Stereo
|
||||||
// * channelCount == 1 -> Mono
|
// * channelCount == 1 -> Mono
|
||||||
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
|
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
|
||||||
|
|
||||||
|
// --- Instance-owned sample references (pS self-contained playback) -------------
|
||||||
|
//
|
||||||
|
// THE ARCHITECTURE CORRECTION: the instrument must never go silent because the extension's
|
||||||
|
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in
|
||||||
|
// its OWN component state, a small table of everything it needs to PLAY each referenced
|
||||||
|
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop,
|
||||||
|
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the
|
||||||
|
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also
|
||||||
|
// refreshes this table opportunistically when readable (recapture/root edits stay live),
|
||||||
|
// never a runtime lifeline.
|
||||||
|
//
|
||||||
|
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an
|
||||||
|
// instance that carries its ref — the instance keeps playing while the FILE exists (normal
|
||||||
|
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately
|
||||||
|
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution.
|
||||||
|
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
|
||||||
|
|
||||||
|
struct SampleRefEntry {
|
||||||
|
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
||||||
|
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
||||||
|
};
|
||||||
|
using SampleRefs = std::vector<SampleRefEntry>;
|
||||||
|
|
||||||
|
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
|
||||||
|
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
|
||||||
|
|
||||||
|
// Every bank sample id this instance plays: the selection (when set) + each zone's
|
||||||
|
// sampleId, de-duplicated, selection first then map order.
|
||||||
|
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||||
|
const PerformanceMap& map);
|
||||||
|
|
||||||
|
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same
|
||||||
|
// distillation selectSample performs). A miss leaves any existing entry untouched — the
|
||||||
|
// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
||||||
|
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||||
|
const std::vector<std::string>& ids);
|
||||||
|
|
||||||
|
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
|
||||||
|
// exactly what the instance currently plays, so it cannot grow with browsing history).
|
||||||
|
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
|
||||||
|
|
||||||
// One entry in the capture browser's card list: the stable id + display name plus the S2
|
// One entry in the capture browser's card list: the stable id + display name plus the S2
|
||||||
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
|
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
|
||||||
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
|
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
|
||||||
@@ -301,10 +342,17 @@ struct ResolvedPerformance {
|
|||||||
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
|
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
|
||||||
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
|
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
|
||||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell
|
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result (the shell
|
||||||
// then falls back to Tier-0 — see reloadFromBank).
|
// then falls back to Tier-0 — see reloadInstrument).
|
||||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||||
const PerformanceMap& map);
|
const PerformanceMap& map);
|
||||||
|
|
||||||
|
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained
|
||||||
|
// playback) — the bank-free mirror of resolvePerformance, sharing the same override-
|
||||||
|
// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref
|
||||||
|
// is dropped + reported (same stale-id shape as the bank path). Pure.
|
||||||
|
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||||
|
const PerformanceMap& map);
|
||||||
|
|
||||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
|
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
|
||||||
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
|
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
|
||||||
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
|
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
|
||||||
@@ -459,17 +507,19 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
|||||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||||
// state), never auto-playing sample #1.
|
// state), never auto-playing sample #1.
|
||||||
//
|
//
|
||||||
// Format (envelope v9): 4-byte LE version tag (== 9), then a 1-byte channel-mode field (0 = mono,
|
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
|
||||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
||||||
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
|
// 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
|
// 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 the FB1 8-byte LE master-gain LINEAR value (IEEE-754
|
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
|
||||||
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
|
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
|
||||||
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
|
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
|
||||||
// mode — see ComponentState::channelModeExplicit), then a 4-byte LE
|
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
|
||||||
|
// instance-owned path + intrinsics per referenced sample; wire shape at
|
||||||
|
// kSelectionZonesRefsV10Version below), then a 4-byte LE
|
||||||
// selection-id length + id bytes, then the CURRENT zones payload (identical to
|
// 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).
|
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||||
// The explicit flag is the ONLY envelope-v9 addition over v8 — the envelope grew a field,
|
// The refs table is the ONLY envelope-v10 addition over v9 — the envelope grew a field,
|
||||||
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
// 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
|
// 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 or a non-finite/out-of-range
|
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
|
||||||
@@ -477,10 +527,12 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
|||||||
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
|
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
|
||||||
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
|
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
|
||||||
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
|
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
|
||||||
// master gain, and channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
|
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
|
||||||
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
|
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
|
||||||
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on):
|
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
|
||||||
// * v9 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, selectionId, zones} direct.
|
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path):
|
||||||
|
// * v10 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, selectionId, zones} direct.
|
||||||
|
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
|
||||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
|
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
|
||||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
|
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
|
||||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
|
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
|
||||||
@@ -528,9 +580,23 @@ struct ComponentState {
|
|||||||
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
|
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
|
||||||
// so an older blob lifting to 1.0 plays exactly as it did.
|
// so an older blob lifting to 1.0 plays exactly as it did.
|
||||||
double masterGainLinear = 1.0;
|
double masterGainLinear = 1.0;
|
||||||
|
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
|
||||||
|
// for every bank sample this instance plays (see the SampleRefs block above). setState
|
||||||
|
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||||
|
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||||
|
// path once (then re-saves self-contained).
|
||||||
|
SampleRefs sampleRefs;
|
||||||
};
|
};
|
||||||
|
|
||||||
inline constexpr std::uint32_t kComponentStateVersion = 9;
|
inline constexpr std::uint32_t kComponentStateVersion = 10;
|
||||||
|
|
||||||
|
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
|
||||||
|
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
|
||||||
|
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
|
||||||
|
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
|
||||||
|
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
|
||||||
|
// hasLoop), 4-byte LE channelCount (two's-complement).
|
||||||
|
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||||
|
|
||||||
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
||||||
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
|
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
|
||||||
|
|||||||
+234
-1
@@ -1755,7 +1755,7 @@ static void testVelocityCurveEndToEndThroughReloadComposition() {
|
|||||||
CHECK(rp.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
CHECK(rp.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
||||||
|
|
||||||
// 3. Build the zoned keymap from decoded DC-1 PCM and play it through an engine constructed
|
// 3. Build the zoned keymap from decoded DC-1 PCM and play it through an engine constructed
|
||||||
// the way reloadFromBank constructs it (Preserve voices pre-sized to a real window).
|
// the way reloadInstrument constructs it (Preserve voices pre-sized to a real window).
|
||||||
auto steadyLevelAt = [&](int vel) -> double {
|
auto steadyLevelAt = [&](int vel) -> double {
|
||||||
DecodedZonePcm pcm;
|
DecodedZonePcm pcm;
|
||||||
pcm.monoFrames.assign(4000, 1.0f);
|
pcm.monoFrames.assign(4000, 1.0f);
|
||||||
@@ -2120,6 +2120,230 @@ static void testReconcileBrowseSequenceNoShadowing() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- pS self-contained playback: the instance-owned sample refs (envelope v10) ---------------
|
||||||
|
|
||||||
|
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
|
||||||
|
bool hasLoop = false, std::int64_t loopStart = 0,
|
||||||
|
std::int64_t loopEnd = 0, int channels = 0) {
|
||||||
|
SampleRefEntry e;
|
||||||
|
e.sampleId = id;
|
||||||
|
e.ref.relativePath = rel;
|
||||||
|
e.ref.rootNote = root;
|
||||||
|
e.ref.loop.hasLoop = hasLoop;
|
||||||
|
e.ref.loop.start = loopStart;
|
||||||
|
e.ref.loop.end = loopEnd;
|
||||||
|
e.ref.channelCount = channels;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSampleRefsRoundTrip() {
|
||||||
|
// v10: the owned refs table round-trips — path + every decode intrinsic per entry —
|
||||||
|
// with the envelope neighbours (selection, zones, explicit flag, gain) intact.
|
||||||
|
ComponentState s;
|
||||||
|
s.selectionId = "kick";
|
||||||
|
s.channelMode = ChannelMode::Stereo;
|
||||||
|
s.channelModeExplicit = true;
|
||||||
|
s.masterGainLinear = 0.5;
|
||||||
|
s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36,
|
||||||
|
/*hasLoop=*/true, 100, 500, /*channels=*/2));
|
||||||
|
s.sampleRefs.push_back(refEntry("pad", "reasampler_bank/pad.wav", 60,
|
||||||
|
/*hasLoop=*/false, 0, 0, /*channels=*/1));
|
||||||
|
s.map.zones.push_back(zone("pad", 48, 72));
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
|
CHECK(back.sampleRefs.size() == 2);
|
||||||
|
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].sampleId == "kick");
|
||||||
|
CHECK(back.sampleRefs.size() == 2 &&
|
||||||
|
back.sampleRefs[0].ref.relativePath == "reasampler_bank/kick.wav");
|
||||||
|
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.rootNote == 36);
|
||||||
|
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.loop.hasLoop &&
|
||||||
|
back.sampleRefs[0].ref.loop.start == 100 && back.sampleRefs[0].ref.loop.end == 500);
|
||||||
|
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.channelCount == 2);
|
||||||
|
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].sampleId == "pad");
|
||||||
|
CHECK(back.sampleRefs.size() == 2 && !back.sampleRefs[1].ref.loop.hasLoop);
|
||||||
|
CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].ref.channelCount == 1);
|
||||||
|
// Envelope neighbours undisturbed (the refs read consumed exactly its own bytes).
|
||||||
|
CHECK(back.selectionId == "kick");
|
||||||
|
CHECK(back.map.zones.size() == 1);
|
||||||
|
CHECK(back.channelMode == ChannelMode::Stereo && back.channelModeExplicit);
|
||||||
|
CHECK(std::fabs(back.masterGainLinear - 0.5) < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSampleRefsResolvePlayableKeymapWithoutBank() {
|
||||||
|
// THE pS architecture correction, end to end in the pure domain: a restored blob
|
||||||
|
// carrying refs resolves to a PLAYABLE keymap with NO bank blob anywhere in the path —
|
||||||
|
// deserialize -> resolvePerformanceFromRefs -> buildZonedKeymap. This is the load path
|
||||||
|
// an instance takes when the extension has not loaded (or does not exist).
|
||||||
|
ComponentState s;
|
||||||
|
s.sampleRefs.push_back(refEntry("a", "b/a.wav", 36));
|
||||||
|
s.sampleRefs.push_back(refEntry("b", "b/b.wav", 48));
|
||||||
|
s.map.zones.push_back(zone("a", 36, 47));
|
||||||
|
s.map.zones.push_back(zone("b", 48, 59, /*rootOverride=*/50));
|
||||||
|
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||||
|
const ResolvedPerformance r = resolvePerformanceFromRefs(back.sampleRefs, back.map);
|
||||||
|
CHECK(r.zones.size() == 2);
|
||||||
|
CHECK(r.droppedSampleIds.empty());
|
||||||
|
CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav");
|
||||||
|
CHECK(r.zones.size() == 2 && r.zones[0].rootNote == 36); // ref intrinsic
|
||||||
|
CHECK(r.zones.size() == 2 && r.zones[1].rootNote == 50); // zone override beats intrinsic
|
||||||
|
std::vector<DecodedZonePcm> decoded;
|
||||||
|
decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100});
|
||||||
|
decoded.push_back(DecodedZonePcm{{0.3f}, 44100});
|
||||||
|
const Keymap km = buildZonedKeymap(r.zones, decoded);
|
||||||
|
CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0);
|
||||||
|
CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testResolveFromRefsMissingRefDrops() {
|
||||||
|
// MISSING-REF = defined no-play: a zone whose id has no ref (never copied, or a pre-v10
|
||||||
|
// blob not yet lifted) drops cleanly + reports; the survivor still plays — the same
|
||||||
|
// shape as the bank path's stale-id policy. (The shell's missing-FILE no-play is the
|
||||||
|
// decode seam: an unreadable WAV yields empty PCM and the zone drops in
|
||||||
|
// buildZonedKeymap — see testBuildZonedKeymapDropsEmptyPcm.)
|
||||||
|
SampleRefs refs;
|
||||||
|
refs.push_back(refEntry("a", "b/a.wav", 36));
|
||||||
|
PerformanceMap m;
|
||||||
|
m.zones.push_back(zone("a", 0, 59));
|
||||||
|
m.zones.push_back(zone("ghost", 60, 127));
|
||||||
|
const ResolvedPerformance r = resolvePerformanceFromRefs(refs, m);
|
||||||
|
CHECK(r.zones.size() == 1);
|
||||||
|
CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav");
|
||||||
|
CHECK(r.droppedSampleIds.size() == 1);
|
||||||
|
CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testResolveFromRefsMatchesBankResolve() {
|
||||||
|
// The two resolution paths share ONE fold (foldZone): the same map resolved via the
|
||||||
|
// bank blob and via a refs table refreshed FROM that bank yields identical effective
|
||||||
|
// zones — the paths cannot drift.
|
||||||
|
Sample s1 = makeSample("a", "Pad", "b/a.wav", 40);
|
||||||
|
s1.loop = LoopPoints{200, 800};
|
||||||
|
const std::string json = bookJson({s1}, {});
|
||||||
|
PerformanceMap m;
|
||||||
|
PerformanceZone z = zone("a", 10, 90, /*rootOverride=*/72);
|
||||||
|
z.startPoint = 512;
|
||||||
|
m.zones.push_back(z);
|
||||||
|
SampleRefs refs;
|
||||||
|
refreshRefsFromBank(refs, json, referencedSampleIds("", m));
|
||||||
|
const ResolvedPerformance viaBank = resolvePerformance(json, m);
|
||||||
|
const ResolvedPerformance viaRefs = resolvePerformanceFromRefs(refs, m);
|
||||||
|
CHECK(viaBank.zones.size() == 1 && viaRefs.zones.size() == 1);
|
||||||
|
if (viaBank.zones.size() == 1 && viaRefs.zones.size() == 1) {
|
||||||
|
CHECK(viaRefs.zones[0].relativePath == viaBank.zones[0].relativePath);
|
||||||
|
CHECK(viaRefs.zones[0].rootNote == viaBank.zones[0].rootNote); // 72 (override)
|
||||||
|
CHECK(viaRefs.zones[0].loop.hasLoop == viaBank.zones[0].loop.hasLoop);
|
||||||
|
CHECK(viaRefs.zones[0].loop.start == viaBank.zones[0].loop.start); // 200 (intrinsic)
|
||||||
|
CHECK(viaRefs.zones[0].loop.end == viaBank.zones[0].loop.end);
|
||||||
|
CHECK(viaRefs.zones[0].startFrame == viaBank.zones[0].startFrame); // 512
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testComponentStateV9LiftsToEmptyRefs() {
|
||||||
|
// OLD-BLOB FALLBACK: a genuine v9 blob (no refs table) restores with an EMPTY table and
|
||||||
|
// every other field intact — the shell then lifts via the bridge-resolve path once the
|
||||||
|
// bank is readable and re-saves self-contained. Hand-built (serializeComponentState now
|
||||||
|
// emits v10, so it cannot make a v9 blob).
|
||||||
|
std::vector<std::uint8_t> v9;
|
||||||
|
v9.push_back(9); v9.push_back(0); v9.push_back(0); v9.push_back(0); // version 9
|
||||||
|
v9.push_back(0); // channel mode = mono
|
||||||
|
for (int i = 0; i < 8; ++i) v9.push_back(0); // marker = 0
|
||||||
|
v9.push_back(88); // preview velocity
|
||||||
|
v9.push_back(7); // voice count
|
||||||
|
v9.push_back(0); // voice mode = poly
|
||||||
|
v9.push_back(0); // trigger = retrigger
|
||||||
|
for (int i = 0; i < 8; ++i) v9.push_back(0); // gain double bytes...
|
||||||
|
v9[17 + 6] = 0xF0; v9[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754)
|
||||||
|
v9.push_back(1); // explicit flag = true
|
||||||
|
const std::string id = "saved";
|
||||||
|
v9.push_back(static_cast<std::uint8_t>(id.size())); v9.push_back(0); v9.push_back(0); v9.push_back(0);
|
||||||
|
v9.insert(v9.end(), id.begin(), id.end());
|
||||||
|
v9.push_back(0); v9.push_back(0); v9.push_back(0); v9.push_back(0); // zone count 0
|
||||||
|
const ComponentState back = deserializeComponentState(v9, 44100.0);
|
||||||
|
CHECK(back.sampleRefs.empty()); // pre-pS blob -> empty table (bridge-resolve lift)
|
||||||
|
CHECK(back.selectionId == "saved");
|
||||||
|
CHECK(back.channelModeExplicit);
|
||||||
|
CHECK(back.previewVelocity == 88);
|
||||||
|
CHECK(back.voiceCount == 7);
|
||||||
|
CHECK(back.masterGainLinear == 1.0);
|
||||||
|
CHECK(back.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testReferencedSampleIdsDedup() {
|
||||||
|
// Selection first, then map order, duplicates collapsed; an empty selection contributes
|
||||||
|
// nothing (no phantom "" id in the refs table).
|
||||||
|
PerformanceMap m;
|
||||||
|
m.zones.push_back(zone("a", 0, 59));
|
||||||
|
m.zones.push_back(zone("b", 60, 99));
|
||||||
|
m.zones.push_back(zone("a", 100, 127)); // duplicate id across zones
|
||||||
|
const std::vector<std::string> ids = referencedSampleIds("b", m); // selection dups a zone
|
||||||
|
CHECK(ids.size() == 2);
|
||||||
|
CHECK(ids.size() == 2 && ids[0] == "b" && ids[1] == "a");
|
||||||
|
const std::vector<std::string> noSel = referencedSampleIds("", m);
|
||||||
|
CHECK(noSel.size() == 2);
|
||||||
|
CHECK(noSel.size() == 2 && noSel[0] == "a" && noSel[1] == "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRefreshRefsFromBankUpsertAndOwnership() {
|
||||||
|
// Upsert: a resolvable id copies in (the selectSample distillation); a re-refresh after
|
||||||
|
// a bank edit UPDATES the owned copy (S9 recapture sync); a bank MISS never strips the
|
||||||
|
// owned entry (a bank deletion cannot silence a self-contained instance); an empty or
|
||||||
|
// malformed blob is a no-op.
|
||||||
|
Sample s1 = makeSample("a", "Kick", "b/a.wav", 36);
|
||||||
|
s1.channelCount = 2;
|
||||||
|
const std::string json1 = bookJson({s1}, {});
|
||||||
|
SampleRefs refs;
|
||||||
|
refreshRefsFromBank(refs, json1, {"a", "ghost"});
|
||||||
|
CHECK(refs.size() == 1); // "ghost" does not resolve -> no entry minted
|
||||||
|
CHECK(refs.size() == 1 && refs[0].sampleId == "a" && refs[0].ref.rootNote == 36);
|
||||||
|
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a.wav");
|
||||||
|
CHECK(refs.size() == 1 && refs[0].ref.channelCount == 2);
|
||||||
|
// Recapture-style bank edit: path + root changed -> the owned copy refreshes.
|
||||||
|
refreshRefsFromBank(refs, bookJson({makeSample("a", "Kick", "b/a2.wav", 40)}, {}), {"a"});
|
||||||
|
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
|
||||||
|
CHECK(refs.size() == 1 && refs[0].ref.relativePath == "b/a2.wav");
|
||||||
|
// Bank deletion: the id no longer resolves -> the OWNED copy survives untouched.
|
||||||
|
refreshRefsFromBank(refs, bookJson({makeSample("x", "Other", "b/x.wav", 60)}, {}), {"a"});
|
||||||
|
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
|
||||||
|
// Malformed / empty blobs: no-op.
|
||||||
|
refreshRefsFromBank(refs, "{garbage", {"a"});
|
||||||
|
refreshRefsFromBank(refs, "", {"a"});
|
||||||
|
CHECK(refs.size() == 1 && refs[0].ref.rootNote == 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRetainRefsFiltersToPlayedSet() {
|
||||||
|
// getState hygiene: only the entries the instance currently plays persist — the table
|
||||||
|
// cannot grow with browsing history. Order of survivors is preserved.
|
||||||
|
SampleRefs refs;
|
||||||
|
refs.push_back(refEntry("a", "b/a.wav", 36));
|
||||||
|
refs.push_back(refEntry("b", "b/b.wav", 48));
|
||||||
|
refs.push_back(refEntry("c", "b/c.wav", 60));
|
||||||
|
retainRefs(refs, {"c", "a"});
|
||||||
|
CHECK(refs.size() == 2);
|
||||||
|
CHECK(refs.size() == 2 && refs[0].sampleId == "a" && refs[1].sampleId == "c");
|
||||||
|
retainRefs(refs, {});
|
||||||
|
CHECK(refs.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSampleRefsTruncatedMidEntry() {
|
||||||
|
// A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest
|
||||||
|
// of the state empty (the selection/zones behind the cut are unreadable anyway) — the
|
||||||
|
// established truncation posture, never a throw across the host boundary.
|
||||||
|
ComponentState s;
|
||||||
|
s.selectionId = "kick";
|
||||||
|
s.sampleRefs.push_back(refEntry("kick", "b/k.wav", 36));
|
||||||
|
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
|
||||||
|
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
||||||
|
// The tail after the refs table is idLen(4) + "kick"(4) + the empty-map zones payload
|
||||||
|
// (marker 4 + version 4 + count 4) = 20 bytes; entry 2 is 43 bytes (4+3 id, 4+7 path,
|
||||||
|
// 4 root, 1+8+8 loop, 4 channels). Cutting 40 bytes lands 20 bytes into entry 2.
|
||||||
|
CHECK(bytes.size() > 40);
|
||||||
|
bytes.resize(bytes.size() - 40);
|
||||||
|
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||||
|
CHECK(back.sampleRefs.size() == 1);
|
||||||
|
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
||||||
|
CHECK(back.selectionId.empty());
|
||||||
|
CHECK(back.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testSelectByIdHit();
|
testSelectByIdHit();
|
||||||
testSelectEmptyIdIsSilence();
|
testSelectEmptyIdIsSilence();
|
||||||
@@ -2245,6 +2469,15 @@ int main() {
|
|||||||
testReconcileNoOpWhenAlreadyCoherent();
|
testReconcileNoOpWhenAlreadyCoherent();
|
||||||
testReconcileGuards();
|
testReconcileGuards();
|
||||||
testReconcileBrowseSequenceNoShadowing();
|
testReconcileBrowseSequenceNoShadowing();
|
||||||
|
testSampleRefsRoundTrip();
|
||||||
|
testSampleRefsResolvePlayableKeymapWithoutBank();
|
||||||
|
testResolveFromRefsMissingRefDrops();
|
||||||
|
testResolveFromRefsMatchesBankResolve();
|
||||||
|
testComponentStateV9LiftsToEmptyRefs();
|
||||||
|
testReferencedSampleIdsDedup();
|
||||||
|
testRefreshRefsFromBankUpsertAndOwnership();
|
||||||
|
testRetainRefsFiltersToPlayedSet();
|
||||||
|
testSampleRefsTruncatedMidEntry();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
|
||||||
return g_fail != 0;
|
return g_fail != 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user