Play cross-rate captures at their own pitch and length

The voice now converts the file's rate to the host's at note-on instead of assuming they match. Stage times resolve at the host rate, the filter solves at it, and the bake follows. Same-rate renders are unchanged bit for bit.
This commit is contained in:
2026-09-25 13:44:39 -04:00
parent 9ff34b0a9e
commit ec60cf78c6
25 changed files with 749 additions and 137 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. 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 — 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_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.
+1 -1
View File
@@ -101,7 +101,7 @@ void ReaSamplerEditor::resetParamControl(int id) {
}
double ReaSamplerEditor::liveSampleRate() const {
return processor_ ? processor_->sampleRate() : 0.0;
return processor_ ? static_cast<double>(processor_->loadedSourceRate()) : 0.0;
}
double ReaSamplerEditor::previewVelocity01() const {
+2 -2
View File
@@ -114,7 +114,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
if (!sourceEntry) return fail("the loaded capture has no resolvable file");
const SelectedSample* source = &sourceEntry->ref;
const int sampleRate = static_cast<int>(processor.sampleRate());
const int sampleRate = processor.hostRenderRate();
if (sampleRate <= 0) return fail("the host has not reported a sample rate yet");
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
@@ -128,7 +128,7 @@ 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();
std::optional<SampleData> snapshot = processor.bakeSnapshot(sampleRate);
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
// The note fires at the velocity the user has been auditioning at: three velocity curves
+21 -16
View File
@@ -81,10 +81,12 @@ std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
// 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) {
const std::string& projectDir, ChannelMode mode,
int renderRate) {
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
if (!pcm) return std::nullopt;
SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm));
SampleData sample =
buildSampleData(resolveCapture(sel, params), std::move(*pcm), renderRate);
if (!sample.playable()) return std::nullopt;
return sample;
}
@@ -143,7 +145,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
mode = channelMode_;
}
if (std::optional<SampleData> decoded =
buildFromRef(*sel, params, projectDir, mode)) {
buildFromRef(*sel, params, projectDir, mode, hostRenderRate())) {
sample = std::move(*decoded);
havePlayable = true;
// Point the built snapshot at the instance's ONE engine-facing block and seed the
@@ -156,9 +158,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// BEFORE the publish: the publish is what makes the audio thread re-merge, and the
// merge resolves every held automation stage time against this rate. Stored after,
// a merge in the window between them would resolve them against the previous
// capture's rate — or, on the first-ever build, against 0, where secondsToFrames
// build's rate — or, on the first-ever build, against 0, where secondsToFrames
// collapses every automated envelope stage to zero frames.
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
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.
@@ -189,7 +192,7 @@ std::string ReaSamplerProcessor::usageInstanceGuid() {
return instanceGuid_;
}
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot(int renderRate) {
const std::string selId = selectedSampleId();
if (selId.empty()) return std::nullopt;
const InstrumentParams params = instrumentParams();
@@ -202,7 +205,7 @@ std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
if (!sel) return std::nullopt;
// 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());
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode(), renderRate);
}
void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
@@ -328,13 +331,8 @@ ReaSamplerProcessor::buildInstrumentLocked(SampleData sample) {
builtVoiceMode = voiceMode_;
builtMonoTrigger = monoTrigger_;
}
// 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. Re-derived per build, so a
// reactivation after the host changed its rate gets a ring sized for the new one.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
// Pre-sized here so process()-time note-on never allocates.
const std::int64_t preserveWindow = preserveWindowFrames(sample);
const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1;
return std::make_unique<LoadedInstrument>(
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
@@ -358,11 +356,18 @@ bool ReaSamplerProcessor::resumeDormantInstrument() {
// Off the audio thread (setActive only). The reactivation half of the lifetime split: the
// 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.
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.
if (live_.load(std::memory_order_acquire)) return true;
if (!dormantSample_) return false;
if (const LoadedInstrument* cur = live_.load(std::memory_order_acquire)) {
return cur->sample.renderRate == renderRate;
}
if (!dormantSample_ || dormantSample_->renderRate != renderRate) 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
+3 -1
View File
@@ -409,7 +409,9 @@ private:
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
int effectiveRoot() const;
// The live sample rate from the bridge, or 0 when unavailable (caller guards).
// 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
// host's: the voice plays a source frame at the source's rate whatever the host runs at.
double liveSampleRate() const;
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
double previewVelocity01() const;
+29 -19
View File
@@ -146,11 +146,12 @@ public:
std::string reloadInstrument();
// The dialed sound as plain data for the offline bake: the SAME refs resolve + decode +
// build reloadInstrument runs, with no live block attached. 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();
// build reloadInstrument runs, with no live block attached, resolved for `renderRate` —
// 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);
// 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
@@ -178,9 +179,16 @@ public:
// editor borrows it (outlives the editor).
ReaperBridge& bridge() { return bridge_; }
// The live host sample rate latched from setupProcessing; the editor's envelope overlay
// shares this time base. 0.0 before setupProcessing runs.
// The live host sample rate latched from setupProcessing. 0.0 before setupProcessing runs.
double sampleRate() const { return sampleRate_; }
// The same rate as the whole-Hz int every SampleData build and bake plan takes, so the
// reload and the bake round it one way. 0 before setupProcessing runs.
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); }
// The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
// Empty id -> silence, no first-sample fallback.
std::string selectedSampleId();
@@ -330,8 +338,9 @@ private:
// 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, 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
// 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.
bool resumeDormantInstrument();
@@ -409,17 +418,17 @@ private:
// reload's WAV decode. The audio thread never takes this; process() only reads via
// LiveParams::read's lock-free seqlock retry.
std::mutex livePublishMutex_;
// The rate the loaded capture was decoded/built at, so a live republish resolves the
// stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing
// built yet.
// The output rate the loaded capture's play params were resolved at (SampleData::
// outputRate — the host's), so a live republish resolves the stored wall-clock seconds to
// exactly the frames the built SampleData carries. 0 = nothing built yet.
//
// ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload
// publishes the new block before installing the new instrument. Swapping to a capture at a
// different rate therefore hands drain voices still ringing from the old-rate capture
// envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap).
// Unavoidable while one block sits above every snapshot, and it touches a release tail
// only.
// 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.
std::atomic<int> builtSampleRate_{0};
// The loaded capture's own decode rate; see loadedSourceRate().
std::atomic<int> loadedSourceRate_{0};
// --- The audio-thread handoff (drain slot) ---
// process() atomically loads live_ + draining_ at block start (two acquires, no lock).
@@ -553,7 +562,8 @@ private:
std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired
std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired
// Latched from setupProcessing; 0.0 is explicitly invalid (reloadInstrument guards on it).
// 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).
double sampleRate_ = 0.0;
Steinberg::int32 maxBlockSize_ = 4096;