406 lines
21 KiB
C++
406 lines
21 KiB
C++
// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle:
|
|
// reloadInstrument (self-contained refs resolve + WAV decode + SampleData build), the
|
|
// safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild,
|
|
// idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the
|
|
// usage publish. Nothing here runs on the audio thread — process() only touches the
|
|
// atomics this family publishes; the atomic-pointer-swap pattern gains no virtual seam.
|
|
|
|
#include "shell/instrument/reasampler_processor.h"
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <random>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
|
|
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
|
#include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision
|
|
#include "core/instrument/map/sample_map.h" // refs resolve, buildSampleData (self-contained)
|
|
#include "core/util/file_bytes.h" // shared whole-file loader
|
|
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse)
|
|
#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam)
|
|
#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey
|
|
|
|
namespace reasampler::vst {
|
|
|
|
using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives
|
|
using namespace reasampler::wire; // assignment_request + sample_usage wire records
|
|
using capture::extractFloatFrames;
|
|
using capture::parseWavLayout;
|
|
using capture::resolveBankFile;
|
|
using capture::WavLayout;
|
|
using util::readFileBytes;
|
|
|
|
namespace {
|
|
|
|
// Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is
|
|
// materially heavier than a Varispeed voice, so a note-on past the cap is dropped rather
|
|
// than glitching. 8 is conservative pending DAW profiling; fixed regardless of the
|
|
// user-set voiceCount (1..32) so raising polyphony never multiplies shifter CPU past budget.
|
|
constexpr std::size_t kPreserveVoiceCap = 8;
|
|
|
|
// Mints a fresh publish identity (32 lowercase hex chars) for either the persisted
|
|
// instanceGuid_ or the in-memory usageNonce_. Uniqueness, not cryptographic strength, is
|
|
// the requirement — planUsagePublish resolves a collision fail-safe anyway.
|
|
std::string mintUsageInstanceGuid() {
|
|
std::random_device rd;
|
|
std::mt19937_64 gen((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
|
|
std::uniform_int_distribution<std::uint64_t> dist;
|
|
char buf[33] = {0};
|
|
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
|
|
static_cast<unsigned long long>(dist(gen)),
|
|
static_cast<unsigned long long>(dist(gen)));
|
|
return std::string(buf);
|
|
}
|
|
|
|
// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only),
|
|
// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono
|
|
// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any
|
|
// resolve/read/decode failure — the caller plays silence.
|
|
std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
|
|
const std::string& relativePath,
|
|
ChannelMode mode) {
|
|
const std::string abs = resolveBankFile(projectDir, relativePath);
|
|
if (abs.empty()) return std::nullopt;
|
|
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
|
const WavLayout layout = parseWavLayout(bytes);
|
|
if (!layout.valid) return std::nullopt;
|
|
std::vector<AudioSample> interleaved =
|
|
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
|
DecodedPcm out = decodeChannels(interleaved, layout.channelCount, mode,
|
|
static_cast<int>(layout.sampleRate));
|
|
if (out.monoFrames.empty()) return std::nullopt;
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string ReaSamplerProcessor::reloadInstrument() {
|
|
// OFF THE AUDIO THREAD. Serializes concurrent reloads (editor click + setState) so the
|
|
// retired-slot free is single-writer; never taken on the audio thread.
|
|
std::lock_guard<std::mutex> lock(reloadMutex_);
|
|
|
|
// Mint this reload's generation number first so the built instrument is stamped
|
|
// before publishing.
|
|
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
|
|
|
// 1. Self-contained resolution: the instance-owned refs table is the source of truth.
|
|
// The live bank blob, when readable, is folded in first (refreshRefsFromBank — the
|
|
// browser's copy-the-ref-in + recapture-sync mechanism), but its absence changes
|
|
// nothing below — a project restored before PROJEXTSTATE parses (or with the
|
|
// extension absent) resolves + plays from the persisted refs.
|
|
const std::string selId = selectedSampleId();
|
|
const InstrumentParams params = instrumentParams();
|
|
const std::vector<std::string> ids = referencedSampleIds(selId);
|
|
SampleRefs refs;
|
|
{
|
|
std::optional<std::string> banksJson =
|
|
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
|
std::lock_guard<std::mutex> rl(refsMutex_);
|
|
if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids);
|
|
// The LOAD path never prunes the owned table: dropping entries on a transient bank
|
|
// miss could destroy the owned intrinsics of the previous selection — the ONE copy
|
|
// that survives with the extension absent. Hygiene lives at the PERSIST boundary
|
|
// (getState filters via retainRefs to what the instance plays).
|
|
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
|
|
}
|
|
const std::string projectDir = bridge_.activeProjectDir();
|
|
// Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
|
|
// capture's own channel count below, before the decode.
|
|
ChannelMode mode = channelMode();
|
|
// Snapshot the voice-system parameters once — baked into the built engine's
|
|
// construction (immutable config; a later change rebuilds).
|
|
int builtVoiceCount = kDefaultVoiceCount;
|
|
VoiceMode builtVoiceMode = VoiceMode::Poly;
|
|
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
|
|
{
|
|
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
|
|
builtVoiceCount = voiceCount_;
|
|
builtVoiceMode = voiceMode_;
|
|
builtMonoTrigger = monoTrigger_;
|
|
}
|
|
|
|
std::string resolvedId;
|
|
std::unique_ptr<LoadedInstrument> built;
|
|
SampleData sample;
|
|
bool havePlayable = false;
|
|
|
|
// 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
|
|
// repitched from its effective root. No first-sample fallback: an empty selection
|
|
// (or one with no ref) resolves to nothing, so an un-picked instrument stays silent
|
|
// rather than auto-playing sample #1. A missing/unreadable WAV is the same defined
|
|
// no-play — no crash, no retry loop.
|
|
if (const SelectedSample* sel = findRef(refs, selId)) {
|
|
// Auto-default: channelModeFor computes the mode from the loaded capture's channel
|
|
// count (always 2 for extension captures; mono only for ingest-imported mono files).
|
|
// An unknown count (0) or explicit user choice keeps the mode.
|
|
{
|
|
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
|
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
|
channelModeExplicit_);
|
|
mode = channelMode_;
|
|
}
|
|
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel->relativePath, mode);
|
|
if (pcm) {
|
|
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
|
|
havePlayable = sample.playable();
|
|
if (havePlayable) {
|
|
// Point the built snapshot at the instance's ONE live block and seed it from
|
|
// the very PlayParams the voices latch, so an untouched knob folds to the same
|
|
// frames the build resolved and a note-on with a live block sounds identical
|
|
// to one without.
|
|
sample.live = &liveParams_;
|
|
liveParams_.publish(instrument::engine::foldLive(sample.play));
|
|
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
|
|
resolvedId = selId; // the concrete pick that resolved
|
|
}
|
|
}
|
|
}
|
|
|
|
if (havePlayable) {
|
|
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
|
|
// pre-sized here so process()-time note-on never allocates. Floored at 2 so a
|
|
// valid window is always a real ring, covering a pathological host rate <= 0 too.
|
|
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
|
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
|
if (preserveWindow < 2) preserveWindow = 2;
|
|
built = std::make_unique<LoadedInstrument>(
|
|
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
|
|
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
|
}
|
|
|
|
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
|
|
// header). A null `built` (no ref / unreadable WAV) installs silence while any
|
|
// displaced tails still ring out via the drain.
|
|
publishBuiltLocked(std::move(built));
|
|
|
|
// 4. Publish this instance's held captures so the extension's prune can never reclaim
|
|
// them. Regardless of decode success: the holds are the refs the instance retains
|
|
// (its play-set), not what decoded — a transiently unreadable WAV stays protected.
|
|
publishUsage(refs, ids);
|
|
return resolvedId;
|
|
}
|
|
|
|
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
|
|
const std::vector<std::string>& ids) {
|
|
if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do
|
|
|
|
UsageRecord mine;
|
|
mine.trackGuid = bridge_.currentTrackGuid();
|
|
for (const std::string& id : ids) {
|
|
if (const SelectedSample* ref = findRef(refs, id)) {
|
|
if (!ref->relativePath.empty()) {
|
|
mine.holds.push_back(UsageHold{id, ref->relativePath});
|
|
}
|
|
}
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(usageMutex_);
|
|
// A never-published instance with nothing held writes nothing (no key litter); once an
|
|
// identity exists, empties do publish (releasing protected holds).
|
|
if (instanceGuid_.empty() && mine.holds.empty()) return;
|
|
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
|
|
// The per-lifetime owner nonce (UsageRecord.ownerNonce) lets planUsagePublish prove
|
|
// "exactly this incarnation wrote the key" — a same-track sibling's byte-identical hold
|
|
// set can never pass as ours, so siblings always union rather than clean-replace.
|
|
if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid();
|
|
mine.ownerNonce = usageNonce_;
|
|
|
|
const std::optional<std::string> existing =
|
|
bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_));
|
|
const UsagePublishPlan plan = planUsagePublish(existing, mine);
|
|
if (plan.remint) {
|
|
// Cloned onto another track (FX copy / track duplication): take a fresh identity;
|
|
// the abandoned old record dies by the extension's liveness rule once its track no
|
|
// longer hosts an instance.
|
|
instanceGuid_ = mintUsageInstanceGuid();
|
|
} else if (plan.skipWrite) {
|
|
return; // idle tick, or a union that adds nothing — no ext-state churn
|
|
}
|
|
bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire);
|
|
}
|
|
|
|
void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> built) {
|
|
// REQUIRES reloadMutex_ held. Shared by reloadInstrument and rebuildVoiceEngine — the
|
|
// one safety-critical swap dance (see the header's drain-slot proof).
|
|
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
|
graveyard_.erase(
|
|
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
|
[seen](const std::unique_ptr<LoadedInstrument>& e) {
|
|
return e->installedAt < seen;
|
|
}),
|
|
graveyard_.end());
|
|
LoadedInstrument* prev = live_.exchange(built.release());
|
|
LoadedInstrument* evicted = draining_.exchange(prev);
|
|
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
|
|
}
|
|
|
|
void ReaSamplerProcessor::rebuildVoiceEngine() {
|
|
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds
|
|
// the engine around a copy of the live instrument's already-decoded SampleData — no
|
|
// bridge, no disk — and publishes through the same drain-slot swap.
|
|
std::lock_guard<std::mutex> lock(reloadMutex_);
|
|
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
|
|
if (!cur) return; // nothing loaded: the new params bake into the next real reload.
|
|
|
|
int builtVoiceCount = kDefaultVoiceCount;
|
|
VoiceMode builtVoiceMode = VoiceMode::Poly;
|
|
MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger;
|
|
{
|
|
std::lock_guard<std::mutex> vp(voiceParamsMutex_);
|
|
builtVoiceCount = voiceCount_;
|
|
builtVoiceMode = voiceMode_;
|
|
builtMonoTrigger = monoTrigger_;
|
|
}
|
|
|
|
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
|
|
// Same Preserve-window derivation as reloadInstrument.
|
|
std::int64_t preserveWindow = static_cast<std::int64_t>(
|
|
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
|
if (preserveWindow < 2) preserveWindow = 2;
|
|
|
|
// Deep-copy the decoded sample: safe to read concurrently with process() because the
|
|
// SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
|
|
// freed.
|
|
SampleData sample = cur->sample;
|
|
auto built = std::make_unique<LoadedInstrument>(
|
|
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
|
|
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
|
publishBuiltLocked(std::move(built));
|
|
}
|
|
|
|
void ReaSamplerProcessor::retireIdleDrain() {
|
|
// Cheap early-out before the lock: 0 means "no drain, or it still sounds".
|
|
const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire);
|
|
if (idleGen == 0) return;
|
|
std::lock_guard<std::mutex> lock(reloadMutex_);
|
|
LoadedInstrument* drain = draining_.load(std::memory_order_acquire);
|
|
// Retire only if the publication names the drain currently in the slot — a stale value
|
|
// (an already-evicted, older drain) can never match the newer occupant's installedAt
|
|
// (monotone in generation), closing a mid-swap race by identity rather than timing.
|
|
if (!drain || drain->installedAt != idleGen) return;
|
|
draining_.store(nullptr, std::memory_order_release);
|
|
graveyard_.push_back(std::unique_ptr<LoadedInstrument>(drain));
|
|
// Prune what is now provably unreachable (same monotone-generation proof as
|
|
// reloadInstrument's reclaim).
|
|
const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire);
|
|
graveyard_.erase(
|
|
std::remove_if(graveyard_.begin(), graveyard_.end(),
|
|
[seen](const std::unique_ptr<LoadedInstrument>& e) {
|
|
return e->installedAt < seen;
|
|
}),
|
|
graveyard_.end());
|
|
}
|
|
|
|
bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
|
// Terminating guard for the pre-v10 legacy lift (caller has already established
|
|
// refs-empty + intent). Once concluded, the steady state is one relaxed load — no bank
|
|
// read, no parse, no reload churn.
|
|
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
|
|
const LegacyLiftDecision decision = legacyLiftDecision(
|
|
bridge_.readReasamplerExtState(kProjExtBanksKey),
|
|
referencedSampleIds(selectedSampleId()));
|
|
if (decision == LegacyLiftDecision::Stale) {
|
|
// Provably stale: give up permanently. A later bank change that re-introduces an
|
|
// id bumps the generation, and genChanged refreshes the refs without this latch.
|
|
legacyLiftConcluded_.store(true, std::memory_order_relaxed);
|
|
return false;
|
|
}
|
|
return true; // Retry (blob not readable yet) or Lift (a ref can be copied in)
|
|
}
|
|
|
|
ReaSamplerProcessor::BankSyncResult
|
|
ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
|
// Off the audio thread (editor's UI timer only). A disconnected bridge yields nullopt
|
|
// for both reads, so this no-ops cleanly.
|
|
BankSyncResult result;
|
|
|
|
// Park an idle drain snapshot in the graveyard on the same cadence that drives
|
|
// reloads, so an edited-away instrument stops costing memory as soon as tails die.
|
|
retireIdleDrain();
|
|
|
|
// --- Assignment-request consume first -------------------------------------------
|
|
// Decodes the pending assignment request (nullopt if absent/malformed), resolves its
|
|
// sampleId against the live bank blob (an unresolvable pair is dropped), then runs the
|
|
// pure consume decision against this instance's persisted marker.
|
|
std::optional<AssignmentRequest> request;
|
|
if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) {
|
|
request = decodeAssignmentRequest(*raw);
|
|
}
|
|
|
|
bool resolves = false;
|
|
if (request) {
|
|
// Resolve against the CURRENT bank blob (a fresh read, so a request whose sample
|
|
// was rolled back by an extension undo resolves to nullopt -> dropped).
|
|
if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) {
|
|
resolves = selectSample(*banksJson, request->sampleId).has_value();
|
|
}
|
|
}
|
|
|
|
// Read + conditionally write lastConsumed under one lock scope so a concurrent
|
|
// getState cannot observe a stale marker between two separate acquisitions.
|
|
std::int64_t lastConsumed = 0;
|
|
const AssignConsumeDecision decision = [&] {
|
|
std::lock_guard<std::mutex> lock(assignMarkerMutex_);
|
|
lastConsumed = lastConsumedAssignGeneration_;
|
|
const AssignConsumeDecision d =
|
|
consumeDecision(request, lastConsumed, resolves, isFocusedTarget);
|
|
// Advance the persisted marker whenever the decision consumed the request
|
|
// (applied or dropped-as-seen); a non-target instance leaves it unchanged so it
|
|
// stays eligible if focus later lands here.
|
|
if (d.consumedGeneration != lastConsumed) {
|
|
lastConsumedAssignGeneration_ = d.consumedGeneration;
|
|
}
|
|
return d;
|
|
}();
|
|
|
|
if (decision.apply) {
|
|
// Apply as this instance's own selection (the instrument updates its own state,
|
|
// never the bank); reloadInstrument below rebuilds against it. The parameter set
|
|
// carries over to the new capture — there is only one, and it governs whatever is
|
|
// loaded (the peer of the editor's Browse Load).
|
|
setSelectedSampleId(decision.sampleId);
|
|
result.applied = true;
|
|
}
|
|
|
|
// --- Bank-generation change-detection -------------------------------------------
|
|
// First poll (lastSeenBankGeneration_ == -1 sentinel) baselines without a reload —
|
|
// setState already loaded from owned refs, so a redundant reload on open would only
|
|
// churn. A later generation change (recapture/ingest/remove/undo) drives the reload.
|
|
std::int64_t currentGen = kBankGenerationAbsent;
|
|
if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) {
|
|
currentGen = parseBankGeneration(*rawGen);
|
|
}
|
|
const bool firstPoll = (lastSeenBankGeneration_ < 0);
|
|
const bool genChanged =
|
|
!firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen);
|
|
lastSeenBankGeneration_ = currentGen;
|
|
|
|
// Legacy lift (pre-v10 blob): restored state carries intent but no owned refs (old
|
|
// blobs had no path table). Reload on this tick until reloadInstrument folds the bank
|
|
// blob into the refs (after which this never fires again — the next save is
|
|
// self-contained). legacyLiftShouldRun concludes permanently once the bank parses and
|
|
// no referenced id resolves — a migration convenience only, never a playback
|
|
// dependency (a v10 blob plays from its refs with no poll at all).
|
|
bool legacyLift = false;
|
|
if (!genChanged && !result.applied && sampleRefs().empty()) {
|
|
legacyLift = !selectedSampleId().empty() && legacyLiftShouldRun();
|
|
}
|
|
|
|
if (genChanged || result.applied || legacyLift) {
|
|
reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play
|
|
// Reported distinctly from an applied assignment so the editor re-snapshots its
|
|
// bank view; a legacy lift counts only when it actually landed an instrument.
|
|
result.reloaded =
|
|
genChanged ||
|
|
(legacyLift && live_.load(std::memory_order_acquire) != nullptr);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
} // namespace reasampler::vst
|