Fix cross-rate Preserve splices, re-stamp on host-rate change, name the rate refusal

The splice crossfade now counts output frames and stops before the ring's far end (this also fixes a lap at matched rate). The search strides by the rate ratio. The editor takes its time base from its own decode. Same-rate output is pinned to hashes from before the change.
This commit is contained in:
2026-09-25 14:17:11 -04:00
parent ec60cf78c6
commit 64f314a6a4
24 changed files with 592 additions and 183 deletions
+1 -1
View File
@@ -214,7 +214,7 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source — or when the host rate is not the one the park (or a publish that landed while inactive) was resolved for, since every stage time in it and in the live block is in that rate's frames. Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no cut to ringing tails. **Activation and decoding are separate lifetimes:** `setActive(false)` parks the decoded `SampleData` and destroys the voice state (a surviving `live_` would be displaced into the drain slot and resurrect stale sustained voices), and `setActive(true)` rebuilds the voices around the parked sample through that same swap — so a host-driven cycle costs no disk read and no decode. The resume still folds the live bank blob into the refs and republishes usage (a `GetProjExtState` plus a parse each, and with no editor open the activation is the only place either happens), and hands back to the full reload when that fold moved the loaded capture's decode source. A host-rate change while inactive does NOT re-decode: every stage time in the park (or in a publish that landed while inactive) and in the live block is in that rate's frames, so the resume re-stamps those in place (`restampHostRateLocked`) and falls back to the full reload only when the new rate refuses the file's (`sourceRateSupported`). Nothing parked means nothing was decoded, which routes the activation back through the full reload too; that is also where the pre-v10 legacy lift lives. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published as relaxed atomics — per-channel peak and smallest limiter gain ACCUMULATED across every block since the UI last read, plus the latched clip (the meter Gotcha below owns why). The limiter's enable is persisted in the parameter set (params payload v15) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. The host's `restartComponent(kLatencyChanged)` is issued by `flushLatencyRestart` alone, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids. Why it is split from the commit is the Gotcha below.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
+3 -2
View File
@@ -100,8 +100,9 @@ void ReaSamplerEditor::resetParamControl(int id) {
resetDeckParam(static_cast<instrument::ui::DeckParam>(id), params_.play);
}
double ReaSamplerEditor::liveSampleRate() const {
return processor_ ? static_cast<double>(processor_->loadedSourceRate()) : 0.0;
double ReaSamplerEditor::selectedSourceRate() const {
const auto it = pcmCache_.find(selectedId_);
return it != pcmCache_.end() ? static_cast<double>(it->second.sampleRate) : 0.0;
}
double ReaSamplerEditor::previewVelocity01() const {
@@ -42,7 +42,7 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
// so at most one of the two hit-tests below is ever live for the same click — both feed the
// SAME arbitration slot below rather than either one getting its own check-order return.
const double rate = liveSampleRate();
const double rate = selectedSourceRate();
const bool nodesLive =
overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
NodeHit envNodeHit;
@@ -245,7 +245,7 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
// set. The StageEnvelope was snapshotted at grab (dragStartEnv_) so the delta is
// absolute.
const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate();
const double rate = selectedSourceRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const DragModifiers mods = dragModifiers();
+1 -1
View File
@@ -63,7 +63,7 @@ StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds
std::int64_t frames,
std::int64_t startFrame) const {
StageEnvelope env;
const double rate = liveSampleRate();
const double rate = selectedSourceRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
+8 -2
View File
@@ -107,8 +107,14 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
// The instance's own loaded state outranks bank availability (the bank is a browser
// source, not the instrument's identity) — a self-contained instance names its sound
// (refs displayName fallback) even when the bank snapshot is empty.
if (!selectedId_.empty())
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
if (!selectedId_.empty()) {
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_);
// The waveform still draws (the editor decodes on its own), so without this a
// refused capture would look loaded and simply never sound.
if (processor_->sourceRateRefused())
title += " -- " + sourceRateRefusalText() + ", not played";
title += "]";
}
else if (samples_.empty()) title += " [bank empty]";
else title += " [pick a capture]";
} else {
@@ -388,7 +388,7 @@ void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea
paintSplineOverlay(bmp, waveArea);
return;
}
const double rate = liveSampleRate();
const double rate = selectedSourceRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const std::int64_t startFrame = params_.startPoint.value_or(0);
+6 -5
View File
@@ -434,12 +434,12 @@ const ReaSamplerEditor::ChannelPcm& ReaSamplerEditor::channelPcmFor(
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
auto it = pcmCache_.find(sampleId);
if (it != pcmCache_.end()) return it->second;
if (it != pcmCache_.end()) return it->second.frames;
// Every failure path caches an empty vector so a broken/missing file is not re-decoded on
// Every failure path caches an empty entry so a broken/missing file is not re-decoded on
// every paint. Keyed by id (width-independent) — the thumbnail bins this at whatever
// width, the snap scans it directly.
std::vector<AudioSample> mono;
MonoPcm mono;
const std::string relativePath = samplePathFor(sampleId);
if (!relativePath.empty()) {
const std::string projectDir = processor_->bridge().activeProjectDir();
@@ -449,11 +449,12 @@ const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string&
if (layout.valid) {
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
mono = downmixToMono(interleaved, layout.channelCount);
mono.frames = downmixToMono(interleaved, layout.channelCount);
mono.sampleRate = static_cast<int>(layout.sampleRate);
}
}
auto ins = pcmCache_.emplace(sampleId, std::move(mono));
return ins.first->second;
return ins.first->second.frames;
}
const Envelope& ReaSamplerEditor::heroEnvelope(std::size_t bins, int laneCount) {
+6 -4
View File
@@ -128,14 +128,16 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
// The snapshot comes first: the default program's window is derived from the sound it
// carries (a Gate release, a Trigger play span), not from a constant.
std::optional<SampleData> snapshot = processor.bakeSnapshot(sampleRate);
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
ReaSamplerProcessor::BuiltCapture built = processor.bakeSnapshot(sampleRate);
if (built.rateRefused) return fail("cannot render: " + sourceRateRefusalText());
if (!built.sample) return fail("the loaded capture could not be decoded for the render");
SampleData& snapshot = *built.sample;
// The note fires at the velocity the user has been auditioning at: three velocity curves
// are live, so a sound dialed at 127 does not bake as one dialed at 40.
const Velocity velocity = Velocity::of(processor.previewVelocity());
const instrument::bake::PlannedBake planned = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, dialed.bakeHold, velocity),
resolveNote(defaultBakeProgram(snapshot, sampleRate, dialed.bakeHold, velocity),
*tempo),
sampleRate, rootNote);
if (!planned.plan) {
@@ -146,7 +148,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const instrument::bake::BakePlan& plan = *planned.plan;
const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), plan, processor.masterGainLinear(),
renderBake(std::move(snapshot), plan, processor.masterGainLinear(),
dialed.limiterEnabled);
if (audio.empty()) return fail("the offline pass produced no audio");
+51 -20
View File
@@ -13,6 +13,7 @@
#include <mutex>
#include <optional>
#include <random>
#include <string>
#include <utility>
#include <vector>
@@ -79,20 +80,26 @@ std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
// The ONE decode + build the reload and the bake snapshot share, so a baked render can
// never be built from a different fold than the one the user is hearing.
std::optional<SampleData> buildFromRef(const SelectedSample& sel,
const InstrumentParams& params,
const std::string& projectDir, ChannelMode mode,
int renderRate) {
ReaSamplerProcessor::BuiltCapture buildFromRef(const SelectedSample& sel,
const InstrumentParams& params,
const std::string& projectDir,
ChannelMode mode, int renderRate) {
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
if (!pcm) return std::nullopt;
if (!pcm) return {};
if (!sourceRateSupported(pcm->sampleRate, renderRate)) return {std::nullopt, true};
SampleData sample =
buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate);
if (!sample.playable()) return std::nullopt;
return sample;
if (!sample.playable()) return {};
return {std::move(sample), false};
}
} // namespace
std::string sourceRateRefusalText() {
return "sample rate over " + std::to_string(static_cast<int>(kMaxSourceRateRatio)) +
"x the host's";
}
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.
@@ -127,6 +134,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
std::unique_ptr<LoadedInstrument> built;
SampleData sample;
bool havePlayable = false;
bool rateRefused = 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
@@ -144,9 +152,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
channelModeExplicit_);
mode = channelMode_;
}
if (std::optional<SampleData> decoded =
buildFromRef(*sel, params, projectDir, mode, hostRenderRate())) {
sample = std::move(*decoded);
BuiltCapture decoded = buildFromRef(*sel, params, projectDir, mode, hostRenderRate());
rateRefused = decoded.rateRefused;
if (decoded.sample) {
sample = std::move(*decoded.sample);
havePlayable = true;
// Point the built snapshot at the instance's ONE engine-facing block and seed the
// MODEL block from the very PlayParams the voices latch, so an untouched knob folds
@@ -161,7 +170,6 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// build's rate — or, on the first-ever build, against 0, where secondsToFrames
// collapses every automated envelope stage to zero frames.
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed);
loadedSourceRate_.store(sample.sampleRate, std::memory_order_relaxed);
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
@@ -172,6 +180,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
}
}
sourceRateRefused_.store(rateRefused, std::memory_order_relaxed);
if (havePlayable) built = buildInstrumentLocked(std::move(sample));
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
@@ -192,9 +201,9 @@ std::string ReaSamplerProcessor::usageInstanceGuid() {
return instanceGuid_;
}
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot(int renderRate) {
ReaSamplerProcessor::BuiltCapture ReaSamplerProcessor::bakeSnapshot(int renderRate) {
const std::string selId = selectedSampleId();
if (selId.empty()) return std::nullopt;
if (selId.empty()) return {};
const InstrumentParams params = instrumentParams();
SampleRefs refs;
{
@@ -202,7 +211,7 @@ std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot(int renderRate) {
refs = sampleRefs_;
}
const SelectedSample* sel = findRef(refs, selId);
if (!sel) return std::nullopt;
if (!sel) return {};
// No live block is attached: the bake renders the stored parameter set, which is what
// every knob has already written, rather than whatever the audio thread is observing.
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode(), renderRate);
@@ -357,17 +366,24 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
// voice state the deactivate destroyed is rebuilt, the PCM it parked is reused as-is.
std::lock_guard<std::mutex> lock(reloadMutex_);
// Every frame count in a built sample — and in the live block published beside it — was
// resolved for the host rate of its build. setupProcessing may have changed that rate while
// inactive (or a setState load ran before the host reported one), and neither a park nor a
// publish can be re-used across it: the full reload re-resolves both.
// resolved for the host rate of its build, and setupProcessing may have changed that rate
// while inactive (or a setState load ran before the host reported one). Only the resolve
// depends on it, never the decode, so both paths below re-stamp rather than re-read a file
// that may since have gone missing.
const int renderRate = hostRenderRate();
// A publish that landed while inactive (setState's reload, a bake's adopt) IS the
// activation state — its voices have never rendered, and it has already superseded the
// park. Rebuilding here would displace a correct instrument into the drain slot.
// park. Rebuilding here at an unchanged rate would displace a correct instrument into the
// drain slot; at a changed one the rebuild is the re-stamp, and there is nothing sounding
// to displace.
if (const LoadedInstrument* cur = live_.load(std::memory_order_acquire)) {
return cur->sample.renderRate == renderRate;
if (cur->sample.renderRate == renderRate) return true;
SampleData restamped = cur->sample;
if (!restampHostRateLocked(restamped, renderRate)) return false;
publishBuiltLocked(buildInstrumentLocked(std::move(restamped)));
return true;
}
if (!dormantSample_ || dormantSample_->renderRate != renderRate) return false;
if (!dormantSample_) return false;
// The activation is still where a bank change made with NO EDITOR OPEN is picked up:
// pollBankSync, the only other route to either of the two calls below, runs off the
@@ -396,6 +412,7 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
// and refreshing the refs without re-decoding would leave the table naming one file while
// the voices play another. Hand back to the full reload, which decodes the new one.
if (sourceMoved) return false;
if (!restampHostRateLocked(*dormantSample_, renderRate)) return false;
// MOVED, not copied: the park exists for this one handoff, and keeping it would hold a
// second copy of the PCM for the whole active lifetime. Disengaged BEFORE the build so a
@@ -410,6 +427,20 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
return true;
}
bool ReaSamplerProcessor::restampHostRateLocked(SampleData& sample, int renderRate) {
if (sample.renderRate == renderRate) return true;
if (!sourceRateSupported(sample.sampleRate, renderRate)) return false;
sample.renderRate = renderRate;
// The model's stored seconds, which is exactly what reloadInstrument resolves (via
// resolveCapture) — so the re-stamp and a disk reload land on the same frames.
sample.play = resolvePlay(instrumentParams().play, sample.outputRate());
// BEFORE the caller's publish, for the reason reloadInstrument gives at the same store.
builtSampleRate_.store(sample.outputRate(), std::memory_order_relaxed);
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
return true;
}
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);
+13 -5
View File
@@ -409,10 +409,13 @@ private:
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
int effectiveRoot() const;
// The loaded capture's OWN rate — the time base that maps its waveform frames to the
// seconds a staged envelope stores — or 0 when nothing has loaded (caller guards). Not the
// The selected capture's OWN rate — the time base that maps its waveform frames to the
// seconds a staged envelope stores — or 0 when nothing is decoded (caller guards). Not the
// host's: the voice plays a source frame at the source's rate whatever the host runs at.
double liveSampleRate() const;
// Read from the same decode the frames came from (monoPcmFor, which every caller has already
// run for its frame count), so the two cannot disagree — the processor's own build can
// fail while the waveform still draws.
double selectedSourceRate() const;
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
double previewVelocity01() const;
// The bake Hold division as a 0..1 knob position, via the pure bake_hold map.
@@ -591,8 +594,13 @@ private:
std::unordered_map<std::string, Envelope> thumbCache_;
// Decoded mono-PCM cache, keyed by id (width-independent). Feeds the waveform envelope
// binning + zero-crossing snap. Cleared alongside thumbCache_ on refresh.
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
// binning + zero-crossing snap, and carries the file's own rate beside the frames it is the
// time base of (selectedSourceRate). Cleared alongside thumbCache_ on refresh.
struct MonoPcm {
std::vector<AudioSample> frames;
int sampleRate = 0; // 0 = nothing decoded
};
std::unordered_map<std::string, MonoPcm> pcmCache_;
// The single-slot interleaved-PCM cache behind channelPcmFor (see its note on why this
// is not keyed into pcmCache_). Cleared alongside pcmCache_ on refresh.
+33 -14
View File
@@ -37,6 +37,10 @@ using instrument::map::kPreviewVelocityDefault;
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
// What the editor and the bake print for a refused file rate (sourceRateSupported), so the
// limit is spelled in one place.
std::string sourceRateRefusalText();
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
public:
ReaSamplerProcessor() = default;
@@ -150,8 +154,15 @@ public:
// the rate the bake's plan renders at, passed in so the two cannot disagree. Rebuilt
// rather than copied off the live snapshot because a tier-3 live edit leaves that
// snapshot's own play params stale on purpose — copying it would bake the pre-drag values.
// nullopt when nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O).
std::optional<SampleData> bakeSnapshot(int renderRate);
// Empty when nothing is loaded, the WAV is unreadable, or its rate is refused (then
// `rateRefused` says so). Off the audio thread (file I/O).
struct BuiltCapture {
std::optional<SampleData> sample;
// The file decoded, but sourceRateSupported (play_params.h) refuses its rate against
// the host's — a distinct failure the user is told about, not an unreadable file.
bool rateRefused = false;
};
BuiltCapture bakeSnapshot(int renderRate);
// Adopt a landed bake in ONE act: the new capture becomes this instance's ref and
// selection, the parameter set and master gain go neutral, and a single reload
@@ -186,9 +197,10 @@ public:
int hostRenderRate() const {
return sampleRate_ > 0.0 ? static_cast<int>(sampleRate_ + 0.5) : 0;
}
// The loaded capture's OWN rate — the time base of its waveform frames, which is what the
// editor's staged-envelope overlay maps seconds onto. 0 before anything has loaded.
int loadedSourceRate() const { return loadedSourceRate_.load(std::memory_order_relaxed); }
// True while the selected capture is silent because the last reload refused its file's
// rate (BuiltCapture::rateRefused). The editor's title band names it
// (sourceRateRefusalText).
bool sourceRateRefused() const { return sourceRateRefused_.load(std::memory_order_relaxed); }
// The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
// Empty id -> silence, no first-sample fallback.
std::string selectedSampleId();
@@ -337,13 +349,20 @@ private:
// The reactivation half of the activation/decode lifetime split (see dormantSample_):
// folds the live bank blob into the refs, rebuilds the voice state around the parked
// sample, publishes it through the same drain-slot swap, and republishes usage. True also
// when a publish landed while inactive, which needs no rebuild. False when there is nothing
// to activate from, when the park or that publish was resolved for a host rate that has
// since changed, or when that fold moved what the park was decoded from — the caller then
// falls back to a full reload, which decodes the new file and is also where the pre-v10
// legacy lift lives. Off the audio thread only.
// when a publish landed while inactive, which needs no rebuild. A park or publish resolved
// for a host rate that has since changed is re-stamped in place (restampHostRateLocked)
// rather than re-decoded. False when there is nothing to activate from, when the new host
// rate refuses the file's, or when that fold moved what the park was decoded from — the
// caller then falls back to a full reload, which decodes the new file and is also where the
// pre-v10 legacy lift lives. Off the audio thread only.
bool resumeDormantInstrument();
// Re-resolves `sample` for a new host rate without touching its PCM: the render rate, the
// stage times (from the model's stored seconds, exactly as a reload resolves them) and the
// live seed published beside it. False, leaving `sample` untouched, when the new rate
// refuses the file's. Requires reloadMutex_ held.
bool restampHostRateLocked(SampleData& sample, int renderRate);
// Builds a playable snapshot around `sample` at the current voice-system parameters and
// host rate, stamped with a fresh generation. Requires reloadMutex_ held; the ONE
// construction site shared by the reload, the voice-param rebuild and the reactivation.
@@ -425,10 +444,10 @@ private:
// ONE BLOCK, ONE RATE: one block sits above every snapshot, so a drain voice reads frame
// counts resolved at whatever this last became — harmless while it is the HOST rate for
// every capture. The one residue is a build made before the host reported a rate, which
// resolves at the capture's own and is replaced by the activation's reload.
// resolves at the capture's own and is re-stamped by the activation.
std::atomic<int> builtSampleRate_{0};
// The loaded capture's own decode rate; see loadedSourceRate().
std::atomic<int> loadedSourceRate_{0};
// See sourceRateRefused(). Written by reloadInstrument only.
std::atomic<bool> sourceRateRefused_{false};
// --- The audio-thread handoff (drain slot) ---
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
@@ -563,7 +582,7 @@ private:
std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired
// Latched from setupProcessing; 0.0 = not reported yet (a build then renders at the
// capture's own rate until resumeDormantInstrument's rate check sends it back to a reload).
// capture's own rate until resumeDormantInstrument re-stamps it for the reported one).
double sampleRate_ = 0.0;
Steinberg::int32 maxBlockSize_ = 4096;