Ξ-W2-T1 remediation: print master gain into the bake, derive the window from the dialed sound, reset play mode to Trigger
This commit is contained in:
@@ -18,26 +18,35 @@ decision about what the render made obsolete.
|
||||
can neither be observed nor disturbed by a bake, and a repeated bake of one dialed sound
|
||||
is byte-identical because nothing outside the passed value can vary between runs.
|
||||
- **The window bounds the render; the envelope does not.** Termination is structural — the
|
||||
loop runs to `BakePlan::totalFrames` and stops. That is why a Gate bake with a sustain
|
||||
loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain
|
||||
loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but
|
||||
even a pathological envelope cannot run past the window.
|
||||
- **The block size is fixed here, not taken from the host.** A block boundary is where the
|
||||
engine re-observes state, so pinning it is part of what makes two bakes on two hosts
|
||||
produce the same bytes.
|
||||
- **A degenerate window is refused, not rendered.** `planBake` returns nullopt for a
|
||||
collapsed window, a non-positive rate, or a window that rounds to no frames.
|
||||
- **The whole signal chain is printed, master gain included.** `renderBake` scales its
|
||||
output by the dialed post-mixer gain, because `resetAfterBake` hands that control back at
|
||||
unity. A render that summed voices alone would return every iteration shifted by 1/gain,
|
||||
and a gain dialed to silence would come back at full level.
|
||||
- **A degenerate or unholdable window is refused, not rendered.** `planBake` returns nullopt
|
||||
for a collapsed window, a non-positive rate, a window that rounds to no frames, and one
|
||||
past `kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the
|
||||
seconds→frames narrowing is undefined long before the allocation would fail.
|
||||
- **The reset's survive list is written out; everything else defaults.** `resetAfterBake`
|
||||
starts from a default-constructed parameter set and copies back only the mapping facts.
|
||||
A parameter added later therefore resets by default — the safe direction, since
|
||||
under-resetting applies the same processing twice while over-resetting costs a re-dial.
|
||||
A new mapping fact must be added to the copy list explicitly.
|
||||
- **Play mode resets to TRIGGER, not to the value struct's Gate default** — the one
|
||||
classification this track made against the ratified rule rather than reading off it. The
|
||||
bake's product is a finished one-shot, and Trigger is the mode that plays a finished
|
||||
one-shot verbatim; Gate would re-gate the printed release tail and each iteration would
|
||||
truncate the previous one's. "Neutral" here means "adds no processing", not "the struct's
|
||||
own default". `bake_reset.cpp` carries the argument at the assignment.
|
||||
|
||||
## Modules
|
||||
|
||||
- `bake_plan` — `defaultBakeProgram` (the program a bake uses until the capture-signal
|
||||
popup ships; its release tail exists so the bake is not truncated at note-off),
|
||||
`BakePlan` (the frame window plus its two event frames), and `planBake`, the one
|
||||
`ResolvedNote` + rate -> frames resolution.
|
||||
- `bake_plan` — `defaultBakeProgram` (the program a bake uses until the capture-signal popup
|
||||
ships; its end offset is DERIVED from the dialed sound, never constant), `BakePlan` (the
|
||||
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
|
||||
`planBake`, the one `ResolvedNote` + rate -> frames resolution.
|
||||
- `bake_render` — `BakeAudio` and `renderBake`: the programmed note through the sample's
|
||||
own voice path, summed into an interleaved buffer at the source's own channel count.
|
||||
- `bake_reset` — `BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
|
||||
@@ -45,7 +54,13 @@ decision about what the render made obsolete.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Frame 0 of the render is the start of the CAPTURED FILE, not note-on. A capture that
|
||||
opens before the note has `noteOnFrame > 0` and silence ahead of it.
|
||||
- **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are
|
||||
offset from each other whenever the note and the capture window do not start together.
|
||||
`bake_plan.h` says which field is in which; do not read them as one clock.
|
||||
- **`defaultBakeProgram`'s Trigger window bounds the Varispeed read stretch, it does not
|
||||
model it.** A downward pitch offset makes the read head take longer to cross the play
|
||||
span, so the window is scaled by the deepest downward offset the voice can reach — an
|
||||
upper bound, so a shallower excursion leaves trailing silence in the file. The
|
||||
capture-signal popup is where a user sets the window exactly.
|
||||
- The render's channel count is the loaded `SampleData`'s, which is already the instance's
|
||||
channel-mode decision — a mono-mode instance bakes mono, and that is faithful, not a fold.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Links only note_program: a plan is the programmed note resolved against a rate, and
|
||||
# nothing about the engine or the bank is needed to compute one.
|
||||
reasampler_pure_library(bake_plan SOURCES bake_plan.cpp LINK PUBLIC note_program)
|
||||
# The default program's window is derived from the DIALED sound, so the plan reads the
|
||||
# engine's value layer (sampler_core) and the one Trigger span formula (trigger_seam).
|
||||
reasampler_pure_library(bake_plan
|
||||
SOURCES bake_plan.cpp
|
||||
LINK PUBLIC note_program sampler_core trigger_seam)
|
||||
reasampler_test(bake_plan LINK bake_plan)
|
||||
|
||||
reasampler_pure_library(bake_render
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
using note::NoteProgram;
|
||||
@@ -12,17 +14,62 @@ using note::ResolvedNote;
|
||||
|
||||
namespace {
|
||||
|
||||
// Seconds -> frames by round-half-away-from-zero, the one conversion every field here
|
||||
// uses, so the window and its two event frames cannot round against each other.
|
||||
std::int64_t toFrames(double seconds, int rate) {
|
||||
return static_cast<std::int64_t>(std::llround(seconds * static_cast<double>(rate)));
|
||||
// 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<double>(rate);
|
||||
const auto ceiling = static_cast<double>(kMaxBakeFrames);
|
||||
if (!(frames >= -ceiling && frames <= ceiling)) return false; // also catches NaN
|
||||
out = static_cast<std::int64_t>(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() {
|
||||
NoteProgram p;
|
||||
p.end = note::EndOffset(note::offsetFromMs(kDefaultReleaseTailMs));
|
||||
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<double>(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<std::int64_t>(dialed.frames.size()), dialed.startFrame);
|
||||
const double stretch = std::pow(
|
||||
2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
|
||||
endOffsetSeconds = static_cast<double>(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<double>(dialed.play.adsr.releaseFrames) / rate;
|
||||
}
|
||||
p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0));
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -31,20 +78,27 @@ std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
|
||||
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;
|
||||
plan.totalFrames = toFrames(resolved.captureLengthSeconds(), 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;
|
||||
|
||||
// Note-on sits at -captureStart into the window: a negative start offset (the capture
|
||||
// opens early) pushes it later, a positive one has already been clamped away by
|
||||
// resolveNote's own window.
|
||||
plan.noteOnFrame = std::clamp(toFrames(-resolved.captureStartSeconds, sampleRate),
|
||||
std::int64_t{0}, plan.totalFrames);
|
||||
plan.noteOffFrame =
|
||||
std::clamp(toFrames(resolved.noteOffSeconds - resolved.captureStartSeconds,
|
||||
sampleRate),
|
||||
plan.noteOnFrame, plan.totalFrames);
|
||||
plan.noteOffFrame = (std::max)(plan.noteOffFrame, plan.noteOnFrame);
|
||||
plan.note = std::clamp(rootNote, 0, 127);
|
||||
plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
|
||||
return plan;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// bake_plan — the programmed note resolved against a concrete sample rate: the frame
|
||||
// window the offline pass renders, and the two event frames inside it.
|
||||
// bake_plan — the programmed note resolved against a concrete sample rate: the frames the
|
||||
// offline pass renders, the slice of them the capture keeps, and the two event frames.
|
||||
//
|
||||
// Separate from bake_render because the plan is what a preview and a bake must agree on;
|
||||
// the render is only one consumer of it.
|
||||
@@ -9,36 +9,49 @@
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
#include "core/instrument/engine/play_params.h" // SampleData (the dialed sound)
|
||||
#include "core/instrument/note/note_program.h"
|
||||
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
// Until the capture-signal popup ships, the bake needs a program to render. A zero end
|
||||
// offset ends the capture exactly at note-off, which truncates every release — so the
|
||||
// default opens the window past the gate by this much.
|
||||
inline constexpr double kDefaultReleaseTailMs = 500.0;
|
||||
// The render's frame ceiling, refused like any other degenerate window. A legal offset
|
||||
// magnitude reaches ~11.6 days, and renderBake allocates two channel buffers plus an
|
||||
// interleaved one from the window — an unbounded one is a bad_alloc inside a UI tick, not a
|
||||
// long bake. ~5.5 minutes at 48 kHz, past any musical programmed note.
|
||||
inline constexpr std::int64_t kMaxBakeFrames = 16'000'000;
|
||||
|
||||
// The program a bake uses when nothing has been entered: one quarter note at the default
|
||||
// velocity, opening at note-on and closing kDefaultReleaseTailMs after the release starts.
|
||||
note::NoteProgram defaultBakeProgram();
|
||||
// The program a bake uses until the capture-signal popup ships: one quarter note at the
|
||||
// default velocity, opening at note-on, with the END offset derived from `dialed` — Gate's
|
||||
// release, or Trigger's play span, at `renderSampleRate` (the rate the bake will render at,
|
||||
// which is what the engine's frame counts are actually consumed against). Derived rather
|
||||
// than constant because the release knob alone spans two seconds, so any fixed tail cuts a
|
||||
// long decay mid-flight.
|
||||
note::NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
|
||||
note::Tempo tempo);
|
||||
|
||||
// The render window in frames. Frame 0 is the start of the captured file, NOT note-on:
|
||||
// a negative start offset opens the capture before the note, and noteOnFrame is where the
|
||||
// note actually lands inside it.
|
||||
// The render window in frames. TWO domains meet here: `totalFrames` is the captured FILE's
|
||||
// length, everything else counts RENDER frames from whichever comes first, note-on or the
|
||||
// capture opening. A positive start offset (legal — it trims the attack) puts note-on at
|
||||
// render frame 0 and the file's frame 0 `leadInFrames` later; a negative one does the
|
||||
// reverse, and the file opens on silence before the note. Either event frame may sit past
|
||||
// the render, which then closes before the note ever fires — a legal empty capture.
|
||||
struct BakePlan {
|
||||
std::int64_t totalFrames = 0;
|
||||
std::int64_t noteOnFrame = 0; // in [0, totalFrames]
|
||||
std::int64_t noteOffFrame = 0; // in [noteOnFrame, totalFrames]
|
||||
std::int64_t totalFrames = 0; // frames in the captured file
|
||||
std::int64_t leadInFrames = 0; // rendered ahead of the file's frame 0, then discarded
|
||||
std::int64_t noteOnFrame = 0; // both in render frames
|
||||
std::int64_t noteOffFrame = 0;
|
||||
// The capture's root: rendering AT root is what makes the root survivable, which is
|
||||
// why the root parameter is the one processing control a bake does not reset.
|
||||
int note = 60;
|
||||
int velocity = 100;
|
||||
int sampleRate = 0;
|
||||
|
||||
std::int64_t renderFrames() const { return leadInFrames + totalFrames; }
|
||||
};
|
||||
|
||||
// nullopt for a collapsed window, a non-positive rate, or a window that rounds to no
|
||||
// frames — a degenerate buffer is refused rather than rendered. `rootNote` and the
|
||||
// resolved velocity are clamped into MIDI range.
|
||||
// nullopt for a collapsed window, a non-positive rate, a window that rounds to no frames,
|
||||
// or one past kMaxBakeFrames — a buffer that is degenerate or unholdable is refused rather
|
||||
// than rendered. `rootNote` and the resolved velocity are clamped into MIDI range.
|
||||
std::optional<BakePlan> planBake(const note::ResolvedNote& resolved, int sampleRate,
|
||||
int rootNote);
|
||||
|
||||
|
||||
@@ -11,16 +11,17 @@ namespace reasampler::instrument::bake {
|
||||
|
||||
namespace {
|
||||
|
||||
// A fixed render block, deliberately independent of the host's: the block boundary is
|
||||
// where the engine observes live parameters and re-checks voice state, so pinning it here
|
||||
// is what keeps two bakes of one dialed sound byte-identical on different hosts.
|
||||
// A fixed render block rather than the host's. A block boundary is where the engine
|
||||
// re-observes live state, and the detach below leaves it nothing to observe — so this is
|
||||
// defence in depth against a future block-boundary read, not the reason two bakes agree.
|
||||
constexpr std::int64_t kBlockFrames = 512;
|
||||
|
||||
} // namespace
|
||||
|
||||
BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
|
||||
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear) {
|
||||
BakeAudio out;
|
||||
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
|
||||
if (plan.leadInFrames < 0 || plan.renderFrames() > kMaxBakeFrames) return out;
|
||||
|
||||
// The live block is the audio thread's moving target; a render that observed it would
|
||||
// depend on what the user happened to be dragging. The dialed values are already in
|
||||
@@ -28,9 +29,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
|
||||
sample.live = nullptr;
|
||||
|
||||
const int channels = sample.channelCount();
|
||||
const auto total = static_cast<std::size_t>(plan.totalFrames);
|
||||
std::vector<AudioSample> left(total, 0.f);
|
||||
std::vector<AudioSample> right(channels == 2 ? total : 0u, 0.f);
|
||||
const auto rendered = static_cast<std::size_t>(plan.renderFrames());
|
||||
std::vector<AudioSample> left(rendered, 0.f);
|
||||
std::vector<AudioSample> right(channels == 2 ? rendered : 0u, 0.f);
|
||||
|
||||
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
|
||||
// does for its live engine — a cold shifter would smear the onset.
|
||||
@@ -40,17 +41,18 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
|
||||
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
|
||||
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
|
||||
|
||||
for (std::int64_t pos = 0; pos < plan.totalFrames;) {
|
||||
for (std::int64_t pos = 0; pos < plan.renderFrames();) {
|
||||
if (pos == plan.noteOnFrame) engine.noteOn(plan.note, plan.velocity);
|
||||
// Trigger ignores note-off by design; in Gate this is the release the programmed
|
||||
// note length bounds.
|
||||
if (pos == plan.noteOffFrame) engine.noteOff(plan.note);
|
||||
|
||||
// Stop the block at the next event frame so both land sample-accurately.
|
||||
std::int64_t limit = plan.totalFrames;
|
||||
if (pos < plan.noteOnFrame) limit = plan.noteOnFrame;
|
||||
else if (pos < plan.noteOffFrame) limit = plan.noteOffFrame;
|
||||
const std::int64_t chunk = std::min(limit - pos, kBlockFrames);
|
||||
// Stop the block at the next event frame so both land sample-accurately. An event
|
||||
// past the window (a capture that closes before the note) never bounds anything.
|
||||
std::int64_t limit = plan.renderFrames();
|
||||
if (pos < plan.noteOnFrame) limit = (std::min)(limit, plan.noteOnFrame);
|
||||
else if (pos < plan.noteOffFrame) limit = (std::min)(limit, plan.noteOffFrame);
|
||||
const std::int64_t chunk = (std::min)(limit - pos, kBlockFrames);
|
||||
if (chunk <= 0) break; // unreachable while limit > pos; a guard, not a path
|
||||
|
||||
const auto at = static_cast<std::size_t>(pos);
|
||||
@@ -62,10 +64,15 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
|
||||
|
||||
out.channelCount = channels;
|
||||
out.sampleRate = plan.sampleRate;
|
||||
const auto lead = static_cast<std::size_t>(plan.leadInFrames);
|
||||
const auto total = static_cast<std::size_t>(plan.totalFrames);
|
||||
out.interleaved.resize(total * static_cast<std::size_t>(channels));
|
||||
// A flat multiply, not the processor's per-sample ramp: the gain is constant for the
|
||||
// whole render, which is exactly what that ramp exists to converge to.
|
||||
const auto gain = static_cast<AudioSample>(masterGainLinear);
|
||||
for (std::size_t f = 0; f < total; ++f) {
|
||||
out.interleaved[f * channels] = left[f];
|
||||
if (channels == 2) out.interleaved[f * channels + 1] = right[f];
|
||||
out.interleaved[f * channels] = left[lead + f] * gain;
|
||||
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// bake_render — the offline pass: one programmed note through a voice engine built for
|
||||
// this render alone, summed into an interleaved buffer.
|
||||
//
|
||||
// Never touches a live engine and never runs on the audio thread: it takes the SampleData
|
||||
// BY VALUE precisely so it can detach the live-parameter block before rendering (see
|
||||
// renderBake), which is what makes a repeated bake byte-identical.
|
||||
// Never touches a live engine and never runs on the audio thread. Takes its SampleData BY
|
||||
// VALUE for the reason this directory's CLAUDE.md records.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -30,10 +29,10 @@ struct BakeAudio {
|
||||
bool empty() const { return frameCount() == 0; }
|
||||
};
|
||||
|
||||
// Renders `plan` through `sample`'s own voice path. The gate is held for the plan's note
|
||||
// span and released at noteOffFrame — with the window itself bounding the render, a Gate
|
||||
// sustain loop terminates by construction rather than by trusting the envelope to end.
|
||||
// An unplayable sample yields an empty result.
|
||||
BakeAudio renderBake(SampleData sample, const BakePlan& plan);
|
||||
// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the
|
||||
// post-mixer gain the processor applies after the engine, printed here because the bake's
|
||||
// reset hands that control back at unity. The result is the plan's captured window: the
|
||||
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
|
||||
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear);
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
|
||||
@@ -14,6 +14,15 @@ BakeReset resetAfterBake(const map::InstrumentParams& dialed) {
|
||||
out.params.keyTrack = dialed.keyTrack;
|
||||
// There is no key-range parameter to carry (core/instrument/CLAUDE.md: no key-range
|
||||
// concept) — if one is ever added it belongs on this list, not in the defaults.
|
||||
|
||||
// Play mode is on neither ratified list, so it is classified here, and the acceptance
|
||||
// criteria decide it: the bake's product is a finished one-shot carrying its own
|
||||
// attack, span and release. Trigger plays that back verbatim — note-off ignored, the
|
||||
// default AHD flat at unity over the whole span. Gate would re-gate it: the default
|
||||
// release would cut the printed tail at note-off, and every further iteration would cut
|
||||
// the previous one's again. "Neutral" for this control means "adds no processing",
|
||||
// which is Trigger, not the value struct's own Gate default.
|
||||
out.params.play.playMode = PlayMode::Trigger;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,17 +11,15 @@
|
||||
namespace reasampler::instrument::bake {
|
||||
|
||||
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
|
||||
// parameter set, but it is post-mixer gain that the render printed, so it belongs to the
|
||||
// same decision and is answered here rather than left to the shell.
|
||||
// parameter set, but renderBake prints it into the file, so it belongs to the same decision
|
||||
// and is answered here rather than left to the shell.
|
||||
struct BakeReset {
|
||||
map::InstrumentParams params;
|
||||
double masterGainLinear = 1.0; // unity — the dialed gain is in the audio now
|
||||
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
|
||||
};
|
||||
|
||||
// Everything defaults; the survivors are copied back explicitly. That direction is
|
||||
// deliberate: a parameter added later is presumptively part of what the render printed,
|
||||
// and over-resetting a control costs a re-dial while under-resetting silently applies the
|
||||
// same processing twice. A new MAPPING fact must be added to the copies below.
|
||||
// Everything defaults; the survivors are copied back explicitly (this directory's CLAUDE.md
|
||||
// owns why that direction, and which classifications are ratified).
|
||||
BakeReset resetAfterBake(const map::InstrumentParams& dialed);
|
||||
|
||||
} // namespace reasampler::instrument::bake
|
||||
|
||||
@@ -72,7 +72,9 @@ sits on.
|
||||
- `resample_name` — the display name a resample's new bank entry takes, so repeated bakes
|
||||
read as one iteration chain (`Kick` -> `Kick r2` -> `Kick r3`) rather than as N unrelated
|
||||
captures. Presentation only: the machine-readable lineage is the ledger's
|
||||
`parentSampleId`, and nothing parses this name back into one.
|
||||
`parentSampleId`, and nothing parses this name back into one. **The rule is per-NAME, not
|
||||
per-bank** — two add-distinct bakes of the same source both land as `"<name> r2"`, so
|
||||
display names are not unique and a browser reading the chain must read the ledger.
|
||||
- `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** It is the recipe facet of the tracking system whose authority lives in `core/tracking`; the lineage facet (which file derives from which) is the ledger's, not the `Sample`'s.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -44,8 +44,9 @@ This directory owns two cross-artifact contracts specifically:
|
||||
namespace via `reaper_bridge::writeUsageExtState` — an entry point that is
|
||||
**prefix-guarded** (accepts only `rsusage_`-prefixed keys, refuses all others), so the
|
||||
read-only-bank invariant is structurally enforced. Direction: the instrument writes
|
||||
usage keys; the extension reads them — the one sanctioned instrument→ext-state write,
|
||||
a deliberate exception analogous to `assignment_request` on the other wire.
|
||||
usage keys; the extension reads them — one of the two sanctioned instrument→ext-state
|
||||
writes (`bake_wire`'s `rsbake_` request key is the other), a deliberate exception
|
||||
analogous to `assignment_request` on the other wire.
|
||||
Usage records are **never cleared by the instrument at teardown** (REAPER destroys the
|
||||
plugin instance when an FX chain is set offline, including Design View's CPU-park, so a
|
||||
terminate-time clear would strip a still-live instance's record); liveness is decided
|
||||
|
||||
@@ -26,6 +26,7 @@ BakeStatus statusFromWire(int raw) {
|
||||
case BakeStatus::StagedMissing:
|
||||
case BakeStatus::NoSource:
|
||||
case BakeStatus::IndexRejected:
|
||||
case BakeStatus::WrongProject:
|
||||
return static_cast<BakeStatus>(raw);
|
||||
}
|
||||
return BakeStatus::Failed;
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
// bake_wire — the resample bake's request/outcome pair, on ONE per-instance ext-state key
|
||||
// ("rsbake_<instanceGuid>"). Pure: no REAPER/VST3/SWELL/vendor includes.
|
||||
//
|
||||
// The instrument writes a BakeRequest, invokes the extension's bake action synchronously,
|
||||
// and reads the BakeOutcome the action wrote back over the same key. That is a call and a
|
||||
// return in one UI tick — deliberately NOT the poller/nonce handshake this seam once
|
||||
// spiked; nothing here may grow a claim protocol.
|
||||
//
|
||||
// `generation` exists for the same reason it does on assignment_request: a repeat bake of
|
||||
// the same source is otherwise indistinguishable from a stale value, and an outcome whose
|
||||
// generation does not echo the request is a leftover, not an answer.
|
||||
// A call and a return in one UI tick — deliberately NOT the poller/nonce handshake this
|
||||
// seam once spiked; nothing here may grow a claim protocol. `generation` is a wall-clock
|
||||
// stamp: it distinguishes a repeat bake from a leftover, and dates a request the extension
|
||||
// must refuse rather than answer to nobody.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -52,6 +48,7 @@ enum class BakeStatus : int {
|
||||
StagedMissing = 3, // the staged file was gone or unreadable
|
||||
NoSource = 4, // the source capture is not in any bank
|
||||
IndexRejected = 5, // the bank refused the add
|
||||
WrongProject = 6, // the request belongs to a project tab this extension has not loaded
|
||||
};
|
||||
|
||||
// What the extension did. On Ok the four sample fields describe the landed entry, so the
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
// decisions (what to write on publish, which records count at prune time) are
|
||||
// pure and provable without a DAW; shells only move strings.
|
||||
//
|
||||
// The INSTRUMENT writes usage keys, the EXTENSION only reads them — the one
|
||||
// sanctioned instrument->ext-state write. It does not weaken the
|
||||
// read-only-bank invariant: the instrument publishes only its own
|
||||
// per-instance key, never banks/view/tail/assign; the bridge's write entry
|
||||
// point structurally accepts only "rsusage_"-prefixed keys.
|
||||
// The INSTRUMENT writes usage keys, the EXTENSION only reads them — one of the
|
||||
// two sanctioned instrument->ext-state writes (bake_wire's request key is the
|
||||
// other). It does not weaken the read-only-bank invariant: the instrument
|
||||
// publishes only its own per-instance key, never banks/view/tail/assign; the
|
||||
// bridge's write entry point structurally accepts only "rsusage_"-prefixed keys.
|
||||
//
|
||||
// Every failure, ambiguity, or uncertainty here fails safe toward PROTECT (the
|
||||
// territory-wide asymmetry, stated in core/tracking/CLAUDE.md). Three folds enforce
|
||||
|
||||
Reference in New Issue
Block a user