// See bake_plan.h. #include "core/instrument/bake/bake_plan.h" #include #include #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 Trigger's source span takes to play. 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; } } // namespace NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate, note::Tempo tempo) { NoteProgram p; // 1/4 straight, velocity 100, capture opening at note-on if (renderSampleRate <= 0) return p; const double rate = static_cast(renderSampleRate); 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, which has nothing to do with the note's length — so the end // offset is whatever is left after the note, positive or negative. const std::int64_t span = map::triggerPlayLength( dialed.play.trigger.lengthFraction, static_cast(dialed.frames.size()), dialed.startFrame); const double stretch = std::pow( 2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0); endOffsetSeconds = static_cast(span) / rate * stretch - tempo.beatsToSeconds(note::divisionBeats(p.length)); } else { // Gate: the release is the one stage that runs after note-off, so it is exactly // what the window has to hold past it. endOffsetSeconds = static_cast(dialed.play.adsr.releaseFrames) / rate; } p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0)); return p; } std::optional planBake(const ResolvedNote& resolved, int sampleRate, int rootNote) { if (resolved.windowCollapsed) return std::nullopt; if (sampleRate <= 0) return std::nullopt; // 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 std::nullopt; if (plan.totalFrames <= 0) return std::nullopt; if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate, plan.leadInFrames)) return std::nullopt; if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return std::nullopt; if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate, plan.noteOffFrame)) return std::nullopt; // Each field cleared the ceiling alone; the render holds their sum. if (plan.renderFrames() > kMaxBakeFrames) return std::nullopt; 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 plan; } } // namespace reasampler::instrument::bake