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