Files
reasampler/src/core/instrument/map/sample_map.cpp
T
daniel da14509ab5 Restore the bank fold and usage publish to the resume path, guard setActive against repeats, and make the meter fold's bound literal
The resume also hands back to a full reload when the fold moves the loaded capture's decode source, so the refs table and the audio cannot skew.
2026-08-02 14:06:37 -04:00

352 lines
16 KiB
C++

// sample_map — pure implementation (the resolution half; the ComponentState codec lives
// in component_state_io.cpp). See sample_map.h.
#include "core/instrument/map/sample_map.h"
#include "core/instrument/engine/period_detect.h" // the load-time Preserve source period
#include <algorithm> // std::remove_if
#include <cassert> // assert
#include <utility> // std::move
namespace reasampler::instrument::map {
namespace {
// The bank stores loop points as an optional LoopPoints (both-or-neither); the core wants
// a SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) {
SampleLoop out;
if (s.loop) {
out.hasLoop = true;
out.start = s.loop->start;
out.end = s.loop->end;
}
return out;
}
// rootNote defaults to middle C (60) when the bank left the intrinsic empty — an
// un-rooted sample plays unity at C4 rather than failing to play.
SelectedSample distill(const Sample& s) {
SelectedSample out;
out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // 0 = unknown (older entry)
return out;
}
} // namespace
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) {
// An empty selection is SILENCE, not the first sample — by design.
if (sampleId.empty()) return std::nullopt;
if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (ordinal order) for the stored id; a sample lives in exactly
// one bank, so first hit wins.
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) {
return distill(*s);
}
}
// A stale stored id is SILENCE too — the editor's empty state, not a substitution.
return std::nullopt;
}
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit) {
if (isExplicit) return current; // user's explicit choice is never fought
if (channelCount <= 0) return current; // unknown (0) or pathological -> no change
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
}
// --- Instance-owned sample references (self-contained playback) -------------
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
if (sampleId.empty()) return nullptr;
for (const SampleRefEntry& e : refs) {
if (e.sampleId == sampleId) return &e.ref;
}
return nullptr;
}
std::vector<std::string> referencedSampleIds(const std::string& selectionId) {
std::vector<std::string> ids;
if (!selectionId.empty()) ids.push_back(selectionId);
return ids;
}
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids) {
if (ids.empty() || banksJson.empty()) return;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return; // malformed blob -> no-op (the instance keeps its own copies)
for (const std::string& id : ids) {
const Sample* found = nullptr;
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(id)) { found = s; break; }
}
if (!found) continue; // bank miss: NEVER strips a ref — the instance owns its copy
const SelectedSample distilled = distill(*found);
bool updated = false;
for (SampleRefEntry& e : refs) {
if (e.sampleId == id) {
e.ref = distilled;
e.displayName = found->displayName; // rename sync
updated = true;
break;
}
}
if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName});
}
}
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b) {
return a.relativePath == b.relativePath && a.rootNote == b.rootNote &&
a.channelCount == b.channelCount && a.loop.hasLoop == b.loop.hasLoop &&
a.loop.start == b.loop.start && a.loop.end == b.loop.end;
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
const std::optional<BankBook> book = BankBook::deserialize(*banksJson);
if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET
for (const std::string& id : ids) {
for (const Bank& b : book->banks()) {
if (b.index.query(id)) return LegacyLiftDecision::Lift;
}
}
// The blob parses and knows none of the referenced ids (or there are none): provably
// stale — a lift can never make progress against this bank.
return LegacyLiftDecision::Stale;
}
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) {
refs.erase(std::remove_if(refs.begin(), refs.end(),
[&ids](const SampleRefEntry& e) {
for (const std::string& id : ids) {
if (id == e.sampleId) return false;
}
return true;
}),
refs.end());
}
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
std::vector<SampleChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
for (const Sample& s : b.index.all()) {
out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
}
}
return out;
}
std::vector<BankChoice> listBanks(const std::string& banksJson) {
std::vector<BankChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
out.push_back(BankChoice{b.id, b.displayName});
}
return out;
}
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
const double inv = 1.0 / static_cast<double>(channelCount);
for (std::size_t f = 0; f < frames; ++f) {
double acc = 0.0;
const std::size_t base = f * stride;
for (std::size_t c = 0; c < stride; ++c) {
acc += static_cast<double>(interleaved[base + c]);
}
out[f] = static_cast<AudioSample>(acc * inv);
}
return out;
}
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
// Clamp the requested channel into the source's range: a channel past the last one reads
// the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono).
std::size_t ch = which < 0 ? 0 : static_cast<std::size_t>(which);
if (ch >= stride) ch = stride - 1;
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch];
return out;
}
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) {
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
DecodedPcm out;
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) {
out.monoFrames = downmixToMono(interleaved, sourceChannels);
return out; // framesR stays empty
}
// extractChannel clamps out-of-range, so a mono source yields L == R (dual-mono).
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
out.framesR = extractChannel(interleaved, sourceChannels, 1);
return out;
}
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate; the source-timeline quantity (trigger %-length)
// carries through untouched, already a fraction.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
// The one seconds->frames fold for a stored AHD; the fraction and the curves are rate-free.
const auto resolveAhd = [&secToFrames](const AhdSeconds& s) {
AhdParams a;
a.attackFrames = secToFrames(s.attackSeconds);
a.decayFrames = secToFrames(s.decaySeconds);
a.holdFraction = s.holdFraction;
a.attackCurve = s.attackCurve;
a.decayCurve = s.decayCurve;
return a;
};
PlayParams out;
out.playMode = stored.playMode;
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
out.adsr.attackCurve = stored.adsr.attackCurve; // dimensionless
out.adsr.decayCurve = stored.adsr.decayCurve;
out.adsr.releaseCurve = stored.adsr.releaseCurve;
out.trigger = stored.trigger; // fraction, unchanged
out.trigAhd = resolveAhd(stored.trigAhd);
out.pitchEngine = stored.pitchEngine;
out.playRate = stored.playRate; // a ratio, rate-free
out.pitchOffsetSemitones = stored.pitchOffsetSemitones; // semitones, rate-free
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
out.pitchVelocityCurve = stored.pitchVelocityCurve; // transfer curve, not a time
// Filter: the control positions are already rate-free and carry through untouched; only
// its envelope resolves to frames.
out.filter.enabled = stored.filter.enabled;
out.filter.settings = stored.filter.settings;
out.filter.modAmount = stored.filter.modAmount;
out.filter.velAmount = stored.filter.velAmount;
out.filter.keyTrack = stored.filter.keyTrack;
out.filter.velocityCurve = stored.filter.velocityCurve;
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
out.filter.env.holdFrames = secToFrames(stored.filter.env.holdSeconds);
out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds);
out.filter.env.sustainLevel = stored.filter.env.sustainLevel;
out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds);
out.filter.env.attackCurve = stored.filter.env.attackCurve;
out.filter.env.decayCurve = stored.filter.env.decayCurve;
out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
out.filter.trigEnv = resolveAhd(stored.filter.trigEnv);
// The three drawn contours are normalized over the sample's own length, so no rate resolves
// them — they carry through verbatim, which is also what makes a different-length capture
// replay the same shape proportionally.
out.ampSpline = stored.ampSpline;
out.pitchSpline = stored.pitchSpline;
out.filterSpline = stored.filterSpline;
// Every field splineActive reads on `out` is already copied from `stored` above, so this
// enforces the same rule enforceGateUnavailableWhileDrawn's doc comment (play_params.h)
// describes — the editor's applyControl is the other caller, so the two cannot drift.
enforceGateUnavailableWhileDrawn(out);
return out;
}
// --- The one parameter set ----------------------------------------------------
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params) {
ResolvedCapture rs;
rs.relativePath = ref.relativePath;
rs.rootNote = params.rootOverride ? *params.rootOverride : ref.rootNote;
// Key tracking + velocity curve are instrument state — carried straight through.
rs.keyTrack = params.keyTrack;
rs.velocityCurve = params.velocityCurve;
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
// The bank is never mutated.
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
rs.loopCrossfadeFrames = params.loopCrossfadeFrames;
rs.startFrame = params.startPoint ? *params.startPoint : 0;
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
return rs;
}
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
const std::string& selectionId,
const InstrumentParams& params) {
const std::optional<SelectedSample> sel = selectSample(banksJson, selectionId);
if (!sel) return std::nullopt;
return resolveCapture(*sel, params);
}
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
const std::string& selectionId,
const InstrumentParams& params) {
const SelectedSample* ref = findRef(refs, selectionId);
if (ref == nullptr) return std::nullopt;
return resolveCapture(*ref, params);
}
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
SampleData data;
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
assert(decoded.sampleRate > 0 &&
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
data.frames = std::move(decoded.monoFrames);
// Carry the second channel only when it length-matches channel 0 (channelCount()
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
data.framesR = std::move(decoded.framesR);
}
data.sampleRate = decoded.sampleRate;
data.rootNote = resolved.rootNote;
data.loop = resolved.loop;
data.loopCrossfadeFrames = resolved.loopCrossfadeFrames;
data.startFrame = resolved.startFrame;
data.keyTrack = resolved.keyTrack;
data.velocityCurve = resolved.velocityCurve;
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(resolved.play, data.sampleRate);
// The one place Preserve's source period is computed: the load, off the audio thread.
// Channel 0 only — a stereo pair's two channels share a fundamental, and the splice
// schedule is linked across them anyway. The span is the sustain loop where one is long
// enough (periodAnalysisSpan owns that rule) — every input to it commits through a full
// reload, so the cache is re-derived whenever the span it was chosen from moves.
const instrument::engine::AnalysisSpan span = instrument::engine::periodAnalysisSpan(
data.frames.size(), data.loop.start, data.loop.end, data.loop.hasLoop, data.sampleRate);
data.sourcePeriodFrames =
instrument::engine::detectPeriod(data.frames, data.sampleRate, span.from, span.count)
.frames;
return data;
}
} // namespace reasampler::instrument::map