289 lines
12 KiB
C++
289 lines
12 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 <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});
|
|
}
|
|
}
|
|
|
|
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; source-timeline quantities (trigger %-length +
|
|
// fades) carry through untouched, already frames/fractions.
|
|
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);
|
|
};
|
|
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.trigger = stored.trigger; // source-frame / fraction, unchanged
|
|
out.pitchEngine = stored.pitchEngine;
|
|
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
|
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
|
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
|
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
|
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.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.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);
|
|
return data;
|
|
}
|
|
|
|
} // namespace reasampler::instrument::map
|