// See bake_plan.h. #include "core/instrument/bake/bake_plan.h" #include #include #include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold) #include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length) #include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula) namespace reasampler::instrument::bake { using note::NoteProgram; using note::ResolvedNote; namespace { // Seconds -> frames by round-half-away-from-zero, the one conversion every field here uses, // so the window and its event frames cannot round against each other. Reports failure // rather than clamping: the double->int64 narrowing below is undefined once the product // leaves int64's range, which a legal offset magnitude reaches long before that. bool toFrames(double seconds, int rate, std::int64_t& out) { const double frames = seconds * static_cast(rate); const auto ceiling = static_cast(kMaxBakeFrames); if (!(frames >= -ceiling && frames <= ceiling)) return false; // also catches NaN out = static_cast(std::llround(frames)); return true; } // The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only // Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset // stretches how long the source takes to play out. Preserve decouples the two, and a Gate // release is ticked per output frame, so neither is affected. double downwardSemitones(const PlayParams& play, int velocity) { if (play.pitchEngine != PitchEngine::Varispeed) return 0.0; double down = (std::min)(0.0, kVelocityPitchRangeSemitones * play.pitchVelocityCurve.eval(velocity)); if (play.pitchEnv.enabled) { // A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points; // the staged AHD only ever travels between 0 and the peak. down += play.pitchSpline.mode == EnvMode::Spline ? -std::fabs(play.pitchEnv.peakSemitones) : (std::min)(0.0, play.pitchEnv.peakSemitones); } return down; } // Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top) // rather than starting a voice already off the end. std::int64_t effectiveStart(const SampleData& dialed) { const auto frameCount = static_cast(dialed.frames.size()); const std::int64_t start = dialed.startFrame; return (start < 0 || start >= frameCount) ? 0 : start; } } // namespace bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames, std::int64_t frameCount) { // resolveLoop already refuses a non-Gate voice, so this is exactly "Gate over a loop the // read path will honour" — the engine's decision, not a second reading of the fields. return engine::loop::resolveLoop(loop, crossfadeFrames, frameCount, mode == PlayMode::Gate) .active; } bool bakeWindowNeedsHold(const SampleData& dialed) { return bakeWindowNeedsHold(dialed.play.playMode, dialed.loop, dialed.loopCrossfadeFrames, static_cast(dialed.frames.size())); } NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate, note::Division hold, note::Velocity velocity) { NoteProgram p; // a quarter note, capture opening at note-on p.velocity = velocity; if (renderSampleRate <= 0) return p; const double rate = static_cast(renderSampleRate); const auto frameCount = static_cast(dialed.frames.size()); const std::int64_t start = effectiveStart(dialed); const double stretch = std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0); const double releaseSeconds = static_cast(dialed.play.adsr.releaseFrames) / rate; double endOffsetSeconds = 0.0; if (dialed.play.playMode == PlayMode::Trigger) { // Trigger ignores note-off entirely: the sound ends when the read head reaches the // play span's end. The note is that span, so the window closes on the sound rather // than on a length the voice never consulted. const std::int64_t span = map::triggerPlayLength(effectiveLengthFraction(dialed.play), frameCount, start); p.length = note::lengthOfSeconds(static_cast(span) / rate * stretch); } else if (bakeWindowNeedsHold(dialed)) { // The loop cycles for as long as the note is held, so the hold IS the length, and the // release is the one stage that runs after note-off. p.length = note::lengthOfDivision(hold); endOffsetSeconds = releaseSeconds; } else { // Gate with no loop: the read head runs off the source and frees the voice whether or // not the gate is still down, so the maximal sound is the whole post-start span held. // Exact, not a ladder rung: a source longer than the ladder's top rung would otherwise // take that rung and release mid-sound, and rounding up to one costs trailing silence // on every other source. const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - start); p.length = note::lengthOfSeconds(static_cast(postStart) / rate * stretch); endOffsetSeconds = releaseSeconds; } // The voice rings its last output out over kDeclickFrames instead of hard-cutting it, and // that ramp starts where the derivations above end. Added on every path, not just the // Preserve one that has the ramp today: trailing silence is free, a hard cut is not. endOffsetSeconds += static_cast(kDeclickFrames) / rate; p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0)); return p; } PlannedBake planBake(const ResolvedNote& resolved, int sampleRate, int rootNote) { const PlannedBake empty{std::nullopt, BakeRefusal::EmptyWindow}; const PlannedBake tooLong{std::nullopt, BakeRefusal::PastFrameCeiling}; if (resolved.windowCollapsed) return empty; if (sampleRate <= 0) return empty; // The render starts at whichever comes first, note-on or the capture opening. A POSITIVE // start offset is legal and means the capture opens after the note — so the head is // rendered and discarded, never folded away by sliding note-on later inside the window. const double renderStartSeconds = (std::min)(resolved.captureStartSeconds, 0.0); BakePlan plan; plan.sampleRate = sampleRate; if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames)) return tooLong; if (plan.totalFrames <= 0) return empty; if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate, plan.leadInFrames)) return tooLong; if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return tooLong; if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate, plan.noteOffFrame)) return tooLong; // Each field cleared the ceiling alone; the render holds their sum. if (plan.renderFrames() > kMaxBakeFrames) return tooLong; plan.noteOffFrame = (std::max)(plan.noteOffFrame, plan.noteOnFrame); plan.note = std::clamp(rootNote, 0, 127); plan.velocity = std::clamp(static_cast(resolved.velocity), 1, 127); return PlannedBake{plan, BakeRefusal::None}; } } // namespace reasampler::instrument::bake