// 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 // std::remove_if #include // assert #include // 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 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 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 referencedSampleIds(const std::string& selectionId) { std::vector ids; if (!selectionId.empty()) ids.push_back(selectionId); return ids; } void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, const std::vector& ids) { if (ids.empty() || banksJson.empty()) return; std::optional 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& banksJson, const std::vector& ids) { if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry; const std::optional 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& 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 listSamples(const std::string& banksJson) { std::vector out; if (banksJson.empty()) return out; std::optional 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 listBanks(const std::string& banksJson) { std::vector out; if (banksJson.empty()) return out; std::optional 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 downmixToMono(const std::vector& interleaved, int channelCount) { std::vector out; if (channelCount <= 0 || interleaved.empty()) return out; const std::size_t stride = static_cast(channelCount); const std::size_t frames = interleaved.size() / stride; out.resize(frames); const double inv = 1.0 / static_cast(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(interleaved[base + c]); } out[f] = static_cast(acc * inv); } return out; } std::vector extractChannel(const std::vector& interleaved, int channelCount, int which) { std::vector out; if (channelCount <= 0 || interleaved.empty()) return out; const std::size_t stride = static_cast(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(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& 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(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(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 resolveFromBank(const std::string& banksJson, const std::string& selectionId, const InstrumentParams& params) { const std::optional sel = selectSample(banksJson, selectionId); if (!sel) return std::nullopt; return resolveCapture(*sel, params); } std::optional 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