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
+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);