// See instrument_bake.h. #include "shell/instrument/instrument_bake.h" #include #include #include #include #include #include #include #include "core/capture/wav_codec.h" // buildFloat32Wav (the bank byte format) #include "core/instrument/bake/bake_plan.h" #include "core/instrument/bake/bake_render.h" #include "core/instrument/bake/bake_reset.h" #include "core/instrument/note/tempo.h" #include "core/wire/bake_wire.h" #include "ext_keys.h" #include "shell/instrument/reaper_bridge.h" #include "shell/instrument/reasampler_processor.h" namespace reasampler::vst { using capture::buildFloat32Wav; using instrument::bake::defaultBakeProgram; using instrument::bake::planBake; using instrument::bake::renderBake; using instrument::bake::resetAfterBake; using instrument::map::SampleRefEntry; using instrument::map::SelectedSample; using instrument::note::Tempo; using instrument::note::Velocity; using instrument::note::resolveNote; using wire::BakeOutcome; using wire::BakeRequest; using wire::BakeStatus; namespace { namespace fs = std::filesystem; // Deletes the staged file on EVERY exit path, success or failure — the same stack-RAII // discipline the capture shell's FX-bypass guard follows. On success the extension has // already COPIED the bytes into the bank, so the delete here is what keeps the temp from // outliving the click. Best-effort: a file already gone is not an error. // // Orphan policy: a crash between the write and the invoke leaves one file in the OS temp // directory, which is exactly what that directory is swept for. Nothing here scans or // deletes files it did not itself create. class StagedFileGuard { public: explicit StagedFileGuard(std::string path) : path_(std::move(path)) {} ~StagedFileGuard() { if (path_.empty()) return; std::error_code ec; fs::remove(path_, ec); } StagedFileGuard(const StagedFileGuard&) = delete; StagedFileGuard& operator=(const StagedFileGuard&) = delete; private: std::string path_; }; // Clears the request key on every exit path. A key left holding a request would be picked // up by the next bake's landing pass and re-run against a temp file that no longer exists. class RequestKeyGuard { public: RequestKeyGuard(ReaperBridge& bridge, std::string key) : bridge_(bridge), key_(std::move(key)) {} ~RequestKeyGuard() { bridge_.writeBakeExtState(key_, ""); } RequestKeyGuard(const RequestKeyGuard&) = delete; RequestKeyGuard& operator=(const RequestKeyGuard&) = delete; private: ReaperBridge& bridge_; std::string key_; }; BakeChainResult fail(std::string message) { return BakeChainResult{false, std::move(message)}; } bool writeFileBytes(const std::string& path, const std::vector& bytes) { std::ofstream f(path, std::ios::binary | std::ios::trunc); if (!f) return false; f.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); return f.good(); } } // namespace bool bakeAvailable(ReaperBridge& bridge) { return bridge.isConnected() && bridge.extensionActionAvailable(wire::bakeActionLookupName()); } BakeChainResult runBake(ReaSamplerProcessor& processor) { ReaperBridge& bridge = processor.bridge(); if (!bakeAvailable(bridge)) return fail("resample needs the ReaSampler extension loaded"); const std::string selectionId = processor.selectedSampleId(); if (selectionId.empty()) return fail("nothing loaded to resample"); // The whole ref entry, not just its SelectedSample: the display name that the new // capture's is derived from sits beside the intrinsics. const instrument::map::SampleRefs refs = processor.sampleRefs(); const SampleRefEntry* sourceEntry = nullptr; for (const SampleRefEntry& e : refs) if (e.sampleId == selectionId) { sourceEntry = &e; break; } if (!sourceEntry) return fail("the loaded capture has no resolvable file"); const SelectedSample* source = &sourceEntry->ref; const int sampleRate = static_cast(processor.sampleRate()); if (sampleRate <= 0) return fail("the host has not reported a sample rate yet"); const std::optional tempo = Tempo::fromBpm(bridge.projectTempoBpm()); if (!tempo) return fail("the project tempo could not be read"); const InstrumentParams dialed = processor.instrumentParams(); const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote; // 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 snapshot = processor.bakeSnapshot(); 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 // 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), *tempo), sampleRate, rootNote); if (!planned.plan) { return fail(planned.refusal == instrument::bake::BakeRefusal::PastFrameCeiling ? "the dialed sound is longer than one bake can hold" : "the programmed capture window is empty"); } const instrument::bake::BakePlan& plan = *planned.plan; const instrument::bake::BakeAudio audio = renderBake(std::move(*snapshot), plan, processor.masterGainLinear()); if (audio.empty()) return fail("the offline pass produced no audio"); // buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's // own 32-bit-float contract, so the round trip is exact. std::vector interleaved(audio.interleaved.begin(), audio.interleaved.end()); const std::vector bytes = buildFloat32Wav(audio.channelCount, static_cast(audio.sampleRate), static_cast(audio.frameCount()), interleaved); const std::string instanceGuid = processor.usageInstanceGuid(); // Named for what it is used for here — the staged file's own name, nothing else. // `request.generation` below takes its OWN, later timestamp: kMaxRequestAgeSeconds // is a budget measured from the write, and re-using this one would silently spend it // on the WAV write that happens in between. const std::int64_t stageStamp = static_cast(std::time(nullptr)); // OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray // file there would read as a prune orphan. std::error_code ec; const fs::path stagedPath = fs::temp_directory_path(ec) / ("reasampler_bake_" + instanceGuid + "_" + std::to_string(stageStamp) + ".wav"); if (ec) return fail("no writable temp directory for the staged render"); const std::string staged = stagedPath.string(); StagedFileGuard stagedGuard(staged); if (!writeFileBytes(staged, bytes)) return fail("could not stage the rendered file"); BakeRequest request; request.instanceGuid = instanceGuid; request.stagedFilePath = staged; request.sourceSampleId = selectionId; request.sourceRelativePath = source->relativePath; request.sourceDisplayName = sourceEntry->displayName; request.ownUsageKey = usageKeyFor(instanceGuid); request.rootNote = plan.note; // Stamped here, immediately before the publish below — AFTER the WAV write above, // which is what makes kMaxRequestAgeSeconds' budget (bake_wire.h) exclude staging // time rather than eat into it. request.generation = static_cast(std::time(nullptr)); const std::string key = bakeKeyFor(instanceGuid); RequestKeyGuard keyGuard(bridge, key); // The bridge PROVES this by reading the key back, so a false here is real: the // extension would find nothing to land. It does not say which of the three ways failed, // so neither does this sentence. if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request))) return fail("the bake request under " + key + " could not be confirmed -- it was either never written or did not " "read back as written, so the extension has nothing to land"); // Synchronous: the extension's landing runs to completion inside this call and writes // its outcome back over the same key before returning. if (!bridge.invokeExtensionAction(wire::bakeActionLookupName())) return fail("the ReaSampler extension's bake action is not registered"); // What the key holds now is the only evidence this side gets, and each of the five // non-answers is a different thing to go fix — collapsing them into one sentence is // what made a stale install indistinguishable from a refusal. All five name the KEY, // because the landing prints one console line per key it scanned and the key is what // correlates the two in a multi-instance session. const wire::BakeAnswer answer = wire::classifyBakeAnswer(bridge.readReasamplerExtState(key), request); switch (answer.kind) { case wire::BakeAnswerKind::Answered: break; case wire::BakeAnswerKind::Unanswered: return fail("the ReaSampler extension did not answer " + key + " -- that key still holds this exact request, untouched. The " "landing action prints one REAPER console line per key it " "scanned; look for that key name there"); case wire::BakeAnswerKind::Undecodable: return fail("the value under " + key + " is neither a request nor an answer this build can read -- the " "extension and ReaSampler 9000 may be from different builds"); case wire::BakeAnswerKind::Cleared: return fail("the bake key " + key + " came back empty -- either the request was cleared before an " "answer was written, or this build could not read whatever was " "there. Look for that key name in the landing action's console " "lines"); case wire::BakeAnswerKind::ForeignRequest: return fail("the bake key " + key + " held a different pending request instead of an answer -- " "unexpected given the bake's single-threaded call flow; if this " "recurs, note the exact steps and file it"); case wire::BakeAnswerKind::ForeignOutcome: return fail("the answer under " + key + " is for a different bake request than this one"); } // wire::answeredOutcome is the guard, not switch exhaustiveness alone: the switch // above has no `default`, so a future BakeAnswerKind enumerator it doesn't yet handle // would otherwise fall through to a raw `*answer.outcome` deref with nothing set. const BakeOutcome* outcomePtr = wire::answeredOutcome(answer); if (!outcomePtr) return fail( "the extension's answer was not one this build recognizes -- the extension " "and ReaSampler 9000 may be from different builds"); const BakeOutcome& outcome = *outcomePtr; if (outcome.status != BakeStatus::Ok) return fail(outcome.message.empty() ? std::string("the bake was refused") : outcome.message); SampleRefEntry entry; entry.sampleId = outcome.sampleId; entry.displayName = outcome.displayName; entry.ref.relativePath = outcome.relativePath; entry.ref.rootNote = outcome.rootNote; entry.ref.channelCount = outcome.channelCount; // No loop: the loop points shaped the render and are meaningless against the new file // (bake_reset owns that rule for the parameter set; this is its bank-intrinsic peer). const instrument::bake::BakeReset reset = resetAfterBake(dialed); processor.adoptBakedCapture(entry, reset.params, reset.masterGainLinear); // The extension's own wording, which distinguishes the three landings (replaced, added, // and pointed at an identical existing entry) more precisely than this side can. return BakeChainResult{true, "resampled -- " + outcome.message}; } } // namespace reasampler::vst