Merge Ξ-W2-T1: the resample bake chain — the instrument renders the dialed sound, the extension banks it, one click re-points and resets

This commit is contained in:
2026-08-01 18:05:06 -04:00
53 changed files with 2735 additions and 75 deletions
+24 -3
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. **ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
Per-module detail — what each file owns, its invariants — lives in the twenty-two per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below. Per-module detail — what each file owns, its invariants — lives in the twenty-three per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
## Settled decisions ## Settled decisions
@@ -83,14 +83,15 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
## Architecture: the load-bearing split ## Architecture: the load-bearing split
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-two directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth. `core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-three directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
| Directory | Scope | | Directory | Scope |
|---|---| |---|---|
| `src/app/` | REAPER extension entry point | | `src/app/` | REAPER extension entry point |
| `src/core/audio/` | pure audio-data math | | `src/core/audio/` | pure audio-data math |
| `src/core/capture/` | pure logic behind the capture pillar | | `src/core/capture/` | pure logic behind the capture pillar |
| `src/core/instrument/` | pure VST3-instrument core (engine / map / note / ui) | | `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / ui) |
| `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages | | `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets | | `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
| `src/core/json/` | the hand-rolled JSON lexical layer | | `src/core/json/` | the hand-rolled JSON lexical layer |
@@ -208,6 +209,26 @@ Plan-style docs live under `docs/`:
- **Relative paths only** in the persisted `BankIndex`. - **Relative paths only** in the persisted `BankIndex`.
- **Capture FX scope:** two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal. - **Capture FX scope:** two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal.
## The resample bake — the one crossing from instrument into bank
A click inside the ReaSampler 9000 editor bakes the dialed sound into a bank capture. The
split is: **the instrument renders, the extension banks.** The instrument produces the audio
on its own voice path in its own process (so the bake is the object code that made the sound
the user approved, immune to engine-version skew between the two artifacts), stages it
outside the bank folder, and invokes ONE extension action over the VST3 host bridge; the
extension lands it and answers over the same per-instance key, synchronously, inside that
call. Consequences that bind:
- **The extension's link graph does not gain the voice engine.** `sampler_core` /
`pitch_shift` / the filter are NOT linked into `reaper_reasampler` — a link edge to any of
them means the design drifted back to an extension-side render.
- **No arrange mutation and no deletion.** The bake writes a file plus an index entry, like
every other capture. "Replace" means the bank entry now denotes the recapture; the
superseded file survives on disk until a prune reclaims it — the iterate loop's recovery
floor.
- **The bake adds nothing to `process()`.** It renders on the UI thread over a separate
`VoiceEngine`, with the live-parameter block detached.
## Non-goals / guardrails ## Non-goals / guardrails
- No auto-insertion of captures into the arrange (see the load-bearing principle). - No auto-insertion of captures into the arrange (see the load-bearing principle).
+5 -1
View File
@@ -10,6 +10,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/capture/capture.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp ${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp ${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
@@ -44,7 +45,10 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp ${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp ${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
) )
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage) target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument
# renders its own bake in its own process, which is what keeps the extension's link graph
# free of the voice engine — a link edge to it here means the design drifted.
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both # OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
+9
View File
@@ -26,6 +26,8 @@
#include "shell/actions/action_registry.h" // the registration table #include "shell/actions/action_registry.h" // the registration table
#include "shell/actions/bank_actions.h" // multi-bank action family #include "shell/actions/bank_actions.h" // multi-bank action family
#include "shell/actions/design_view_actions.h" // Design View action family #include "shell/actions/design_view_actions.h" // Design View action family
#include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id)
#include "shell/capture/bake_land.h" // resample-bake landing action body
#include "shell/capture/capture_batch.h" // batch + recapture action bodies #include "shell/capture/capture_batch.h" // batch + recapture action bodies
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies #include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver #include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
@@ -86,6 +88,7 @@ static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session)
static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); } static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); }
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); } static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); } static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
static void RunShowVersion(int) { static void RunShowVersion(int) {
// On-demand only — no unconditional startup print (routine console chatter pops // On-demand only — no unconditional startup print (routine console chatter pops
// the console window). // the console window).
@@ -131,6 +134,12 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
&RunCancelRealtime}); &RunCancelRealtime});
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source", rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
&RunRecaptureFromSource}); &RunRecaptureFromSource});
// Invoked by a ReaSampler 9000 instance over the VST3 host bridge (and bindable, so a
// stranded request can be landed by hand). The suffix is the wire contract itself —
// core/wire/bake_wire owns the spelling both artifacts read.
rows.push_back({reasampler::wire::kBakeActionSuffix,
"land pending ReaSampler 9000 resample bake",
&RunResampleBake});
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion}); rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
return rows; return rows;
+5 -2
View File
@@ -1,8 +1,8 @@
# src/core/instrument — pure VST3-instrument core (engine / map / note / ui) # src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)
## Scope ## Scope
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in four The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
subdirectories: subdirectories:
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting, - **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
@@ -14,6 +14,9 @@ subdirectories:
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo - **`note/`** — the programmed capture-signal model: musical-division note length, tempo
resolution, and anchored start/end offsets — the one record and resolver a resolution, and anchored start/end offsets — the one record and resolver a
capture-signal popup and the offline bake read from, so they cannot diverge. capture-signal popup and the offline bake read from, so they cannot diverge.
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
window, the offline render over a voice engine built for that render alone, and the
ratified post-bake reset. See `bake/CLAUDE.md`.
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band - **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
interiors, waveform, keyboard strip, capture browser, param controls, envelope interiors, waveform, keyboard strip, capture browser, param controls, envelope
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
+2
View File
@@ -2,6 +2,8 @@ add_subdirectory(engine)
add_subdirectory(map) add_subdirectory(map)
add_subdirectory(note) add_subdirectory(note)
add_subdirectory(ui) add_subdirectory(ui)
# Last: bake composes the three above it.
add_subdirectory(bake)
# The spline EG spans all three: the shared curve + its RT cursor (engine), the dual-state # The spline EG spans all three: the shared curve + its RT cursor (engine), the dual-state
# persistence (map), and the point-editing grammar (ui). Declared here because no one # persistence (map), and the point-editing grammar (ui). Declared here because no one
+61
View File
@@ -0,0 +1,61 @@
# src/core/instrument/bake — the resample bake's pure half
## Scope
The offline pass that turns the dialed instrument into a file, and the reset that hands the
instrument back neutral afterwards. A fifth peer of `engine/` / `map/` / `note/` / `ui/`
under `core/instrument/`, pure by the same rule — no REAPER types, no VST3 types, no host.
It is neither engine (it owns no voice), mapping (it resolves no capture), nor note (it
holds no program): it is the *composition* of the three into one render, plus the one
decision about what the render made obsolete.
## Invariants
- **The bake renders on its OWN engine, never the live one.** `renderBake` takes its
`SampleData` BY VALUE and detaches `SampleData::live` before constructing a `VoiceEngine`
for the render alone. Two consequences, both load-bearing: the audio thread's live block
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::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 whole signal chain is printed, master gain included** — the gain multiply in
`bake_render.cpp` carries the argument for why.
- **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.
`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 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
both the parameter set and the post-mixer master gain.
## Gotchas
- **`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.
+15
View File
@@ -0,0 +1,15 @@
# 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
SOURCES bake_render.cpp
LINK PUBLIC bake_plan sampler_core)
reasampler_test(bake_render LINK bake_render)
# sample_map carries InstrumentParams, which is the whole of what a reset rewrites.
reasampler_pure_library(bake_reset SOURCES bake_reset.cpp LINK PUBLIC sample_map)
reasampler_test(bake_reset LINK bake_reset)
+107
View File
@@ -0,0 +1,107 @@
// See bake_plan.h.
#include "core/instrument/bake/bake_plan.h"
#include <algorithm>
#include <cmath>
#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<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(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;
}
std::optional<BakePlan> 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<int>(resolved.velocity), 1, 127);
return plan;
}
} // namespace reasampler::instrument::bake
+58
View File
@@ -0,0 +1,58 @@
// 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.
#pragma once
#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 {
// 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 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. 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; // 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, 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);
} // namespace reasampler::instrument::bake
+90
View File
@@ -0,0 +1,90 @@
// See bake_render.h.
#include "core/instrument/bake/bake_render.h"
#include <algorithm>
#include <cmath>
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::instrument::bake {
namespace {
// 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, double masterGainLinear) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
// Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan
// (planBake already bounds both — bake_plan.cpp) could otherwise carry leadInFrames
// near INT64_MAX and signed-overflow inside the guard meant to catch exactly that.
if (plan.leadInFrames < 0 || plan.leadInFrames > kMaxBakeFrames ||
plan.totalFrames > kMaxBakeFrames) {
return out;
}
if (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
// this SampleData's own play params, which is what the bake is meant to print.
sample.live = nullptr;
const int channels = sample.channelCount();
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.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(plan.sampleRate) / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
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. 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);
const auto n = static_cast<std::size_t>(chunk);
if (channels == 2) engine.render(left.data() + at, right.data() + at, n);
else engine.render(left.data() + at, n);
pos += chunk;
}
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));
// Printed here rather than left for the processor: resetAfterBake hands master gain
// back to unity, so a render that only summed voices would return every iteration
// shifted by 1/gain, and a gain dialed to silence would come back at full level. 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[lead + f] * gain;
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain;
}
return out;
}
} // namespace reasampler::instrument::bake
+39
View File
@@ -0,0 +1,39 @@
// 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. Takes its SampleData BY
// VALUE for the reason this directory's CLAUDE.md records.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/bake/bake_plan.h"
#include "core/instrument/engine/play_params.h"
namespace reasampler::instrument::bake {
using audio::AudioSample;
struct BakeAudio {
std::vector<AudioSample> interleaved; // [f0c0, f0c1, f1c0, …]
int channelCount = 0; // 0 = nothing rendered
int sampleRate = 0;
std::int64_t frameCount() const {
return channelCount > 0
? static_cast<std::int64_t>(interleaved.size()) / channelCount
: 0;
}
bool empty() const { return frameCount() == 0; }
};
// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the
// post-mixer gain the processor applies after the engine; see the gain multiply in
// bake_render.cpp for why it is printed here rather than left to the processor. 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
+29
View File
@@ -0,0 +1,29 @@
// See bake_reset.h.
#include "core/instrument/bake/bake_reset.h"
namespace reasampler::instrument::bake {
BakeReset resetAfterBake(const map::InstrumentParams& dialed) {
BakeReset out;
// The root is what the note was rendered at, so it is exactly what the new capture
// plays back at unity — resetting it would detune every following iteration.
out.params.rootOverride = dialed.rootOverride;
// How far pitch tracks the keyboard is a fact about the mapping; a single rendered
// note carries no trace of it.
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;
}
} // namespace reasampler::instrument::bake
+25
View File
@@ -0,0 +1,25 @@
// bake_reset — hand the instrument back neutral after a bake: the dialed processing now
// lives in the recaptured audio, so the controls that produced it return to their defaults.
//
// The rule, ratified by Daniel: a control resets iff its effect is in the printed audio; a
// MAPPING fact survives, because it describes how the file is played, not how it was made.
#pragma once
#include "core/instrument/map/sample_map.h" // InstrumentParams
namespace reasampler::instrument::bake {
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
// parameter set; it is answered here because renderBake prints it into the file (see
// bake_render.cpp's gain multiply) rather than left to the shell.
struct BakeReset {
map::InstrumentParams params;
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
};
// 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
+6 -2
View File
@@ -42,7 +42,7 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; }; const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); }; const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview. The // The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview, bake. The
// velocity-curve button that used to sit here now lives in the deck's VELOCITY group. // velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
const int navH = std::min(kRunButtonH, row.height); const int navH = std::min(kRunButtonH, row.height);
const int navTop = topFor(navH); const int navTop = topFor(navH);
@@ -72,9 +72,13 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
r.preview = Rect::ltrb(leftOf(prevRight, kPreviewBtnW), prevTop, prevRight, r.preview = Rect::ltrb(leftOf(prevRight, kPreviewBtnW), prevTop, prevRight,
prevTop + std::min(kRunButtonH, row.height)); prevTop + std::min(kRunButtonH, row.height));
const int bakeRight = leftOf(r.preview.x, kRunGap);
r.bake = Rect::ltrb(leftOf(bakeRight, kBakeButtonWidth), prevTop, bakeRight,
prevTop + std::min(kRunButtonH, row.height));
// The title takes what the run leaves; clamped so a narrow window collapses it rather // The title takes what the run leaves; clamped so a narrow window collapses it rather
// than inverting it. // than inverting it.
r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.preview.x - kRunGap), r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.bake.x - kRunGap),
row.bottom()); row.bottom());
if (r.controls.empty()) return r; if (r.controls.empty()) return r;
+3 -1
View File
@@ -9,6 +9,7 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
inline constexpr int kBakeButtonWidth = 54; // the resample-bake trigger
// Every interactive rect inside the chrome band, in one pass so draw and hit-test cannot // Every interactive rect inside the chrome band, in one pass so draw and hit-test cannot
// derive them differently. The toolbar's fixed run is right-anchored and the title takes // derive them differently. The toolbar's fixed run is right-anchored and the title takes
@@ -17,7 +18,8 @@ inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
struct ChromeRects { struct ChromeRects {
Rect toolbar; // full-width top row Rect toolbar; // full-width top row
Rect title; // the title text slot: the toolbar left of the control run Rect title; // the title text slot: the toolbar left of the control run
Rect preview; // ---- the right-anchored run, left to right ---- Rect bake; // ---- the right-anchored run, left to right ----
Rect preview;
Rect velCell; // preview-velocity knob cell (knob + label band) Rect velCell; // preview-velocity knob cell (knob + label band)
Rect velKnob; Rect velKnob;
Rect velLabel; Rect velLabel;
+6
View File
@@ -69,6 +69,12 @@ sits on.
- `bank_model``Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart. - `bank_model``Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart.
- `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface. - `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface.
- `slot_map` (`core/model`) — the gap-preserving display-position carrier for ONE bank (sample id → slot, ≥0), extracted from `bank_book` (Q-W1): append/remove/reorder (insert-before-and-shift)/`reconcile` against live membership, `resetDense` migration seed, JSON round-trip. Wrapped (not merged) by `bank_book`. - `slot_map` (`core/model`) — the gap-preserving display-position carrier for ONE bank (sample id → slot, ≥0), extracted from `bank_book` (Q-W1): append/remove/reorder (insert-before-and-shift)/`reconcile` against live membership, `resetDense` migration seed, JSON round-trip. Wrapped (not merged) by `bank_book`.
- `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. **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. - `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 ## Gotchas
+4
View File
@@ -9,6 +9,10 @@ reasampler_pure_library(bank_book
LINK PUBLIC bank_model slot_map PRIVATE json) LINK PUBLIC bank_model slot_map PRIVATE json)
reasampler_test(bank_book LINK bank_book) reasampler_test(bank_book LINK bank_book)
# Links nothing: a display name is a string rule, not a bank operation.
reasampler_pure_library(resample_name SOURCES resample_name.cpp)
reasampler_test(resample_name LINK resample_name)
reasampler_pure_library(provenance SOURCES provenance.cpp LINK PRIVATE wire) reasampler_pure_library(provenance SOURCES provenance.cpp LINK PRIVATE wire)
# bank_model: the test proves the recorded recipe survives the Sample-JSON round-trip. # bank_model: the test proves the recorded recipe survives the Sample-JSON round-trip.
reasampler_test(provenance LINK provenance bank_model) reasampler_test(provenance LINK provenance bank_model)
+49
View File
@@ -0,0 +1,49 @@
// See resample_name.h.
#include "core/model/resample_name.h"
#include <cctype>
namespace reasampler::model {
namespace {
constexpr const char* kFallbackStem = "resample";
constexpr int kFirstIteration = 2; // the source itself is iteration 1
// The " r<digits>" tail, if the name ends in one and the digits are a whole number > 0.
// Returns 0 (no tail) otherwise; `stemLength` is then left untouched.
int trailingIteration(const std::string& name, std::size_t& stemLength) {
std::size_t digitsBegin = name.size();
while (digitsBegin > 0 && std::isdigit(static_cast<unsigned char>(name[digitsBegin - 1])))
--digitsBegin;
if (digitsBegin == name.size()) return 0; // no digits at the end
if (digitsBegin < 2) return 0; // no room for " r"
if (name[digitsBegin - 1] != 'r' || name[digitsBegin - 2] != ' ') return 0;
// Overflow-safe accumulate: a pathological digit run stops counting rather than
// wrapping into a small number and silently reusing an existing name.
int value = 0;
for (std::size_t i = digitsBegin; i < name.size(); ++i) {
if (value > 1000000) return 0;
value = value * 10 + (name[i] - '0');
}
if (value <= 0) return 0;
stemLength = digitsBegin - 2;
return value;
}
} // namespace
std::string nextIterationName(const std::string& sourceName) {
if (sourceName.empty())
return std::string(kFallbackStem) + " r" + std::to_string(kFirstIteration);
std::size_t stemLength = sourceName.size();
const int current = trailingIteration(sourceName, stemLength);
if (current > 0)
return sourceName.substr(0, stemLength) + " r" + std::to_string(current + 1);
return sourceName + " r" + std::to_string(kFirstIteration);
}
} // namespace reasampler::model
+19
View File
@@ -0,0 +1,19 @@
#pragma once
// resample_name — the display name a resample's new bank entry takes, so a repeated bake
// reads as one iteration chain in the browser rather than as N unrelated captures.
//
// The chain is legible in the NAME only; the machine-readable lineage is
// OriginRecord::parentSampleId, written at birth. This is presentation, and deliberately
// not parsed back into a lineage by anything.
#include <string>
namespace reasampler::model {
// "Kick" -> "Kick r2" -> "Kick r3". A name already carrying an " r<N>" tail increments it
// rather than stacking a second one; an empty name becomes "resample r2". A tail that is
// not a whole positive number (" r", " r0", " rx") is left alone and a fresh " r2"
// appended, because it was never one of ours.
std::string nextIterationName(const std::string& sourceName);
} // namespace reasampler::model
+4 -1
View File
@@ -72,7 +72,10 @@ birth record is written for every system-created file, ambiguous parentage or no
- `tracking_authority` — the one decision surface: `pruneProtection` (the `owned` and - `tracking_authority` — the one decision surface: `pruneProtection` (the `owned` and
held-path inputs prune's set algebra consumes, plus the blocked/blockers verdict) held-path inputs prune's set algebra consumes, plus the blocked/blockers verdict)
and `tiedUsageExists` (`Yes` / `No` / `Indeterminate`, per capture, excluding the and `tiedUsageExists` (`Yes` / `No` / `Indeterminate`, per capture, excluding the
asker's own usage key). Both read one borrowed `TrackingState`. asker's own usage key). Both read one borrowed `TrackingState`. `resampleLanding` is
the resample's branch off that answer: `Replace` only on a definite `No`, since
`AddDistinct` disturbs no existing holder and is therefore the non-destructive side of
this question — `Indeterminate` lands with `Yes`.
## Gotchas ## Gotchas
+4
View File
@@ -42,4 +42,8 @@ Answer tiedUsageExists(const TrackingState& state, const std::string& capturePat
return Answer::No; return Answer::No;
} }
Landing resampleLanding(Answer answer) {
return answer == Answer::No ? Landing::Replace : Landing::AddDistinct;
}
} // namespace reasampler::tracking } // namespace reasampler::tracking
+9
View File
@@ -62,4 +62,13 @@ enum class Answer { No, Yes, Indeterminate };
Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath, Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath,
const std::string& ownUsageKey); const std::string& ownUsageKey);
// What a resample does with its result: take over the source's bank entry, or land beside
// it as a new one.
enum class Landing { Replace, AddDistinct };
// Replace only on a definite No. `Indeterminate` takes the same branch as `Yes` because
// AddDistinct disturbs no existing holder — it is the non-destructive side of this
// question, which is the rule the whole directory is written to.
Landing resampleLanding(Answer answer);
} // namespace reasampler::tracking } // namespace reasampler::tracking
+4 -2
View File
@@ -44,8 +44,9 @@ This directory owns two cross-artifact contracts specifically:
namespace via `reaper_bridge::writeUsageExtState` — an entry point that is namespace via `reaper_bridge::writeUsageExtState` — an entry point that is
**prefix-guarded** (accepts only `rsusage_`-prefixed keys, refuses all others), so the **prefix-guarded** (accepts only `rsusage_`-prefixed keys, refuses all others), so the
read-only-bank invariant is structurally enforced. Direction: the instrument writes read-only-bank invariant is structurally enforced. Direction: the instrument writes
usage keys; the extension reads them — the one sanctioned instrument→ext-state write, usage keys; the extension reads them — one of the two sanctioned instrument→ext-state
a deliberate exception analogous to `assignment_request` on the other wire. 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 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 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 terminate-time clear would strip a still-live instance's record); liveness is decided
@@ -79,6 +80,7 @@ This directory owns two cross-artifact contracts specifically:
- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory. - `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory.
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge. - `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge. - `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`.
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure. - `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it. - `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
+5
View File
@@ -4,6 +4,11 @@ reasampler_test(wire LINK wire)
reasampler_pure_library(assignment_request SOURCES assignment_request.cpp LINK PRIVATE wire) reasampler_pure_library(assignment_request SOURCES assignment_request.cpp LINK PRIVATE wire)
reasampler_test(assignment_request LINK assignment_request) reasampler_test(assignment_request LINK assignment_request)
# app_version is PUBLIC: the action's lookup name is channel-qualified, so a consumer that
# resolves the action reads the same channel identity the extension registered under.
reasampler_pure_library(bake_wire SOURCES bake_wire.cpp LINK PRIVATE wire PUBLIC app_version)
reasampler_test(bake_wire LINK bake_wire)
reasampler_pure_library(sample_usage SOURCES sample_usage.cpp LINK PRIVATE wire) reasampler_pure_library(sample_usage SOURCES sample_usage.cpp LINK PRIVATE wire)
# prune_reconcile composes the protection proof at the pure layer: a capture held by a live # prune_reconcile composes the protection proof at the pure layer: a capture held by a live
# instance lands in the referenced union, so pruneOrphans can never emit it. # instance lands in the referenced union, so pruneOrphans can never emit it.
+125
View File
@@ -0,0 +1,125 @@
// bake_wire.cpp — see bake_wire.h. Pure: standard library only.
#include "core/wire/bake_wire.h"
#include "core/version/app_version.h"
#include "core/wire/wire.h"
namespace reasampler::wire {
namespace {
constexpr const char* kRequestMagic = "rsbakereq1";
constexpr const char* kOutcomeMagic = "rsbakeout1";
using wire::putField;
using Cursor = wire::Cursor;
// An unrecognized integer is `Failed`, not a parse error: the two artifacts ship
// independently, and a newer extension naming a failure this build has no word for must
// still read as a failure rather than as Ok (which is what the numeric default would be).
BakeStatus statusFromWire(int raw) {
switch (static_cast<BakeStatus>(raw)) {
case BakeStatus::Ok:
case BakeStatus::Failed:
case BakeStatus::NoProject:
case BakeStatus::StagedMissing:
case BakeStatus::NoSource:
case BakeStatus::IndexRejected:
case BakeStatus::WrongProject:
return static_cast<BakeStatus>(raw);
}
return BakeStatus::Failed;
}
} // namespace
std::string bakeActionLookupName() {
return "_" + version::channelCommandId(kBakeActionSuffix);
}
bool BakeRequest::operator==(const BakeRequest& o) const {
return instanceGuid == o.instanceGuid && stagedFilePath == o.stagedFilePath &&
sourceSampleId == o.sourceSampleId &&
sourceRelativePath == o.sourceRelativePath &&
sourceDisplayName == o.sourceDisplayName && ownUsageKey == o.ownUsageKey &&
rootNote == o.rootNote && generation == o.generation;
}
bool BakeOutcome::operator==(const BakeOutcome& o) const {
return status == o.status && sampleId == o.sampleId &&
relativePath == o.relativePath && displayName == o.displayName &&
rootNote == o.rootNote && channelCount == o.channelCount &&
replaced == o.replaced && message == o.message && generation == o.generation;
}
std::string encodeBakeRequest(const BakeRequest& req) {
std::string out = kRequestMagic;
putField(out, req.instanceGuid);
putField(out, req.stagedFilePath);
putField(out, req.sourceSampleId);
putField(out, req.sourceRelativePath);
putField(out, req.sourceDisplayName);
putField(out, req.ownUsageKey);
putField(out, std::to_string(req.rootNote));
putField(out, std::to_string(req.generation));
return out;
}
std::optional<BakeRequest> decodeBakeRequest(const std::string& wire) {
Cursor cur(wire);
if (!cur.literal(kRequestMagic)) return std::nullopt;
BakeRequest req;
if (!cur.field(req.instanceGuid)) return std::nullopt;
if (!cur.field(req.stagedFilePath)) return std::nullopt;
if (!cur.field(req.sourceSampleId)) return std::nullopt;
if (!cur.field(req.sourceRelativePath)) return std::nullopt;
if (!cur.field(req.sourceDisplayName)) return std::nullopt;
if (!cur.field(req.ownUsageKey)) return std::nullopt;
if (!cur.fieldInt(req.rootNote)) return std::nullopt;
if (!cur.fieldInt64(req.generation)) return std::nullopt;
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
return req;
}
std::string encodeBakeOutcome(const BakeOutcome& outcome) {
std::string out = kOutcomeMagic;
putField(out, std::to_string(static_cast<int>(outcome.status)));
putField(out, outcome.sampleId);
putField(out, outcome.relativePath);
putField(out, outcome.displayName);
putField(out, std::to_string(outcome.rootNote));
putField(out, std::to_string(outcome.channelCount));
putField(out, outcome.replaced ? "1" : "0");
putField(out, outcome.message);
putField(out, std::to_string(outcome.generation));
return out;
}
std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire) {
Cursor cur(wire);
if (!cur.literal(kOutcomeMagic)) return std::nullopt;
BakeOutcome outcome;
int rawStatus = 0;
std::string replaced;
if (!cur.fieldInt(rawStatus)) return std::nullopt;
if (!cur.field(outcome.sampleId)) return std::nullopt;
if (!cur.field(outcome.relativePath)) return std::nullopt;
if (!cur.field(outcome.displayName)) return std::nullopt;
if (!cur.fieldInt(outcome.rootNote)) return std::nullopt;
if (!cur.fieldInt(outcome.channelCount)) return std::nullopt;
if (!cur.field(replaced)) return std::nullopt;
if (!cur.field(outcome.message)) return std::nullopt;
if (!cur.fieldInt64(outcome.generation)) return std::nullopt;
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
if (replaced != "0" && replaced != "1") return std::nullopt;
outcome.status = statusFromWire(rawStatus);
outcome.replaced = (replaced == "1");
return outcome;
}
} // namespace reasampler::wire
+82
View File
@@ -0,0 +1,82 @@
#pragma once
// 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.
//
// 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>
#include <string>
namespace reasampler::wire {
// The extension action the instrument invokes, as its command-id suffix. FOREVER-STABLE
// per channel like every other registered suffix. Both artifacts read this one symbol.
inline constexpr const char* kBakeActionSuffix = "RESAMPLE_BAKE";
// The NamedCommandLookup spelling of that action: a LEADING UNDERSCORE the
// rec->Register("command_id", …) string itself does not carry. The one place that
// underscore is written.
std::string bakeActionLookupName();
// What the instrument asks for. Every path is the instrument's own knowledge: it staged
// the file, it knows which capture it was resampling, and it knows its own usage key.
struct BakeRequest {
std::string instanceGuid; // the asking instance ("rsbake_<guid>" names the key)
std::string stagedFilePath; // ABSOLUTE, outside the bank folder; the instrument deletes it
std::string sourceSampleId; // the capture being resampled (lineage parent)
std::string sourceRelativePath; // its project-relative path — the tie query's subject
std::string sourceDisplayName; // what the new entry's name is derived from
std::string ownUsageKey; // "rsusage_<guid>" — excluded from the tie scan
int rootNote = 60;
std::int64_t generation = 0;
bool operator==(const BakeRequest& o) const;
bool operator!=(const BakeRequest& o) const { return !(*this == o); }
};
// Why a bake did not land. Values are WIRE INTEGERS — never renumber, only append; an
// unrecognized value decodes as `Failed` so a newer extension's vocabulary cannot make an
// older instrument read a failure as a success.
enum class BakeStatus : int {
Ok = 0,
Failed = 1, // generic / unrecognized
NoProject = 2, // unsaved project: the bank has no location
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
// instance can adopt it without a bank read — the same self-contained discipline the
// SampleRefs table exists for.
struct BakeOutcome {
BakeStatus status = BakeStatus::Failed;
std::string sampleId;
std::string relativePath;
std::string displayName;
int rootNote = 60;
int channelCount = 0;
bool replaced = false; // false = a distinct entry was added
std::string message; // human-readable, for the console
std::int64_t generation = 0; // echoes the request's
bool operator==(const BakeOutcome& o) const;
bool operator!=(const BakeOutcome& o) const { return !(*this == o); }
};
// Length-prefixed fields behind a magic+version tag, the house idiom, so arbitrary bytes
// in a path or a name round-trip whole.
std::string encodeBakeRequest(const BakeRequest& req);
std::string encodeBakeOutcome(const BakeOutcome& outcome);
// std::nullopt on malformed/truncated/trailing-garbage input, and on a tag this build does
// not read — a future version's record is refused rather than half-parsed. Round-trips.
std::optional<BakeRequest> decodeBakeRequest(const std::string& wire);
std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire);
} // namespace reasampler::wire
+5 -5
View File
@@ -11,11 +11,11 @@
// decisions (what to write on publish, which records count at prune time) are // decisions (what to write on publish, which records count at prune time) are
// pure and provable without a DAW; shells only move strings. // pure and provable without a DAW; shells only move strings.
// //
// The INSTRUMENT writes usage keys, the EXTENSION only reads them — the one // The INSTRUMENT writes usage keys, the EXTENSION only reads them — one of the
// sanctioned instrument->ext-state write. It does not weaken the // two sanctioned instrument->ext-state writes (bake_wire's request key is the
// read-only-bank invariant: the instrument publishes only its own // other). It does not weaken the read-only-bank invariant: the instrument
// per-instance key, never banks/view/tail/assign; the bridge's write entry // publishes only its own per-instance key, never banks/view/tail/assign; the
// point structurally accepts only "rsusage_"-prefixed keys. // bridge's write entry point structurally accepts only "rsusage_"-prefixed keys.
// //
// Every failure, ambiguity, or uncertainty here fails safe toward PROTECT (the // Every failure, ambiguity, or uncertainty here fails safe toward PROTECT (the
// territory-wide asymmetry, stated in core/tracking/CLAUDE.md). Three folds enforce // territory-wide asymmetry, stated in core/tracking/CLAUDE.md). Three folds enforce
+16 -3
View File
@@ -55,9 +55,10 @@ inline constexpr const char* kProjExtAssignKey = "assign_request";
// The INSTRUMENT writes one key per instance — "rsusage_<instanceGuid>" — carrying // The INSTRUMENT writes one key per instance — "rsusage_<instanceGuid>" — carrying
// the sample_usage wire record of every capture that instance holds; the EXTENSION // the sample_usage wire record of every capture that instance holds; the EXTENSION
// enumerates the prefix at prune-scan time so a held capture can never be pruned. // enumerates the prefix at prune-scan time so a held capture can never be pruned.
// This is the ONE sanctioned instrument-side ext-state write (it never mutates // This is one of the TWO sanctioned instrument-side ext-state writes (the bake
// banks/view/tail/assign; the bridge's write entry point structurally accepts only // request below is the other; neither mutates banks/view/tail/assign, and the
// this prefix). The "rs" qualifier keeps a future "usage_*"-prefixed key from being // bridge's write entry points structurally accept only these two prefixes). The
// "rs" qualifier keeps a future "usage_*"-prefixed key from being
// swept into the FX-liveness fold. FOREVER-STABLE — changing the prefix strands // swept into the FX-liveness fold. FOREVER-STABLE — changing the prefix strands
// every saved project's usage records (prune falls back to bank-references-only // every saved project's usage records (prune falls back to bank-references-only
// until instances republish). // until instances republish).
@@ -68,4 +69,16 @@ inline std::string usageKeyFor(const std::string& instanceGuid) {
return std::string(kProjExtUsageKeyPrefix) + instanceGuid; return std::string(kProjExtUsageKeyPrefix) + instanceGuid;
} }
// The resample bake's one key per asking instance — "rsbake_<instanceGuid>". The
// INSTRUMENT writes the request here and the EXTENSION writes its outcome back over the
// same key, within one synchronous action invocation; the instrument then clears it. The
// second of the two prefixes the instrument may write (the guard itself lives in
// reaper_bridge.h). Nothing durable is keyed off this spelling, but a stale value from a
// crashed session must still decode or be ignored — so treat it as fixed anyway.
inline constexpr const char* kProjExtBakeKeyPrefix = "rsbake_";
inline std::string bakeKeyFor(const std::string& instanceGuid) {
return std::string(kProjExtBakeKeyPrefix) + instanceGuid;
}
} // namespace reasampler } // namespace reasampler
+1
View File
@@ -37,6 +37,7 @@ detail not covered there:
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. - `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). - `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places). - `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded, and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert". A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header.
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action. - `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload. - `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26). - `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
+350
View File
@@ -0,0 +1,350 @@
// bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them
// extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState,
// SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h.
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/resample_name.h" // the iteration-chain display name
#include "core/tracking/tracking_authority.h" // resampleLanding (replace vs add-distinct)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/wire/bake_wire.h"
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
#include "ext_keys.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/session.h"
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_EnumProjExtState
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_SetProjExtState
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
namespace {
namespace fs = std::filesystem;
using model::AddResult;
using model::Sample;
using model::nextIterationName;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
// The whole chain is a call and a return inside ONE editor tick, so a request older than
// this has no reader left: it is a crash leftover, and it is CLEARED rather than landed.
// Without the guard a stranded request would be banked on the next unrelated instance's
// click, and the outcome written back to a key nobody will collect would persist into the
// .rpp forever.
constexpr std::int64_t kMaxRequestAgeSeconds = 30;
BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
out.message = std::move(message);
out.generation = generation;
return out;
}
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
// The bank holding `sampleId`, plus the entry itself. nullptr when no bank holds it.
const Sample* findSourceSample(const BankBook& book, const std::string& sampleId,
std::string& bankIdOut) {
for (const Bank& b : book.banks()) {
if (const Sample* s = b.index.query(sampleId)) {
bankIdOut = b.id;
return s;
}
}
return nullptr;
}
// Lands ONE request into `session`'s book, which must already be the book of the project
// `projectDir` names. Mutates book + ledger without persisting; the caller persists once for
// the batch. Every refusal path leaves the book untouched and writes no file, so a failed
// bake is invisible to the project.
BakeOutcome landOne(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
if (projectDir.empty())
return refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location", request.generation);
const std::vector<std::uint8_t> bytes = util::readFileBytes(request.stagedFilePath);
if (bytes.empty())
return refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
request.generation);
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid || layout.frameCount() == 0)
return refuse(BakeStatus::StagedMissing, "the staged render is not a usable WAV",
request.generation);
BankBook& book = session.book();
std::string bankId;
const Sample* source = findSourceSample(book, request.sourceSampleId, bankId);
if (!source)
return refuse(BakeStatus::NoSource, "the resampled capture is not in any bank",
request.generation);
// Copied, not aliased: every mutation below invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
const bool replace = (landing == tracking::Landing::Replace);
// WAV-aware hash: the bank's dedup key, and — on the add path only — the reason a
// byte-identical bake yields no second entry. Replace never dedups, matching
// updateSampleInPlace's own contract: an in-place refresh is not an insert.
const std::string contentHash = hashWavContent(bytes);
BakeOutcome out;
out.generation = request.generation;
out.rootNote = request.rootNote;
out.channelCount = static_cast<int>(layout.channelCount);
out.replaced = replace;
if (!replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(bankId)) {
if (const Sample* existing = index->findByHash(contentHash)) {
// Dedup BEFORE the disk write: this bake is bytes the bank already holds,
// so it re-points at that entry rather than depositing an unreferenced
// twin for the prune to reclaim later.
out.status = BakeStatus::Ok;
out.sampleId = existing->id;
out.relativePath = existing->relativePath;
out.displayName = existing->displayName;
out.message = "identical to an existing capture -- pointed at it";
return out;
}
}
}
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
// Seconds alone are not unique enough here: two bakes of the same source inside one
// second would derive the same file name, and the second would overwrite a file the
// first had just indexed. The content hash separates them, and leaves a re-bake of
// identical bytes idempotent rather than duplicated.
const std::string uniqueTag =
std::to_string(nowSec) +
(contentHash.empty() ? std::string{} : "-" + contentHash.substr(0, 8));
const std::string stem =
sourceCopy.displayName.empty() ? std::string("resample") : sourceCopy.displayName;
const BankPaths paths = deriveBankPaths(projectDir, stem, uniqueTag);
std::error_code ec;
fs::create_directories(paths.absoluteDir, ec); // idempotent; the write reports failure
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
if (!writeFileBytes(destPath, bytes))
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
request.generation);
Sample landed;
// Replace keeps the entry's identity and its slot — the sound iterated, it did not
// become a different capture. The superseded FILE is untouched: it stays on disk,
// unreferenced, until a prune reclaims it, which is the iterate loop's recovery floor.
landed.id = replace ? sourceCopy.id
: ("bake-" + uniqueTag + "-" + paths.fileName);
landed.displayName =
replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
landed.relativePath = paths.relativePath; // project-relative (invariant)
landed.channelCount = static_cast<int>(layout.channelCount);
landed.sampleRate = static_cast<int>(layout.sampleRate);
landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
landed.rootNote = request.rootNote; // rendered AT root — that is what makes it survive
landed.tier = model::Tier::Scratch;
landed.contentHash = contentHash;
landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
const bool indexed = replace
? book.updateSampleInPlace(sourceCopy.id, landed)
: (book.index(bankId) &&
book.index(bankId)->add(landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — the
// carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
// known bytes. Leaving it would deposit an untracked orphan per refused bake.
fs::remove(destPath, ec);
return refuse(BakeStatus::IndexRejected,
replace ? "the bank refused the replacement"
: "the bank refused the new capture",
request.generation);
}
session.recordCreated(landed, tracking::OriginKind::Capture);
out.status = BakeStatus::Ok;
out.sampleId = landed.id;
out.relativePath = landed.relativePath;
out.displayName = landed.displayName;
out.message = replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
// Every "rsbake_*" key in one project, keys only — the value can outgrow
// EnumProjExtState's fixed buffer, so it is read separately by the growing reader.
std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
std::vector<std::string> keys;
const std::string prefix = kProjExtBakeKeyPrefix;
char keyBuf[256];
for (int idx = 0;; ++idx) {
keyBuf[0] = '\0';
if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf,
static_cast<int>(sizeof(keyBuf)), nullptr, 0))
break;
const std::string key(keyBuf);
if (key.compare(0, prefix.size(), prefix) == 0) keys.push_back(key);
}
return keys;
}
std::optional<std::string> readKey(ReaProject* proj, const std::string& key) {
const auto read = wire::readProjExtStateGrowing([&](char* buf, int cap) {
return GetProjExtState(proj, kProjExtNamespace(), key.c_str(), buf, cap);
});
if (read.status != wire::GrowingExtStateRead::Status::Complete) return std::nullopt;
return read.value;
}
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
};
// Every open project tab. The request key lives in the INSTANCE's project, which is not
// necessarily the focused one, so the scan cannot assume the current tab holds it.
std::vector<OpenProject> openProjects() {
std::vector<OpenProject> out;
std::vector<char> buf(4096, '\0');
for (int idx = 0;; ++idx) {
buf[0] = '\0';
ReaProject* proj = EnumProjects(idx, buf.data(), static_cast<int>(buf.size()));
if (!proj) break;
out.push_back(OpenProject{proj, projectDirOfRpp(std::string(buf.data()))});
}
return out;
}
// One answer to write back after the undo block closes.
struct Answer {
ReaProject* proj = nullptr;
std::string key;
std::string wire; // empty = clear the key instead of answering it
std::string console; // empty = nothing to print
};
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
// Three projects have to agree before anything may land: the tab the request was found
// in, the tab whose book/ledger poll() last loaded, and the tab saveToActiveProject
// will persist into. Land on a disagreement and one tab's bake is written into
// another's bank. Whether REAPER makes Main_OnCommandEx's `proj` current for the
// action's duration is unverified in the DAW; this holds either way, and a request it
// cannot land is told why rather than silently ignored.
const void* loaded = session.loadedProject();
const void* active = EnumProjects(-1, nullptr, 0);
const bool sessionUsable = loaded != nullptr && loaded == active;
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
std::vector<Answer> answers;
int landedCount = 0;
Undo_BeginBlock2(nullptr);
for (const OpenProject& open : openProjects()) {
for (const std::string& key : pendingBakeKeys(open.proj)) {
const std::optional<std::string> raw = readKey(open.proj, key);
if (!raw) continue;
const std::optional<BakeRequest> request = wire::decodeBakeRequest(*raw);
// Not a request: an outcome this instance has not yet collected, or a value from
// a build we do not read. Leave it — the writing instance owns clearing its key.
if (!request) continue;
// Stale (either direction, so a clock moved backwards is caught too): clear the
// key, never answer it. The instance that could read an answer is gone.
if (std::llabs(nowSec - request->generation) > kMaxRequestAgeSeconds) {
answers.push_back(Answer{open.proj, key, std::string{}, std::string{}});
continue;
}
BakeOutcome outcome;
if (!sessionUsable || static_cast<const void*>(open.proj) != loaded) {
outcome = refuse(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension has "
"loaded -- focus that tab and try again",
request->generation);
} else {
outcome = landOne(session, open.dir, *request);
if (outcome.status == BakeStatus::Ok) ++landedCount;
}
// A WrongProject request left sitting in a tab this call did not come from
// prints again on every OTHER tab's bake, since the scan revisits every open
// project each time. `active` is this call's own proxy for "the invoking tab"
// (see the three-projects comment above) — only that tab's own refusal is fresh
// feedback to a user who just clicked bake; every other one is a rescan repeat.
const bool ownRequest = static_cast<const void*>(open.proj) == active;
std::string console;
if (outcome.status != BakeStatus::Ok &&
(outcome.status != BakeStatus::WrongProject || ownRequest)) {
console = "ReaSampler resample: " + outcome.message + ".\n";
}
answers.push_back(
Answer{open.proj, key, wire::encodeBakeOutcome(outcome), console});
}
}
if (landedCount > 0) {
// A bake changes what a live instance would play, so the generation bump rides the
// persist — every other open instance refreshes hands-free.
session.bumpBankGeneration();
const bool persisted = session.saveToActiveProject();
Undo_EndBlock2(nullptr,
persisted ? "ReaSampler: resample bake into bank" : "",
persisted ? UNDO_STATE_MISCCFG : 0);
} else {
Undo_EndBlock2(nullptr, "", 0); // nothing landed — record no empty undo point
}
// OUTSIDE the undo block on purpose: the answer is transient handshake state, and an
// undo point that captured it could restore the consumed request on the next Ctrl-Z —
// which the following bake would then re-land against a temp file that is long gone.
for (const Answer& answer : answers) {
SetProjExtState(answer.proj, kProjExtNamespace(), answer.key.c_str(),
answer.wire.c_str());
if (!answer.console.empty()) ShowConsoleMsg(answer.console.c_str());
}
if (landedCount > 0) bankPanelRefresh();
}
} // namespace reasampler::capture
+27
View File
@@ -0,0 +1,27 @@
#pragma once
// bake_land — the EXTENSION's half of the resample chain: take the file a ReaSampler 9000
// instance staged outside the bank, land it as a bank capture, and answer over the same
// per-instance key the request arrived on.
//
// Renders nothing (the instrument already did, through its own engine, in its own process
// — which is what makes the bake the sound the user approved). Writes a file into the bank
// folder and an index entry, and NOTHING else: no timeline item, and no deletion — the
// superseded audio survives until a prune reclaims it.
#include <string>
namespace reasampler {
class ReaSamplerSession;
}
namespace reasampler::capture {
// The bake action's body: scans EVERY open project tab for pending "rsbake_*" requests and
// lands the ones belonging to the project this session has loaded, one undo point for the
// batch. Normally there is exactly one — the instance that just invoked us. Runs
// synchronously inside that instance's Main_OnCommandEx call, so the answer is available to
// it the moment this returns. A request from another tab is refused, and one too old to
// have a reader left is cleared rather than answered.
void RunResampleBake(ReaSamplerSession& session);
} // namespace reasampler::capture
+16 -3
View File
@@ -83,8 +83,12 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
**Non-goals / guardrails.** **Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a - The instrument never captures and never inserts into the arrange. Playback is a
read-only act over the bank. Any instrument path that captures, places a timeline read-only act over the bank. Any instrument path that places a timeline item, or that
item, or writes back into the bank is a bug. writes bank state itself, is a bug. **The resample bake is not an exception to that
and does not widen it:** the instrument RENDERS its own sound and REQUESTS a landing;
the extension is what captures the file into the bank and writes the index. The
instrument's whole outbound surface is one prefix-guarded request key plus one action
id — see `instrument_bake` and `reaper_bridge` below.
- The instrument never ingests. Capture, import, and drop-ingest are *extension* - The instrument never ingests. Capture, import, and drop-ingest are *extension*
acts; the instrument only reads and plays. A drop onto the editor window (if ever acts; the instrument only reads and plays. A drop onto the editor window (if ever
shipped) is relayed to the extension as an ingest request — the instrument never shipped) is relayed to the extension as an ingest request — the instrument never
@@ -99,17 +103,26 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules ## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant. - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. - `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules, stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)* - `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
## Gotchas ## Gotchas
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
the very instance whose frame is on the stack. Deferring by one tick is same-thread and
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one.
- The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next.
- `editor_internal.h` is include-only — it has no TU of its own and must never become - `editor_internal.h` is include-only — it has no TU of its own and must never become
a public seam; only the `reasampler_editor` band-axis TUs include it. a public seam; only the `reasampler_editor` band-axis TUs include it.
- **The editor window class carries `CS_DBLCLKS`, which REPLACES the second button-down of - **The editor window class carries `CS_DBLCLKS`, which REPLACES the second button-down of
+3 -1
View File
@@ -66,6 +66,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
editor_input_curve.cpp editor_input_curve.cpp
editor_stroke.cpp editor_stroke.cpp
editor_platform.cpp editor_platform.cpp
instrument_bake.cpp
reasampler_embed.cpp reasampler_embed.cpp
reaper_bridge.cpp reaper_bridge.cpp
# draw_kit is compiled into each module rather than being a static library see root # draw_kit is compiled into each module rather than being a static library see root
@@ -88,7 +89,8 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
waveform_view bank_sync browser_scroll param_slider tooltip waveform_view bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
file_bytes curve_law stroke_aa) file_bytes curve_law stroke_aa
bake_plan bake_render bake_reset bake_wire wav_codec)
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives # SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC. # LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC}) target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
@@ -40,6 +40,14 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
invalidate(); invalidate();
return true; return true;
} }
// Resample bake. Arm only: onSyncTimer runs it a tick later, off this handler's stack
// and with no mouse capture held (instrument_bake.h says why that matters). A click
// while the extension is absent is swallowed — the button already paints Disabled.
if (contains(cr.bake, x, y)) {
if (bakeAvailable_) bakePending_ = true;
invalidate();
return true;
}
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never // Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
// jumps the value; the delta from the grab point maps via knobDragValue. // jumps the value; the delta from the grab point maps via knobDragValue.
if (contains(cr.velCell, x, y)) { if (contains(cr.velCell, x, y)) {
@@ -109,6 +117,7 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl
const ChromeRects& cr = fl.chrome; const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1}; if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1}; if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1}; if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1}; if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
@@ -112,6 +112,9 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
} else { } else {
title += " [host: no bridge]"; title += " [host: no bridge]";
} }
// A bake outcome outranks the identity readout while it lasts: the click's only other
// feedback is the sound itself, which is by design indistinguishable from before.
if (bakeMessageTicks_ > 0 && !bakeMessage_.empty()) title = bakeMessage_;
kitText(bmp, cr.title, title.c_str(), kToolbarFont, Role::TextPrimary); kitText(bmp, cr.title, title.c_str(), kToolbarFont, Role::TextPrimary);
// Browse: the picker. When nothing is loaded it is the empty state's dominant // Browse: the picker. When nothing is loaded it is the empty state's dominant
@@ -128,6 +131,20 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
// nothing picked there is no root, no preview and no channel decision to make. // nothing picked there is no root, no preview and no channel decision to make.
if (empty) return; if (empty) return;
// Resample bake. Disabled without the extension: the bank is the extension's surface,
// so with it absent there is no writer and the control must read unavailable rather
// than accept a click it cannot honour.
{
const KitButtonBox box{toKitBox(cr.bake)};
const InteractionState st =
!bakeAvailable_ ? InteractionState::Disabled
: (bakePending_ ? InteractionState::Active
: (isHovered(HoverKind::kBake, -1)
? InteractionState::Hover
: InteractionState::Rest));
drawButton(bmp, box, "Bake", st, /*warn=*/false);
}
// Preview-trigger button (fires the loaded capture at root through the live voice engine). // Preview-trigger button (fires the loaded capture at root through the live voice engine).
// A drawn play triangle rather than a label or an embedded image: it inherits the button's // A drawn play triangle rather than a label or an embedded image: it inherits the button's
// own foreground role, so it stays legible in every interaction state at no build cost. // own foreground role, so it stays legible in every interaction state at no build cost.
+27
View File
@@ -19,6 +19,7 @@
#include "ext_keys.h" #include "ext_keys.h"
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span) #include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span)
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter) #include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
#include "shell/instrument/instrument_bake.h" // the deferred bake the sync tick runs
#include "shell/instrument/reaper_bridge.h" #include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
@@ -39,6 +40,10 @@ using ui::ThumbnailKey;
using ui::thumbnailKeyString; using ui::thumbnailKeyString;
using util::readFileBytes; using util::readFileBytes;
// Sync ticks a bake outcome stays in the title band. Long enough to read, short enough
// that it does not shadow the identity readout.
constexpr int kBakeMessageTicks = 20;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) { : CPluginView(nullptr), processor_(processor) {
// The default IS the enforced floor (checkSizeConstraint) — the face opens at the size its // The default IS the enforced floor (checkSizeConstraint) — the face opens at the size its
@@ -104,6 +109,28 @@ void ReaSamplerEditor::onSyncTimer() {
if (!processor_) return; if (!processor_) return;
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
// can never be enabled on one tick and refuse on the next.
const bool available = bakeAvailable(processor_->bridge());
if (available != bakeAvailable_) {
bakeAvailable_ = available;
invalidate();
}
// The click armed it; this is where it runs — same thread, one tick later, no mouse
// capture held, and no REAPER action nested inside a mouse handler.
if (bakePending_) {
bakePending_ = false;
const BakeChainResult result = runBake(*processor_);
bakeMessage_ = result.message;
bakeMessageTicks_ = kBakeMessageTicks;
// A landed bake re-pointed the instance and reset the parameter set; re-snapshot
// so the face draws the new capture and its neutral controls.
if (result.ok) refreshFromBank();
invalidate();
}
if (bakeMessageTicks_ > 0 && --bakeMessageTicks_ == 0) invalidate();
// An open editor is the focused assignment target (thundering-herd policy); instances // An open editor is the focused assignment target (thundering-herd policy); instances
// with no editor open never poll (the timer is bound to the child window). // with no editor open never poll (the timer is bound to the child window).
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
+209
View File
@@ -0,0 +1,209 @@
// See instrument_bake.h.
#include "shell/instrument/instrument_bake.h"
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <vector>
#include "core/capture/wav_codec.h" // buildFloat32Wav (the bank byte format)
#include "core/instrument/bake/bake_plan.h"
#include "core/instrument/bake/bake_render.h"
#include "core/instrument/bake/bake_reset.h"
#include "core/instrument/note/tempo.h"
#include "core/wire/bake_wire.h"
#include "ext_keys.h"
#include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using capture::buildFloat32Wav;
using instrument::bake::defaultBakeProgram;
using instrument::bake::planBake;
using instrument::bake::renderBake;
using instrument::bake::resetAfterBake;
using instrument::map::SampleRefEntry;
using instrument::map::SelectedSample;
using instrument::note::Tempo;
using instrument::note::resolveNote;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
namespace {
namespace fs = std::filesystem;
// Deletes the staged file on EVERY exit path, success or failure — the same stack-RAII
// discipline the capture shell's FX-bypass guard follows. On success the extension has
// already COPIED the bytes into the bank, so the delete here is what keeps the temp from
// outliving the click. Best-effort: a file already gone is not an error.
//
// Orphan policy: a crash between the write and the invoke leaves one file in the OS temp
// directory, which is exactly what that directory is swept for. Nothing here scans or
// deletes files it did not itself create.
class StagedFileGuard {
public:
explicit StagedFileGuard(std::string path) : path_(std::move(path)) {}
~StagedFileGuard() {
if (path_.empty()) return;
std::error_code ec;
fs::remove(path_, ec);
}
StagedFileGuard(const StagedFileGuard&) = delete;
StagedFileGuard& operator=(const StagedFileGuard&) = delete;
private:
std::string path_;
};
// Clears the request key on every exit path. A key left holding a request would be picked
// up by the next bake's landing pass and re-run against a temp file that no longer exists.
class RequestKeyGuard {
public:
RequestKeyGuard(ReaperBridge& bridge, std::string key)
: bridge_(bridge), key_(std::move(key)) {}
~RequestKeyGuard() { bridge_.writeBakeExtState(key_, ""); }
RequestKeyGuard(const RequestKeyGuard&) = delete;
RequestKeyGuard& operator=(const RequestKeyGuard&) = delete;
private:
ReaperBridge& bridge_;
std::string key_;
};
BakeChainResult fail(std::string message) {
return BakeChainResult{false, std::move(message)};
}
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
} // namespace
bool bakeAvailable(ReaperBridge& bridge) {
return bridge.isConnected() &&
bridge.extensionActionAvailable(wire::bakeActionLookupName());
}
BakeChainResult runBake(ReaSamplerProcessor& processor) {
ReaperBridge& bridge = processor.bridge();
if (!bakeAvailable(bridge))
return fail("resample needs the ReaSampler extension loaded");
const std::string selectionId = processor.selectedSampleId();
if (selectionId.empty()) return fail("nothing loaded to resample");
// The whole ref entry, not just its SelectedSample: the display name that the new
// capture's is derived from sits beside the intrinsics.
const instrument::map::SampleRefs refs = processor.sampleRefs();
const SampleRefEntry* sourceEntry = nullptr;
for (const SampleRefEntry& e : refs)
if (e.sampleId == selectionId) { sourceEntry = &e; break; }
if (!sourceEntry) return fail("the loaded capture has no resolvable file");
const SelectedSample* source = &sourceEntry->ref;
const int sampleRate = static_cast<int>(processor.sampleRate());
if (sampleRate <= 0) return fail("the host has not reported a sample rate yet");
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
if (!tempo) return fail("the project tempo could not be read");
const InstrumentParams dialed = processor.instrumentParams();
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
// The snapshot comes first: the default program's window is derived from the sound it
// carries (a Gate release, a Trigger play span), not from a constant.
std::optional<SampleData> snapshot = processor.bakeSnapshot();
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
const auto plan = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, *tempo), *tempo), sampleRate,
rootNote);
if (!plan) return fail("the programmed capture window is empty");
const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), *plan, processor.masterGainLinear());
if (audio.empty()) return fail("the offline pass produced no audio");
// buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's
// own 32-bit-float contract, so the round trip is exact.
std::vector<double> interleaved(audio.interleaved.begin(), audio.interleaved.end());
const std::vector<std::uint8_t> bytes =
buildFloat32Wav(audio.channelCount, static_cast<std::uint32_t>(audio.sampleRate),
static_cast<std::size_t>(audio.frameCount()), interleaved);
const std::string instanceGuid = processor.usageInstanceGuid();
const std::int64_t stamp = static_cast<std::int64_t>(std::time(nullptr));
// OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray
// file there would read as a prune orphan.
std::error_code ec;
const fs::path stagedPath =
fs::temp_directory_path(ec) /
("reasampler_bake_" + instanceGuid + "_" + std::to_string(stamp) + ".wav");
if (ec) return fail("no writable temp directory for the staged render");
const std::string staged = stagedPath.string();
StagedFileGuard stagedGuard(staged);
if (!writeFileBytes(staged, bytes)) return fail("could not stage the rendered file");
BakeRequest request;
request.instanceGuid = instanceGuid;
request.stagedFilePath = staged;
request.sourceSampleId = selectionId;
request.sourceRelativePath = source->relativePath;
request.sourceDisplayName = sourceEntry->displayName;
request.ownUsageKey = usageKeyFor(instanceGuid);
request.rootNote = plan->note;
request.generation = stamp;
const std::string key = bakeKeyFor(instanceGuid);
RequestKeyGuard keyGuard(bridge, key);
if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request)))
return fail("could not publish the bake request");
// Synchronous: the extension's landing runs to completion inside this call and writes
// its outcome back over the same key before returning.
if (!bridge.invokeExtensionAction(wire::bakeActionLookupName()))
return fail("the ReaSampler extension's bake action is not registered");
const std::optional<std::string> raw = bridge.readReasamplerExtState(key);
const std::optional<BakeOutcome> outcome =
raw ? wire::decodeBakeOutcome(*raw) : std::nullopt;
// No outcome at all means the action never reached our request — an older extension
// that registers the id but does not read this key, or an invocation REAPER deferred.
if (!outcome) return fail("the extension did not answer the bake request");
if (outcome->generation != request.generation)
return fail("the extension answered a different bake request");
if (outcome->status != BakeStatus::Ok)
return fail(outcome->message.empty() ? std::string("the bake was refused")
: outcome->message);
SampleRefEntry entry;
entry.sampleId = outcome->sampleId;
entry.displayName = outcome->displayName;
entry.ref.relativePath = outcome->relativePath;
entry.ref.rootNote = outcome->rootNote;
entry.ref.channelCount = outcome->channelCount;
// No loop: the loop points shaped the render and are meaningless against the new file
// (bake_reset owns that rule for the parameter set; this is its bank-intrinsic peer).
const instrument::bake::BakeReset reset = resetAfterBake(dialed);
processor.adoptBakedCapture(entry, reset.params, reset.masterGainLinear);
// The extension's own wording, which distinguishes the three landings (replaced, added,
// and pointed at an identical existing entry) more precisely than this side can.
return BakeChainResult{true, "resampled -- " + outcome->message};
}
} // namespace reasampler::vst
+37
View File
@@ -0,0 +1,37 @@
// instrument_bake — the instrument's half of the resample chain: render the dialed sound
// offline, stage it outside the bank, ask the extension to bank it, then re-point and go
// neutral. UI thread only; nothing here is on any audio path.
//
// The extension is REQUIRED. Without it the bank has no writer, so the affordance reads
// Disabled and this refuses rather than half-baking.
#pragma once
#include <string>
namespace reasampler::vst {
class ReaSamplerProcessor;
class ReaperBridge;
// Whether a bake can run at all right now: a REAPER host, and the extension's bake action
// registered. Runs one NamedCommandLookup and builds one std::string per call — cost
// unmeasured. It is on the editor's sync tick because that is where the button's paint
// state is decided, and the control must never be enabled and then refuse.
bool bakeAvailable(ReaperBridge& bridge);
struct BakeChainResult {
bool ok = false;
std::string message; // always populated — the editor prints it either way
};
// Runs the whole chain against `processor`. On failure NOTHING has changed: no temp file
// survives, no bank entry was added, no assign was written, and the dialed state is
// exactly as it was before the click.
//
// MUST NOT be called from a mouse handler. It invokes a REAPER action synchronously — an
// offline landing nested inside a click, with the mouse captured, re-pointing the very
// instance whose frame is on the stack. The editor defers it to its own timer tick.
BakeChainResult runBake(ReaSamplerProcessor& processor);
} // namespace reasampler::vst
+74 -6
View File
@@ -77,6 +77,18 @@ std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
return out; return out;
} }
// The ONE decode + build the reload and the bake snapshot share, so a baked render can
// never be built from a different fold than the one the user is hearing.
std::optional<SampleData> buildFromRef(const SelectedSample& sel,
const InstrumentParams& params,
const std::string& projectDir, ChannelMode mode) {
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
if (!pcm) return std::nullopt;
SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm));
if (!sample.playable()) return std::nullopt;
return sample;
}
} // namespace } // namespace
std::string ReaSamplerProcessor::reloadInstrument() { std::string ReaSamplerProcessor::reloadInstrument() {
@@ -144,11 +156,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
channelModeExplicit_); channelModeExplicit_);
mode = channelMode_; mode = channelMode_;
} }
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel->relativePath, mode); if (std::optional<SampleData> decoded =
if (pcm) { buildFromRef(*sel, params, projectDir, mode)) {
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm)); sample = std::move(*decoded);
havePlayable = sample.playable(); havePlayable = true;
if (havePlayable) {
// Point the built snapshot at the instance's ONE live block and seed it from // Point the built snapshot at the instance's ONE live block and seed it from
// the very PlayParams the voices latch, so an untouched knob folds to the same // the very PlayParams the voices latch, so an untouched knob folds to the same
// frames the build resolved and a note-on with a live block sounds identical // frames the build resolved and a note-on with a live block sounds identical
@@ -164,7 +175,6 @@ std::string ReaSamplerProcessor::reloadInstrument() {
resolvedId = selId; // the concrete pick that resolved resolvedId = selId; // the concrete pick that resolved
} }
} }
}
if (havePlayable) { if (havePlayable) {
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs), // Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
@@ -190,6 +200,53 @@ std::string ReaSamplerProcessor::reloadInstrument() {
return resolvedId; return resolvedId;
} }
std::string ReaSamplerProcessor::usageInstanceGuid() {
std::lock_guard<std::mutex> lock(usageMutex_);
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
return instanceGuid_;
}
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
const std::string selId = selectedSampleId();
if (selId.empty()) return std::nullopt;
const InstrumentParams params = instrumentParams();
SampleRefs refs;
{
std::lock_guard<std::mutex> rl(refsMutex_);
refs = sampleRefs_;
}
const SelectedSample* sel = findRef(refs, selId);
if (!sel) return std::nullopt;
// No live block is attached: the bake renders the stored parameter set, which is what
// every knob has already written, rather than whatever the audio thread is observing.
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode());
}
void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
const InstrumentParams& reset,
double masterGainLinear) {
{
std::lock_guard<std::mutex> rl(refsMutex_);
bool replaced = false;
for (SampleRefEntry& existing : sampleRefs_) {
if (existing.sampleId == entry.sampleId) { existing = entry; replaced = true; break; }
}
if (!replaced) sampleRefs_.push_back(entry);
}
setSelectedSampleId(entry.sampleId);
setInstrumentParams(reset);
{
// ARMED, not stored: publishBuiltLocked applies it inside the same locked section as
// the pointer swap. Storing it here instead would put the reset gain under the OLD
// capture for the whole bridge-read-plus-WAV-decode the reload below runs first.
std::lock_guard<std::mutex> lock(reloadMutex_);
gainAtNextPublish_ = masterGainLinear;
}
// ONE reload for the re-point and the reset together: it decodes the new file and
// publishes the neutral parameters in the same swap.
reloadInstrument();
}
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
const std::vector<std::string>& ids) { const std::vector<std::string>& ids) {
if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do
@@ -240,6 +297,17 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
}), }),
graveyard_.end()); graveyard_.end());
LoadedInstrument* prev = live_.exchange(built.release()); LoadedInstrument* prev = live_.exchange(built.release());
// A bake's reset gain lands here rather than at its call site, so the gain and the
// capture it belongs to become audible to process() within one block of each other.
// live_.exchange above and the gain store below are two independent relaxed atomics
// with no ordering between them, so the honest bound is "within one block", not "the
// same instant" — a tail still ringing out of the drain does take the new gain, one
// gain sitting above every snapshot, the same shape as ONE BLOCK, ONE RATE (see
// builtSampleRate_).
if (gainAtNextPublish_) {
setMasterGainLinear(*gainAtNextPublish_);
gainAtNextPublish_.reset();
}
LoadedInstrument* evicted = draining_.exchange(prev); LoadedInstrument* evicted = draining_.exchange(prev);
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted)); if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
} }
+72 -11
View File
@@ -42,6 +42,10 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
setProjExtState_ = nullptr; setProjExtState_ = nullptr;
getTrackGuid_ = nullptr; getTrackGuid_ = nullptr;
guidToString_ = nullptr; guidToString_ = nullptr;
namedCommandLookup_ = nullptr;
mainOnCommandEx_ = nullptr;
getCursorPositionEx_ = nullptr;
timeMapGetTimeSigAtTime_ = nullptr;
hostApp_ = nullptr; hostApp_ = nullptr;
if (!context) return false; if (!context) return false;
@@ -69,6 +73,17 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
reaper->getReaperApi("GetTrackGUID")); reaper->getReaperApi("GetTrackGUID"));
guidToString_ = reinterpret_cast<GuidToStringFn>( guidToString_ = reinterpret_cast<GuidToStringFn>(
reaper->getReaperApi("guidToString")); reaper->getReaperApi("guidToString"));
// The bake crossing: resolve the extension's action id by name, then fire it against
// this instance's own project. All degrade to null gracefully — an unresolvable pair
// simply leaves the bake affordance disabled.
namedCommandLookup_ = reinterpret_cast<NamedCommandLookupFn>(
reaper->getReaperApi("NamedCommandLookup"));
mainOnCommandEx_ = reinterpret_cast<MainOnCommandExFn>(
reaper->getReaperApi("Main_OnCommandEx"));
getCursorPositionEx_ = reinterpret_cast<GetCursorPositionExFn>(
reaper->getReaperApi("GetCursorPositionEx"));
timeMapGetTimeSigAtTime_ = reinterpret_cast<TimeMapGetTimeSigAtTimeFn>(
reaper->getReaperApi("TimeMap_GetTimeSigAtTime"));
return getProjExtState_ != nullptr; return getProjExtState_ != nullptr;
} }
@@ -95,25 +110,71 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
return decodeGetProjExtState(read.apiReturn, read.value); return decodeGetProjExtState(read.apiReturn, read.value);
} }
bool ReaperBridge::writeUsageExtState(const std::string& usageKey, bool ReaperBridge::writeGuarded(const std::string& key, const std::string& value,
const std::string& value) { const char* requiredPrefix) {
if (!setProjExtState_ || !hostApp_) return false; if (!setProjExtState_ || !hostApp_) return false;
// Read-only-bank guard: this module writes usage keys and nothing else. A non- // Read-only-BANK guard: this module writes the two sanctioned per-instance prefixes
// "rsusage_" key is refused rather than widening the instrument's write surface // and nothing else. Any other key is refused rather than widening the instrument's
// (banks/view/tail/assign stay extension-owned). // write surface (banks/view/tail/assign stay extension-owned).
const std::string prefix = kProjExtUsageKeyPrefix; const std::string prefix = requiredPrefix;
if (usageKey.compare(0, prefix.size(), prefix) != 0) return false; if (key.compare(0, prefix.size(), prefix) != 0) return false;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_); auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3); // null = current project (same as reads) void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
// SetProjExtState returns the size of the extname's state — after storing a // SetProjExtState returns the size of the extname's state — after storing a
// non-empty value that's necessarily > 0, so <= 0 means the write did not land (the // non-empty value that's necessarily > 0, so <= 0 means the write did not land (the
// publish path retries next reload tick; a silent drop would leave holds unprotected). // publish path retries next reload tick; a silent drop would leave holds unprotected).
// A deliberate CLEAR (empty value) shrinks the state and can legitimately return 0,
// so it is reported as landed.
const int rv = const int rv =
setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str()); setProjExtState_(proj, kProjExtNamespace(), key.c_str(), value.c_str());
// Deliberately NO MarkProjectDirty: a usage change always rides a component-state return value.empty() ? true : rv > 0;
// change that already dirties the project. }
return rv > 0;
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
const std::string& value) {
return writeGuarded(usageKey, value, kProjExtUsageKeyPrefix);
}
bool ReaperBridge::writeBakeExtState(const std::string& bakeKey,
const std::string& value) {
return writeGuarded(bakeKey, value, kProjExtBakeKeyPrefix);
}
int ReaperBridge::lookupCommand(const std::string& commandName) {
if (!namedCommandLookup_ || commandName.empty()) return 0;
return namedCommandLookup_(commandName.c_str());
}
bool ReaperBridge::extensionActionAvailable(const std::string& commandName) {
return mainOnCommandEx_ != nullptr && lookupCommand(commandName) != 0;
}
bool ReaperBridge::invokeExtensionAction(const std::string& commandName) {
if (!mainOnCommandEx_ || !hostApp_) return false;
const int command = lookupCommand(commandName);
if (command == 0) return false;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
// getReaperParent(3), not the focused tab — the ask is that an instance in a background
// tab bakes into ITS OWN project's bank (see the header on what that does and does not
// guarantee).
void* proj = reaper->getReaperParent(3);
// flag 0 is the conventional "no modifier" value; the header documents no other, and
// the extension's hookcommand ignores it.
mainOnCommandEx_(command, 0, proj);
return true;
}
double ReaperBridge::projectTempoBpm() {
if (!timeMapGetTimeSigAtTime_ || !getCursorPositionEx_ || !hostApp_) return 0.0;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3);
int num = 0, denom = 0;
double tempo = 0.0;
timeMapGetTimeSigAtTime_(proj, getCursorPositionEx_(proj), &num, &denom, &tempo);
// The meter is read but not applied: the note model's beat is a quarter note by
// ruling (core/instrument/note/CLAUDE.md), so the undivided BPM is the right one.
return tempo;
} }
std::string ReaperBridge::currentTrackGuid() { std::string ReaperBridge::currentTrackGuid() {
+60 -8
View File
@@ -44,15 +44,49 @@ public:
// project or when unconnected. Not RT-safe. // project or when unconnected. Not RT-safe.
std::string activeProjectDir(); std::string activeProjectDir();
// Writes THIS INSTANCE's usage record: the ONE sanctioned instrument-side ext-state // The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one
// write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's usageKeyFor); any // key prefix and refusing every other key. That structural refusal is what keeps the
// other key is refused, enforcing the read-only-bank invariant structurally (banks/ // read-only-BANK invariant intact — banks/view/tail/assign stay unwritable from here —
// view/tail/assign stay unwritable from the instrument). Returns true iff written // and neither payload is bank state. Both return true iff the write landed (the
// (the SetProjExtState return is checked). NOT RT-safe — publish sites are the // SetProjExtState return is checked) and neither is RT-safe: the call sites are the
// off-audio-thread reload path only. Deliberately does NOT mark the project dirty: a // off-audio-thread reload path and the editor's UI tick.
// usage change always rides a component-state change that already does. //
// Neither marks the project dirty. A usage change always rides a component-state change
// that already does; a bake request is transient and is cleared in the same tick.
// THIS INSTANCE's usage record. `usageKey` MUST carry the "rsusage_" prefix
// (ext_keys.h's usageKeyFor).
bool writeUsageExtState(const std::string& usageKey, const std::string& value); bool writeUsageExtState(const std::string& usageKey, const std::string& value);
// THIS INSTANCE's resample-bake request. `bakeKey` MUST carry the "rsbake_" prefix
// (ext_keys.h's bakeKeyFor). An empty value clears the key.
bool writeBakeExtState(const std::string& bakeKey, const std::string& value);
// --- Extension action invocation (the bake crossing) ------------------------------
//
// `commandName` is the NamedCommandLookup spelling — the registered command_id string
// with a leading underscore, which the registration string itself does not carry.
// Whether the extension is loaded AND has registered this action. Used to paint the
// affordance: an unavailable action must read Disabled, never enabled-then-refusing.
bool extensionActionAvailable(const std::string& commandName);
// Fires the action with THIS INSTANCE's own project tab (getReaperParent(3)) as
// Main_OnCommandEx's `proj`, rather than leaving it to whichever tab is focused. What
// REAPER then makes current for the action's duration is NOT verified in the DAW, so
// this is a request, not a guarantee — the extension side re-derives which project a
// request came from and refuses one it cannot safely land. Returns false when the action
// is unregistered; the invocation itself reports nothing, so a caller learns the result
// from the state the action wrote, never from here. Must NOT be called from a mouse
// handler: it runs the extension's whole bake landing synchronously, and the action
// re-points this instance.
bool invokeExtensionAction(const std::string& commandName);
// The effective project tempo (BPM, quarter notes per minute) at the edit cursor of
// this instance's own project. 0.0 when unconnected or unresolvable — the caller
// refuses rather than substituting a tempo (no hardcoded rates or tempos in src/).
double projectTempoBpm();
// The canonical GUID string of the track hosting this FX instance (same rendering as // The canonical GUID string of the track hosting this FX instance (same rendering as
// the extension's track_guid::guidString, so usage records compare byte-equal // the extension's track_guid::guidString, so usage records compare byte-equal
// against its live-FX enumeration). Empty when unconnected or no track context (the // against its live-FX enumeration). Empty when unconnected or no track context (the
@@ -70,9 +104,14 @@ private:
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current // EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current
// tab) follows the active project, same convention as the persist shell. // tab) follows the active project, same convention as the persist shell.
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz); using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
// Used ONLY by writeUsageExtState (prefix-guarded) — see the read-only-bank note there. // Used ONLY by the two prefix-guarded writers — see the read-only-bank note there.
using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key, using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
const char* value); const char* value);
using NamedCommandLookupFn = int (*)(const char* commandName);
using MainOnCommandExFn = void (*)(int command, int flag, void* proj);
using GetCursorPositionExFn = double (*)(void* proj);
using TimeMapGetTimeSigAtTimeFn = void (*)(void* proj, double time, int* numOut,
int* denomOut, double* tempoOut);
// Opaque-pointer signatures so the header stays SDK-type-free; the GUID* is passed // Opaque-pointer signatures so the header stays SDK-type-free; the GUID* is passed
// straight through, never dereferenced here. // straight through, never dereferenced here.
using GetTrackGuidFn = void* (*)(void* tr); using GetTrackGuidFn = void* (*)(void* tr);
@@ -85,6 +124,19 @@ private:
SetProjExtStateFn setProjExtState_ = nullptr; SetProjExtStateFn setProjExtState_ = nullptr;
GetTrackGuidFn getTrackGuid_ = nullptr; GetTrackGuidFn getTrackGuid_ = nullptr;
GuidToStringFn guidToString_ = nullptr; GuidToStringFn guidToString_ = nullptr;
NamedCommandLookupFn namedCommandLookup_ = nullptr;
MainOnCommandExFn mainOnCommandEx_ = nullptr;
GetCursorPositionExFn getCursorPositionEx_ = nullptr;
TimeMapGetTimeSigAtTimeFn timeMapGetTimeSigAtTime_ = nullptr;
// The one prefix guard both public writers route through, so the two cannot diverge
// in how strictly they refuse a key.
bool writeGuarded(const std::string& key, const std::string& value,
const char* requiredPrefix);
// 0 when the action is not registered (the extension is absent or older). REAPER's
// documented "not found" return is 0 by convention only — the header does not state
// it — so every caller treats 0 as unavailable and never as a valid command id.
int lookupCommand(const std::string& commandName);
}; };
} // namespace reasampler::vst } // namespace reasampler::vst
+11
View File
@@ -121,6 +121,7 @@ private:
kChanMono, // the mono channel-mode segment kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment kChanStereo, // the stereo channel-mode segment
kPreview, // the preview-trigger button kPreview, // the preview-trigger button
kBake, // the resample-bake trigger
kControl, // a knob-deck element (index = control id) kControl, // a knob-deck element (index = control id)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id) kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id) kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
@@ -422,6 +423,16 @@ private:
// up. One note at a time — a fresh press releases the prior. // up. One note at a time — a fresh press releases the prior.
int previewingNote_ = -1; int previewingNote_ = -1;
// Resample bake. The click only ARMS it; the sync tick runs it. Running it inline
// would nest a synchronous REAPER action — which re-points this very instance — inside
// a mouse handler with the capture held.
bool bakePending_ = false;
// Whether the extension's bake action is registered, resolved on the same tick that
// governs the button's paint, so the control is never enabled and then refusing.
bool bakeAvailable_ = false;
std::string bakeMessage_; // last outcome, shown in the title band
int bakeMessageTicks_ = 0; // sync ticks the message survives
// The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only): // The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only):
// an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts // an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts
// down via the sync tick). Never ingests, never inserts a timeline item. // down via the sync tick). Never ingests, never inserts a timeline item.
@@ -11,6 +11,7 @@
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -26,6 +27,7 @@ namespace reasampler::vst {
using instrument::map::ComponentState; using instrument::map::ComponentState;
using instrument::map::InstrumentParams; using instrument::map::InstrumentParams;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs; using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault; using instrument::map::kPreviewVelocityDefault;
@@ -120,6 +122,21 @@ public:
// resolved selection id ("" if nothing loaded). // resolved selection id ("" if nothing loaded).
std::string reloadInstrument(); std::string reloadInstrument();
// The dialed sound as plain data for the offline bake: the SAME refs resolve + decode +
// build reloadInstrument runs, with no live block attached. Rebuilt rather than copied
// off the live snapshot because a tier-3 live edit leaves that snapshot's own play
// params stale on purpose — copying it would bake the pre-drag values. nullopt when
// nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O).
std::optional<SampleData> bakeSnapshot();
// Adopt a landed bake in ONE act: the new capture becomes this instance's ref and
// selection, the parameter set and master gain go neutral, and a single reload
// publishes all three together. The ordering is the whole point — anything published
// ahead of the re-point would apply neutral settings to the OLD capture, which is the
// one thing they are meaningless against. UI thread only.
void adoptBakedCapture(const SampleRefEntry& entry, const InstrumentParams& reset,
double masterGainLinear);
// What pollBankSync did this tick, so the editor can react only when something changed. // What pollBankSync did this tick, so the editor can react only when something changed.
struct BankSyncResult { struct BankSyncResult {
bool reloaded = false; // bank generation changed (or a legacy lift landed) -> reloaded bool reloaded = false; // bank generation changed (or a legacy lift landed) -> reloaded
@@ -203,6 +220,11 @@ public:
// fallback when the bank blob is unreadable. Guarded by refsMutex_. // fallback when the bank blob is unreadable. Guarded by refsMutex_.
SampleRefs sampleRefs(); SampleRefs sampleRefs();
// This instance's usage identity, minted here if it has never published. It names BOTH
// the "rsusage_" record the bake's tie query must exclude and the "rsbake_" request
// key, so the two can never name different instances. Off the audio thread.
std::string usageInstanceGuid();
private: private:
// If process() published that the drain instrument is fully idle, move it into the // If process() published that the drain instrument is fully idle, move it into the
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its // graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
@@ -283,6 +305,10 @@ private:
std::atomic<std::uint64_t> drainIdleGeneration_{0}; std::atomic<std::uint64_t> drainIdleGeneration_{0};
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// A master gain that must reach the audio thread in the SAME swap as the next publish —
// the bake's reset, which the old capture would be the wrong thing to apply it to.
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
std::optional<double> gainAtNextPublish_;
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the // The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread. // audio thread.
+4 -2
View File
@@ -14,7 +14,9 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
- **Prune is the single, exclusive file-deletion authority.** No bank op, no - **Prune is the single, exclusive file-deletion authority.** No bank op, no
capture op, no Design View op deletes a file; if any path other than prune capture op, no Design View op deletes a file; if any path other than prune
deletes a bank file, reject it in review. deletes a bank file, reject it in review. The ONE carve-out — a shell removing
a file it wrote itself moments earlier that no index ever referenced, wherever
it sits — is stated in full at `prune_fs.cpp`'s header and nowhere else.
- **Dry-run first, always; no silent deletion.** Prune reports before it deletes - **Dry-run first, always; no silent deletion.** Prune reports before it deletes
(orphan count, reclaimed size, and — for a small set — the files); actual (orphan count, reclaimed size, and — for a small set — the files); actual
deletion is a confirmed second step. No periodic/background sweep. deletion is a confirmed second step. No periodic/background sweep.
@@ -51,7 +53,7 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
## Modules ## Modules
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block. - `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state. - `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses. - `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
+26 -7
View File
@@ -2,13 +2,19 @@
// //
// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove // deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove
// on SWELL platforms) is the ONLY code in the system that deletes USER files — // on SWELL platforms) is the ONLY code in the system that deletes USER files —
// the sole deletion authority over the bank folder's bytes (a shell removing a // the sole deletion authority over the bank folder's bytes. THE carve-out, and
// transient scratch file it just created, e.g. the drop path's temp // its one home: a shell removing a file it wrote itself moments earlier and
// .vstpreset, is self-cleanup, not authority over user data). Deliberately // that no index ever referenced is self-cleanup, not authority over user data.
// file-local (anonymous namespace): nothing outside this TU can reach it, and // It covers a transient scratch file outside the bank (the drop path's temp
// this concentration must never spread. The safety-critical "which files are // .vstpreset) AND a bank-folder write whose index entry was then refused
// orphans" decision stays in the pure core (prune_reconcile); this TU only // (bake_land) — the discriminator is "did this call create it, and did anything
// enumerates, resolves, stats, and — after the confirm — executes. // ever reference it", not where it sits.
//
// deleteOrphanFile is deliberately file-local (anonymous namespace): nothing
// outside this TU can reach it, and this concentration must never spread. The
// safety-critical "which files are orphans" decision stays in the pure core
// (prune_reconcile); this TU only enumerates, resolves, stats, and — after the
// confirm — executes.
// //
// Compiled into the reaper_reasampler module. REAPER-facing only through the // Compiled into the reaper_reasampler module. REAPER-facing only through the
// persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly. // persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly.
@@ -204,6 +210,19 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
} // namespace } // namespace
tracking::Answer ReaSamplerSession::tiedUsageFor(const std::string& capturePath,
const std::string& ownUsageKey) const {
// The SECOND consumer of the same gather the prune scan above runs — deliberately
// next to it, so "both answers come out of one TrackingState" is structural rather
// than a rule two files have to remember.
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj) return tracking::Answer::Indeterminate;
const wire::UsageFoldResult usage = scanInstanceUsage(proj);
const tracking::TrackingState state{trackingStatus_, tracking_, usage};
return tracking::tiedUsageExists(state, capturePath, ownUsageKey);
}
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const { reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_); const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
reclaim::PruneReport report = reclaim::PruneReport report =
+16 -2
View File
@@ -7,8 +7,7 @@
// JSON bridge, GUID minting, bank-folder relocation (see ext_state_io.h). // JSON bridge, GUID minting, bank-folder relocation (see ext_state_io.h).
// * prune_fs.cpp — pruneDryRun/pruneOrphanSet/pruneReclaim: the prune // * prune_fs.cpp — pruneDryRun/pruneOrphanSet/pruneReclaim: the prune
// scan and THE SINGLE FILE-DELETION AUTHORITY over user files in the bank // scan and THE SINGLE FILE-DELETION AUTHORITY over user files in the bank
// folder. Nothing else in the system deletes bank-folder bytes (a shell's // folder, plus the one self-cleanup carve-out from it, both stated there.
// self-cleanup of its own transient scratch file is not this authority).
// //
// Save: BankModel JSON -> SetProjExtState under namespace "reasampler" (ext // Save: BankModel JSON -> SetProjExtState under namespace "reasampler" (ext
// state lives inside the .rpp, so the index travels with the project for // state lives inside the .rpp, so the index travels with the project for
@@ -28,6 +27,7 @@
#include "core/model/bank_model.h" #include "core/model/bank_model.h"
#include "core/reclaim/prune_reconcile.h" #include "core/reclaim/prune_reconcile.h"
#include "core/tracking/origin_ledger.h" #include "core/tracking/origin_ledger.h"
#include "core/tracking/tracking_authority.h"
#include "core/version/app_version.h" #include "core/version/app_version.h"
#include "core/view/view_mode_model.h" #include "core/view/view_mode_model.h"
@@ -63,6 +63,13 @@ public:
model::BankModel& bank() { return book_.activeIndex(); } model::BankModel& bank() { return book_.activeIndex(); }
const model::BankModel& bank() const { return book_.activeIndex(); } const model::BankModel& bank() const { return book_.activeIndex(); }
// The project whose book/view/tail/ledger poll() last loaded — compare-only, never
// dereferenced; nullptr before the first poll. An action that can be fired against a
// project other than the one currently loaded here (the VST bake, which targets its own
// instance's tab) MUST check this before mutating: the book in memory belongs to one
// project, and landing another tab's request would write its capture into this bank.
const void* loadedProject() const { return lastProject_; }
// Design-View model; persists MODEL STATE only (visibility on open is the view shell's job). // Design-View model; persists MODEL STATE only (visibility on open is the view shell's job).
ViewModeModel& view() { return view_; } ViewModeModel& view() { return view_; }
const ViewModeModel& view() const { return view_; } const ViewModeModel& view() const { return view_; }
@@ -115,6 +122,13 @@ public:
// prune action confirms this set before deleting it. Read-only. // prune action confirms this set before deleting it. Read-only.
std::vector<std::string> pruneOrphanSet() const; std::vector<std::string> pruneOrphanSet() const;
// The resample's replace-vs-add input for one capture, gathered from the SAME live
// tracking state the prune scan reads, so the two consumers cannot disagree. Exposed
// as the answer rather than as the ledger, because an absent record and an unreadable
// ledger demand opposite treatment and only the pair says which. Read-only.
tracking::Answer tiedUsageFor(const std::string& capturePath,
const std::string& ownUsageKey) const;
// Delete the confirmed orphan set — the sole file-deletion path, // Delete the confirmed orphan set — the sole file-deletion path,
// callable only after an explicit user confirm. Re-enumerates and runs // callable only after an explicit user confirm. Re-enumerates and runs
// the pure core fresh, deleting exactly `confirmed ∩ freshOrphans` so a // the pure core fresh, deleting exactly `confirmed ∩ freshOrphans` so a
+223
View File
@@ -0,0 +1,223 @@
// Standalone tests for reasampler::instrument::bake::bake_plan — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: the default program's window derived from the dialed sound (a Gate release, a
// Trigger play span, and the Varispeed read-stretch bound); the frame window and both event
// frames against hand-computed values; a capture opening BEFORE note-on and one opening
// AFTER it; the refusals — a collapsed window, a non-positive rate, a window that rounds to
// nothing, and one past the frame ceiling; and root/velocity clamping.
#include "../src/core/instrument/bake/bake_plan.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::bake;
using namespace reasampler::instrument::note;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static Tempo at(double bpm) {
const std::optional<Tempo> t = Tempo::fromBpm(bpm);
if (!t) { std::printf("FAIL: fixture tempo %f rejected\n", bpm); ++g_fail; }
return t.value_or(Tempo::fromBpm(120.0).value());
}
namespace {
constexpr int kRate = 48000;
SampleData dialedSample(std::size_t frames = 96000) {
SampleData s;
s.frames.assign(frames, 0.5f);
s.sampleRate = kRate;
s.rootNote = 60;
return s;
}
} // namespace
int main() {
// --- The default program's window comes from the DIALED release, not a constant -----
{
SampleData s = dialedSample();
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = kRate * 3 / 2; // 1.5 s — past any fixed tail
const NoteProgram p = defaultBakeProgram(s, kRate, at(120.0));
const ResolvedNote r = resolveNote(p, at(120.0));
// A quarter note at 120 BPM is 0.5 s; the window must hold the whole 1.5 s release.
CHECK(r.noteOffSeconds > 0.499 && r.noteOffSeconds < 0.501);
CHECK(r.captureStartSeconds == 0.0);
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
CHECK(!r.windowCollapsed);
// A shorter release yields a shorter window — the derivation really reads the knob.
s.play.adsr.releaseFrames = kRate / 10; // 0.1 s
const ResolvedNote shorter = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
CHECK(shorter.captureEndSeconds > 0.599 && shorter.captureEndSeconds < 0.601);
}
// --- Trigger: the window is the play span, which ignores the note's length ----------
{
SampleData s = dialedSample(/*frames=*/kRate * 2); // 2 s of source
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.75; // 1.5 s of it
const ResolvedNote r = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
CHECK(r.captureStartSeconds == 0.0);
CHECK(r.captureEndSeconds > 1.49 && r.captureEndSeconds < 1.51);
// A span SHORTER than the quarter note closes the window early rather than padding
// it out to note-off — the sound is over, and a negative end offset is legal.
s.play.trigger.lengthFraction = 0.1; // 0.2 s
const ResolvedNote brief = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
CHECK(!brief.windowCollapsed);
CHECK(brief.captureEndSeconds > 0.199 && brief.captureEndSeconds < 0.201);
}
// --- Trigger under Varispeed: a downward pitch offset stretches the read -----------
{
SampleData s = dialedSample(/*frames=*/kRate); // 1 s, played whole
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = -12.0; // an octave down = half speed at the peak
const ResolvedNote r = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
// Bounded at the deepest offset: 1 s of source can take up to 2 s to cross.
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
// Preserve decouples pitch from the read rate, so the same dial bounds nothing.
s.play.pitchEngine = PitchEngine::Preserve;
const ResolvedNote kept = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
at(120.0));
CHECK(kept.captureEndSeconds > 0.99 && kept.captureEndSeconds < 1.01);
}
// --- The frame window and both event frames ----------------------------------
{
NoteProgram p; // 1/4 straight, velocity 100
p.end = EndOffset(offsetFromMs(250.0));
const ResolvedNote r = resolveNote(p, at(120.0)); // note-off 0.5 s, end 0.75 s
const auto plan = planBake(r, 48000, 60);
CHECK(plan.has_value());
CHECK(plan->totalFrames == 36000); // 0.75 s * 48 kHz
CHECK(plan->leadInFrames == 0);
CHECK(plan->renderFrames() == 36000);
CHECK(plan->noteOnFrame == 0);
CHECK(plan->noteOffFrame == 24000); // 0.5 s * 48 kHz
CHECK(plan->sampleRate == 48000);
CHECK(plan->note == 60);
CHECK(plan->velocity == 100);
}
// --- A capture that opens BEFORE note-on -------------------------------------
{
NoteProgram p;
p.start = StartOffset(offsetFromMs(-100.0)); // negative = earlier
p.end = EndOffset(offsetFromMs(100.0));
const ResolvedNote r = resolveNote(p, at(120.0));
const auto plan = planBake(r, 44100, 60);
CHECK(plan.has_value());
// Window is [-0.1, 0.6] s = 0.7 s; note-on sits 0.1 s in, note-off 0.5 s after it.
CHECK(plan->totalFrames == 30870);
CHECK(plan->leadInFrames == 0); // nothing to discard: the file opens first
CHECK(plan->noteOnFrame == 4410);
CHECK(plan->noteOffFrame == 26460);
CHECK(plan->noteOffFrame - plan->noteOnFrame == 22050); // the note's own length
}
// --- A capture that opens AFTER note-on (a positive start trims the attack) ---
{
NoteProgram p;
p.start = StartOffset(offsetFromMs(100.0)); // positive = later: the head is cut
p.end = EndOffset(offsetFromMs(100.0));
const ResolvedNote r = resolveNote(p, at(120.0));
const auto plan = planBake(r, 48000, 60);
CHECK(plan.has_value());
// Window is [0.1, 0.6] s = 0.5 s of FILE, but the note starts 0.1 s before it, so
// the render must produce that head and throw it away rather than shift the note.
CHECK(plan->totalFrames == 24000);
CHECK(plan->leadInFrames == 4800);
CHECK(plan->renderFrames() == 28800);
CHECK(plan->noteOnFrame == 0); // the note is at the START of the render
CHECK(plan->noteOffFrame == 24000); // still its full 0.5 s length
CHECK(plan->noteOffFrame - plan->noteOnFrame == 24000);
}
// --- Refusals -----------------------------------------------------------------
{
NoteProgram p;
// An end offset more negative than the note length inverts the window.
p.end = EndOffset(offsetFromMs(-10000.0));
const ResolvedNote r = resolveNote(p, at(120.0));
CHECK(r.windowCollapsed);
CHECK(!planBake(r, 48000, 60).has_value());
}
{
NoteProgram plain;
const ResolvedNote r = resolveNote(plain, at(120.0));
CHECK(!planBake(r, 0, 60).has_value());
CHECK(!planBake(r, -48000, 60).has_value());
}
{
// A legal but sub-frame window rounds to nothing and is refused rather than
// rendered as a degenerate buffer.
NoteProgram p;
p.end = EndOffset(offsetFromMs(-500.0)); // exactly cancels the 0.5 s note
const ResolvedNote r = resolveNote(p, at(120.0));
CHECK(!r.windowCollapsed);
CHECK(r.captureLengthSeconds() == 0.0);
CHECK(!planBake(r, 48000, 60).has_value());
}
{
// A legal offset magnitude reaches days: refused at the ceiling, not attempted as
// an allocation (and never narrowed out of int64's range on the way there).
const double overSeconds =
(static_cast<double>(kMaxBakeFrames) / 48000.0) + 1.0;
NoteProgram p;
p.end = EndOffset(offsetFromMs(overSeconds * 1000.0));
const ResolvedNote big = resolveNote(p, at(120.0));
CHECK(!big.windowCollapsed);
CHECK(!planBake(big, 48000, 60).has_value());
// The extreme a legal OffsetAmount can hold, in both directions.
NoteProgram huge;
huge.end = EndOffset(offsetFromMs(kMaxConvertibleMagnitude));
CHECK(!planBake(resolveNote(huge, at(120.0)), 48000, 60).has_value());
NoteProgram far;
far.start = StartOffset(offsetFromMs(-kMaxConvertibleMagnitude));
CHECK(!planBake(resolveNote(far, at(120.0)), 48000, 60).has_value());
// And just under it still plans, so the ceiling is a bound, not a blanket refusal.
NoteProgram fits;
fits.end = EndOffset(offsetFromMs(
(static_cast<double>(kMaxBakeFrames) / 48000.0 - 1.0) * 1000.0));
const auto planned = planBake(resolveNote(fits, at(120.0)), 48000, 60);
CHECK(planned.has_value());
CHECK(planned && planned->renderFrames() <= kMaxBakeFrames);
}
// --- Domain clamps -------------------------------------------------------------
{
NoteProgram p;
p.end = EndOffset(offsetFromMs(100.0));
const ResolvedNote r = resolveNote(p, at(120.0));
const auto low = planBake(r, 48000, -5);
const auto high = planBake(r, 48000, 900);
const auto mid = planBake(r, 48000, 60);
CHECK(low.has_value() && high.has_value() && mid.has_value());
if (low && high && mid) {
CHECK(low->note == 0);
CHECK(high->note == 127);
CHECK(mid->note == 60);
}
}
if (g_fail == 0) std::printf("bake_plan: all tests passed\n");
return g_fail ? 1 : 0;
}
+257
View File
@@ -0,0 +1,257 @@
// Standalone tests for reasampler::instrument::bake::bake_render — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: Gate termination WITH a sustain loop active (the render must end at the window,
// and the tail must be silent because the gate actually released — not merely because the
// buffer ran out); Trigger termination on its own play span; channel-count preservation
// with no stereo fold; the master gain being PRINTED into the output; a lead-in rendered
// and discarded; byte-identical repeats; and the refusals (unplayable sample, empty
// window, a window past the frame ceiling).
#include "../src/core/instrument/bake/bake_render.h"
#include "../src/core/instrument/engine/live_params.h"
#include <cmath>
#include <cstdio>
#include <limits>
using namespace reasampler;
using namespace reasampler::instrument::bake;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
namespace {
constexpr int kRate = 48000;
// Distinct per-channel DC so a downmix or a channel duplication is visible in the output
// rather than hidden behind two identical channels.
SampleData makeSample(bool stereo, std::size_t frames = 1000) {
SampleData s;
s.frames.assign(frames, 0.5f);
if (stereo) s.framesR.assign(frames, -0.25f);
s.sampleRate = kRate;
s.rootNote = 60;
return s;
}
// Peak magnitude of channel 0 over [from, to) output frames.
double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
double peak = 0.0;
for (std::int64_t f = from; f < to && f < audio.frameCount(); ++f) {
const double v = std::fabs(
static_cast<double>(audio.interleaved[static_cast<std::size_t>(
f * audio.channelCount)]));
if (v > peak) peak = v;
}
return peak;
}
BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff,
std::int64_t leadIn = 0) {
BakePlan p;
p.totalFrames = total;
p.leadInFrames = leadIn;
p.noteOnFrame = noteOn;
p.noteOffFrame = noteOff;
p.note = 60;
p.velocity = 100;
p.sampleRate = kRate;
return p;
}
constexpr double kUnity = 1.0;
} // namespace
int main() {
// --- Gate, sustain loop active: the render terminates and the gate really released --
{
SampleData s = makeSample(/*stereo=*/false, /*frames=*/200);
// A 100-frame loop over a 200-frame sample: held past the sample end it would cycle
// forever, which is exactly the runaway the window has to bound.
s.loop = SampleLoop{true, 0, 100};
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 480; // 10 ms — short enough to finish inside the tail
const BakePlan plan = planOf(/*total=*/9600, /*noteOn=*/0, /*noteOff=*/4800);
const BakeAudio audio = renderBake(s, plan, kUnity);
CHECK(audio.frameCount() == 9600); // bounded, not a runaway
CHECK(audio.channelCount == 1);
// Sounding right up to the release…
CHECK(peakAt(audio, 4700, 4800) > 0.4);
// …and silent well after it, which only holds if the note-off was honoured: the
// loop would otherwise still be cycling at full level here.
CHECK(peakAt(audio, 6000, 9600) < 1e-6);
}
// --- Trigger: note-off is ignored, the play span ends the sound -------------------
{
SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.5; // 500 source frames at unity ratio
const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/100);
const BakeAudio audio = renderBake(s, plan, kUnity);
CHECK(audio.frameCount() == 2000);
// Still sounding past the note-off Trigger ignores…
CHECK(peakAt(audio, 200, 400) > 0.4);
// …and finished at its own span end, well before the window closes.
CHECK(peakAt(audio, 700, 2000) < 1e-6);
}
// --- Channel count preserved; no stereo fold --------------------------------------
{
SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
s.play.playMode = PlayMode::Trigger;
const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);
const BakeAudio audio = renderBake(s, plan, kUnity);
CHECK(audio.channelCount == 2);
CHECK(audio.frameCount() == 500);
CHECK(audio.interleaved.size() == 1000u);
// The two channels carry the source's two distinct signals: a downmix would make
// them equal, a duplication would make R equal L.
CHECK(audio.interleaved[200] > 0.4f);
CHECK(audio.interleaved[201] < -0.2f);
CHECK(audio.interleaved[201] > -0.3f);
}
// --- The master gain is PRINTED into the file ---------------------------------------
// The reset hands the control back at unity, so a render that summed voices alone would
// shift every iteration by 1/gain — and a gain dialed to silence would come back loud.
{
SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
s.play.playMode = PlayMode::Trigger;
const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);
const BakeAudio unity = renderBake(s, plan, kUnity);
const BakeAudio quiet = renderBake(s, plan, 0.25);
const BakeAudio loud = renderBake(s, plan, 4.0);
const BakeAudio silent = renderBake(s, plan, 0.0);
CHECK(unity.interleaved.size() == quiet.interleaved.size());
bool scaled = !unity.interleaved.empty();
for (std::size_t i = 0; scaled && i < unity.interleaved.size(); ++i) {
scaled = std::fabs(quiet.interleaved[i] - unity.interleaved[i] * 0.25f) < 1e-6f &&
std::fabs(loud.interleaved[i] - unity.interleaved[i] * 4.0f) < 1e-5f;
}
CHECK(scaled);
// Both channels, not just the one the peak helper reads.
CHECK(quiet.interleaved[201] < 0.f && quiet.interleaved[201] > -0.1f);
// A gain of zero prints silence rather than returning the sound at full level.
CHECK(peakAt(silent, 0, 500) == 0.0);
CHECK(peakAt(unity, 0, 500) > 0.4);
}
// --- A lead-in is rendered and then discarded ---------------------------------------
// A positive start offset trims the note's head: the frames before the window must be
// produced (so the envelope really is mid-flight when the file opens) and dropped.
{
SampleData s = makeSample(/*stereo=*/false, /*frames=*/4000);
s.play.playMode = PlayMode::Trigger;
s.play.trigAhd.attackFrames = 1000; // still climbing when the window opens
const BakePlan trimmed = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/2000,
/*leadIn=*/1000);
const BakeAudio audio = renderBake(s, trimmed, kUnity);
CHECK(audio.frameCount() == 1000); // the FILE is the window, not the render
// Frame 0 of the file is frame 1000 of the render — the attack's end, not its
// start. A clamped-away lead-in would put the attack's silent onset here instead.
const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity);
CHECK(peakAt(audio, 0, 1) > peakAt(whole, 0, 1));
CHECK(std::fabs(static_cast<double>(audio.interleaved[0]) -
static_cast<double>(whole.interleaved[1000])) < 1e-6);
}
// --- Bit-identical repeats ---------------------------------------------------------
{
SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
s.loop = SampleLoop{true, 0, 333};
s.play.adsr.attackFrames = 97; // a shape whose per-frame state must replay exactly
s.play.adsr.releaseFrames = 211;
const BakePlan plan = planOf(/*total=*/4096, /*noteOn=*/13, /*noteOff=*/2731);
const BakeAudio a = renderBake(s, plan, kUnity);
const BakeAudio b = renderBake(s, plan, kUnity);
CHECK(a.interleaved.size() == b.interleaved.size());
CHECK(!a.interleaved.empty());
bool identical = a.interleaved.size() == b.interleaved.size();
for (std::size_t i = 0; identical && i < a.interleaved.size(); ++i)
identical = (a.interleaved[i] == b.interleaved[i]);
CHECK(identical);
// The window opened before the note: those frames must be untouched silence.
CHECK(peakAt(a, 0, 13) == 0.0);
CHECK(peakAt(a, 200, 400) > 0.0);
}
// --- The bake is off the audio thread's block, structurally ------------------------
// The only thing process() and a bake could share is the live-parameter block. This
// pins that they do not: the caller's SampleData is untouched (renderBake took a
// copy), and the render ignores what is published in the block — the bake prints the
// dialed parameter set, not whatever the audio thread is currently observing.
{
instrument::engine::LiveParams block;
SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
s.play.playMode = PlayMode::Trigger;
s.play.trigAhd.attackFrames = 0; // dialed: instant attack
// Publish a MUCH slower attack into the block. A render that observed it would be
// near-silent at the point the dialed shape is already at full level.
s.live = &block;
{
PlayParams slow = s.play;
slow.trigAhd.attackFrames = 900;
block.publish(instrument::engine::foldLive(slow));
}
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
const BakeAudio audio = renderBake(s, plan, kUnity);
CHECK(s.live == &block); // the caller's own snapshot was not detached
// At frame 100 the dialed instant attack is at full level; the published 900-frame
// attack would be barely a ninth of the way up.
CHECK(peakAt(audio, 90, 110) > 0.4);
// And it matches a render from a block-free copy exactly.
SampleData detached = s;
detached.live = nullptr;
const BakeAudio reference = renderBake(detached, plan, kUnity);
bool identical = audio.interleaved.size() == reference.interleaved.size();
for (std::size_t i = 0; identical && i < audio.interleaved.size(); ++i)
identical = (audio.interleaved[i] == reference.interleaved[i]);
CHECK(identical);
}
// --- Refusals -----------------------------------------------------------------------
{
SampleData empty; // nothing decoded
empty.sampleRate = kRate;
CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity).empty());
SampleData s = makeSample(false);
CHECK(renderBake(s, planOf(0, 0, 0), kUnity).empty());
// The ceiling planBake enforces is re-checked here: a hand-built plan must not be
// able to walk the render into an allocation it cannot hold.
CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity).empty());
CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity).empty());
// A hand-built plan can carry a lead-in near the int64 ceiling; the guard must trip
// on that field alone rather than signed-overflowing inside renderFrames()'s sum.
CHECK(renderBake(s,
planOf(1000, 0, 500,
/*leadIn=*/std::numeric_limits<std::int64_t>::max() - 10),
kUnity)
.empty());
}
if (g_fail == 0) std::printf("bake_render: all tests passed\n");
return g_fail ? 1 : 0;
}
+171
View File
@@ -0,0 +1,171 @@
// Standalone tests for reasampler::instrument::bake::bake_reset — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers the ratified reset scope PER PARAMETER, in both directions: every control whose
// effect the render printed comes back at its default, and every mapping fact comes back
// untouched. Asserted field by field rather than by struct equality on purpose — a
// whole-struct compare would pass while silently resetting a survivor, or vice versa.
#include "../src/core/instrument/bake/bake_reset.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::bake;
using reasampler::instrument::map::InstrumentParams;
using reasampler::instrument::map::PlaySeconds;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
namespace {
// Every field moved off its default, so a reset that misses one is visible.
InstrumentParams dialed() {
InstrumentParams p;
p.rootOverride = 43;
p.loopOverride = SampleLoop{true, 111, 222};
p.startPoint = 4321;
p.loopCrossfadeFrames = 512;
p.keyTrack = 0.5;
p.velocityCurve = VelocityCurve::linear();
p.play.playMode = PlayMode::Trigger;
p.play.adsr.attackSeconds = 0.4;
p.play.adsr.holdSeconds = 0.3;
p.play.adsr.decaySeconds = 0.2;
p.play.adsr.sustainLevel = 0.1;
p.play.adsr.releaseSeconds = 0.9;
p.play.adsr.attackCurve = 2.5;
p.play.adsr.decayCurve = 0.4;
p.play.adsr.releaseCurve = 3.0;
p.play.trigger.lengthFraction = 0.25;
p.play.trigAhd.attackSeconds = 0.11;
p.play.trigAhd.decaySeconds = 0.22;
p.play.trigAhd.holdFraction = 0.33;
p.play.trigAhd.attackCurve = 1.7;
p.play.pitchEngine = PitchEngine::Varispeed;
p.play.pitchEnv.enabled = true;
p.play.pitchEnv.peakSemitones = -7.0;
p.play.pitchEnv.shape.attackSeconds = 0.05;
p.play.pitchVelocityCurve = VelocityCurve::linear();
p.play.filter.enabled = true;
p.play.filter.modAmount = -0.8;
p.play.filter.velAmount = 0.6;
p.play.filter.keyTrack = 1.5;
p.play.filter.env.attackSeconds = 0.7;
p.play.filter.trigEnv.decaySeconds = 0.8;
p.play.filter.velocityCurve = VelocityCurve::linear();
p.play.ampSpline.mode = EnvMode::Spline;
p.play.ampSpline.contour = VelocityCurve::linear();
p.play.pitchSpline.mode = EnvMode::Spline;
p.play.filterSpline.mode = EnvMode::Spline;
return p;
}
bool sameCurve(const VelocityCurve& a, const VelocityCurve& b) {
if (a.domain() != b.domain() || a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i) {
if (a.points()[i].velocity != b.points()[i].velocity) return false;
if (a.points()[i].value != b.points()[i].value) return false;
}
return true;
}
} // namespace
int main() {
const InstrumentParams before = dialed();
const BakeReset reset = resetAfterBake(before);
const InstrumentParams& after = reset.params;
const InstrumentParams fresh; // the defaults every reset control must land on
const PlaySeconds freshPlay;
// --- SURVIVE: mapping facts, absent from the printed audio ----------------------
CHECK(after.rootOverride.has_value());
CHECK(after.rootOverride == before.rootOverride);
CHECK(after.keyTrack == before.keyTrack);
CHECK(after.keyTrack == 0.5); // and it is the dialed value, not the default 1.0
CHECK(fresh.keyTrack != before.keyTrack); // the fixture really did move it
// --- RESET: loop points, start point, crossfade ---------------------------------
CHECK(!after.loopOverride.has_value());
CHECK(!after.startPoint.has_value());
CHECK(after.loopCrossfadeFrames == 0);
// --- RESET: the velocity transfer curves ----------------------------------------
CHECK(sameCurve(after.velocityCurve, VelocityCurve::flat()));
CHECK(!sameCurve(after.velocityCurve, before.velocityCurve));
CHECK(sameCurve(after.play.pitchVelocityCurve, VelocityCurve::zero()));
CHECK(sameCurve(after.play.filter.velocityCurve, VelocityCurve::zero()));
// --- RESET: play mode, to TRIGGER rather than to the struct's Gate default -------
// The bake's product is a finished one-shot; Trigger plays it back verbatim, Gate would
// re-gate its printed release tail and each iteration would truncate the last one's.
CHECK(after.play.playMode == PlayMode::Trigger);
CHECK(freshPlay.playMode == PlayMode::Gate); // and that really is NOT the default
// The Trigger face it lands on plays the whole file flat: full span, unity throughout.
CHECK(after.play.trigger.lengthFraction == 1.0);
CHECK(after.play.trigAhd.attackSeconds == 0.0);
CHECK(after.play.trigAhd.decaySeconds == 0.0);
{
// …and a GATE-dialed instrument lands there too: this is a reset to a chosen
// neutral, not the dialed value surviving.
InstrumentParams gated = dialed();
gated.play.playMode = PlayMode::Gate;
CHECK(resetAfterBake(gated).params.play.playMode == PlayMode::Trigger);
}
// --- RESET: the amp envelope, staged, every stage and every curve exponent ------
CHECK(after.play.adsr.attackSeconds == freshPlay.adsr.attackSeconds);
CHECK(after.play.adsr.holdSeconds == freshPlay.adsr.holdSeconds);
CHECK(after.play.adsr.decaySeconds == freshPlay.adsr.decaySeconds);
CHECK(after.play.adsr.sustainLevel == freshPlay.adsr.sustainLevel);
CHECK(after.play.adsr.releaseSeconds == freshPlay.adsr.releaseSeconds);
CHECK(after.play.adsr.attackCurve == freshPlay.adsr.attackCurve);
CHECK(after.play.adsr.decayCurve == freshPlay.adsr.decayCurve);
CHECK(after.play.adsr.releaseCurve == freshPlay.adsr.releaseCurve);
CHECK(after.play.trigger.lengthFraction == freshPlay.trigger.lengthFraction);
CHECK(after.play.trigAhd.attackSeconds == freshPlay.trigAhd.attackSeconds);
CHECK(after.play.trigAhd.decaySeconds == freshPlay.trigAhd.decaySeconds);
CHECK(after.play.trigAhd.holdFraction == freshPlay.trigAhd.holdFraction);
CHECK(after.play.trigAhd.attackCurve == freshPlay.trigAhd.attackCurve);
// --- RESET: pitch engine + pitch envelope ---------------------------------------
CHECK(after.play.pitchEngine == freshPlay.pitchEngine);
CHECK(after.play.pitchEngine == PitchEngine::Preserve); // the product default
CHECK(!after.play.pitchEnv.enabled);
CHECK(after.play.pitchEnv.peakSemitones == 0.0);
CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds);
// --- RESET: the filter, including its velocity/key-tracking mod -----------------
CHECK(!after.play.filter.enabled);
CHECK(after.play.filter.modAmount == 0.0);
CHECK(after.play.filter.velAmount == 0.0);
CHECK(after.play.filter.keyTrack == 0.0);
CHECK(after.play.filter.env.attackSeconds == freshPlay.filter.env.attackSeconds);
CHECK(after.play.filter.trigEnv.decaySeconds == freshPlay.filter.trigEnv.decaySeconds);
// --- RESET: the three spline contours AND their mode flags ----------------------
// The flag selects which shape ran, so the shape it selected is in the audio; with
// both contours reset it also has nothing left to preserve.
CHECK(after.play.ampSpline.mode == EnvMode::Staged);
CHECK(after.play.pitchSpline.mode == EnvMode::Staged);
CHECK(after.play.filterSpline.mode == EnvMode::Staged);
CHECK(sameCurve(after.play.ampSpline.contour, VelocityCurve::rampDown()));
CHECK(!sameCurve(after.play.ampSpline.contour, before.play.ampSpline.contour));
// --- RESET: master gain ----------------------------------------------------------
CHECK(reset.masterGainLinear == 1.0);
// --- An absent root override stays absent (nothing is invented) ------------------
{
InstrumentParams noRoot = dialed();
noRoot.rootOverride.reset();
CHECK(!resetAfterBake(noRoot).params.rootOverride.has_value());
}
if (g_fail == 0) std::printf("bake_reset: all tests passed\n");
return g_fail ? 1 : 0;
}
+188
View File
@@ -0,0 +1,188 @@
// Standalone tests for reasampler::wire::bake_wire — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling wire tests.
//
// Covers: the exact bytes each record encodes to (the two artifacts ship independently, so
// a field reorder or an inserted field must fail here rather than pass a round-trip and
// break a mixed-version pair); request + outcome round-trips including bytes that would
// break a delimiter-based format; the refusals every house wire record shares (wrong tag,
// truncation, trailing garbage, a swapped record kind); an unrecognized status integer
// degrading to Failed rather than to Ok; and the action lookup name's leading underscore.
#include "../src/core/wire/bake_wire.h"
#include "../src/core/version/app_version.h"
#include <cstdio>
#include <string>
using namespace reasampler::wire;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
int main() {
// --- The exact bytes on the wire -------------------------------------------------
// A round-trip alone would pass a reordered or inserted field; the tags exist to guard
// the LAYOUT, so the layout is what is pinned. Changing either literal below means an
// already-shipped pair of artifacts can no longer talk — bump the tag, don't edit it.
{
BakeRequest req;
req.instanceGuid = "abcd";
req.stagedFilePath = "T/b.wav";
req.sourceSampleId = "cap-1";
req.sourceRelativePath = "bank/k.wav";
req.sourceDisplayName = "Kick";
req.ownUsageKey = "rsusage_abcd";
req.rootNote = 36;
req.generation = 1893456000;
CHECK(encodeBakeRequest(req) ==
"rsbakereq1"
"4:abcd"
"7:T/b.wav"
"5:cap-1"
"10:bank/k.wav"
"4:Kick"
"12:rsusage_abcd"
"2:36"
"10:1893456000");
BakeOutcome out;
out.status = BakeStatus::Ok;
out.sampleId = "bake-1";
out.relativePath = "bank/k2.wav";
out.displayName = "Kick r2";
out.rootNote = 36;
out.channelCount = 2;
out.replaced = true;
out.message = "replaced";
out.generation = 1893456000;
CHECK(encodeBakeOutcome(out) ==
"rsbakeout1"
"1:0"
"6:bake-1"
"11:bank/k2.wav"
"7:Kick r2"
"2:36"
"1:2"
"1:1"
"8:replaced"
"10:1893456000");
}
// --- Request round-trip, with hostile field content -----------------------------
{
BakeRequest req;
req.instanceGuid = "0123abcd";
req.stagedFilePath = "C:/Temp/re:sampler 9000/bake 12:34.wav"; // colons + spaces
req.sourceSampleId = "cap-1";
req.sourceRelativePath = "reasampler_bank/kick.wav";
req.sourceDisplayName = "Kick r2";
req.ownUsageKey = "rsusage_0123abcd";
req.rootNote = 36;
req.generation = 1893456000;
const std::string encoded = encodeBakeRequest(req);
const auto decoded = decodeBakeRequest(encoded);
CHECK(decoded.has_value());
CHECK(*decoded == req);
// Empty strings and a zero generation survive too (a first, un-named source).
BakeRequest bare;
CHECK(decodeBakeRequest(encodeBakeRequest(bare)) == bare);
}
// --- Outcome round-trip -----------------------------------------------------------
{
BakeOutcome out;
out.status = BakeStatus::Ok;
out.sampleId = "bake-1893456000-kick_1893456000.wav";
out.relativePath = "reasampler_bank/kick_1893456000.wav";
out.displayName = "Kick r3";
out.rootNote = 36;
out.channelCount = 2;
out.replaced = true;
out.message = "replaced the bank entry";
out.generation = 1893456000;
const auto decoded = decodeBakeOutcome(encodeBakeOutcome(out));
CHECK(decoded.has_value());
CHECK(*decoded == out);
CHECK(decoded->replaced);
out.replaced = false;
CHECK(decodeBakeOutcome(encodeBakeOutcome(out))->replaced == false);
}
// --- Malformed input is refused, never half-parsed ---------------------------------
{
BakeRequest req;
req.instanceGuid = "abc";
req.rootNote = 60;
const std::string good = encodeBakeRequest(req);
CHECK(!decodeBakeRequest("").has_value());
CHECK(!decodeBakeRequest("rsbakereq0" + good.substr(10)).has_value()); // wrong tag
CHECK(!decodeBakeRequest(good.substr(0, good.size() - 3)).has_value()); // truncated
CHECK(!decodeBakeRequest(good + "junk").has_value()); // trailing
// The two records share a key; each must refuse the other's bytes outright.
CHECK(!decodeBakeOutcome(good).has_value());
CHECK(!decodeBakeRequest(encodeBakeOutcome(BakeOutcome{})).has_value());
}
// --- A status integer this build does not know reads as a FAILURE ------------------
{
// Hand-built with a future status value; every other field is well-formed, so only
// the vocabulary gap is under test.
BakeOutcome out;
out.status = BakeStatus::Ok;
out.generation = 7;
std::string wire = encodeBakeOutcome(out);
// The status field is the first after the tag: "<len>':'<digits>".
const std::string okField = "1:0";
const std::size_t at = wire.find(okField);
CHECK(at != std::string::npos);
wire.replace(at, okField.size(), "2:99");
const auto decoded = decodeBakeOutcome(wire);
CHECK(decoded.has_value());
CHECK(decoded->status == BakeStatus::Failed); // never Ok
CHECK(decoded->generation == 7);
// Every status this build DOES know survives its own round trip — including the
// most recently appended one, which an older reader will see as Failed.
for (const BakeStatus s :
{BakeStatus::Ok, BakeStatus::Failed, BakeStatus::NoProject,
BakeStatus::StagedMissing, BakeStatus::NoSource, BakeStatus::IndexRejected,
BakeStatus::WrongProject}) {
BakeOutcome one;
one.status = s;
const auto back = decodeBakeOutcome(encodeBakeOutcome(one));
CHECK(back.has_value() && back->status == s);
}
}
// --- The lookup name carries the underscore the registration string does not -------
// The load-bearing half is the REGISTRATION string: main.cpp registers that spelling
// verbatim, and NamedCommandLookup needs exactly one underscore in front of it. If
// channelCommandId ever grew one of its own, the lookup would carry two and resolve to
// nothing.
{
const std::string registered =
reasampler::version::channelCommandId(kBakeActionSuffix);
const std::string lookup = bakeActionLookupName();
const std::size_t suffixLen = std::string(kBakeActionSuffix).size();
CHECK(!registered.empty());
CHECK(registered.front() != '_');
CHECK(lookup.size() == registered.size() + 1);
CHECK(lookup.front() == '_' && lookup[1] != '_');
CHECK(lookup.compare(1, std::string::npos, registered) == 0);
// The suffix is the TAIL of the id — the channel prefix goes in front of it, and a
// suffix that drifted into the middle would name a different action.
CHECK(lookup.size() > suffixLen);
CHECK(lookup.compare(lookup.size() - suffixLen, suffixLen, kBakeActionSuffix) == 0);
}
if (g_fail == 0) std::printf("bake_wire: all tests passed\n");
return g_fail ? 1 : 0;
}
+45
View File
@@ -0,0 +1,45 @@
// Standalone tests for reasampler::model::resample_name — no REAPER, no framework.
//
// Covers: the first iteration, the chain incrementing rather than stacking, the empty
// name, and the tails that are NOT one of ours and must be left alone.
#include "../src/core/model/resample_name.h"
#include <cstdio>
using reasampler::model::nextIterationName;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
int main() {
CHECK(nextIterationName("Kick") == "Kick r2");
CHECK(nextIterationName("Kick r2") == "Kick r3");
CHECK(nextIterationName("Kick r9") == "Kick r10");
CHECK(nextIterationName("Kick r10") == "Kick r11");
// The chain composes: three bakes read as r2, r3, r4 — not "Kick r2 r2 r2".
CHECK(nextIterationName(nextIterationName(nextIterationName("Kick"))) == "Kick r4");
CHECK(nextIterationName("") == "resample r2");
CHECK(nextIterationName(" ") == " r2"); // a name of spaces is still a name
// Tails that are not ours: appended to, never rewritten.
CHECK(nextIterationName("Kick r") == "Kick r r2");
CHECK(nextIterationName("Kick r0") == "Kick r0 r2");
CHECK(nextIterationName("Kick rx") == "Kick rx r2");
CHECK(nextIterationName("Kickr2") == "Kickr2 r2"); // no space before the r
CHECK(nextIterationName("Kick R2") == "Kick R2 r2"); // capital R is not the marker
CHECK(nextIterationName("r2") == "r2 r2"); // no stem to attach the tail to
CHECK(nextIterationName("2") == "2 r2");
CHECK(nextIterationName("Take 3") == "Take 3 r2"); // digits without the " r"
// A digit run past the counting bound is left alone rather than wrapping into a low
// number that would collide with an existing entry's name.
CHECK(nextIterationName("Kick r99999999999999999999") ==
"Kick r99999999999999999999 r2");
if (g_fail == 0) std::printf("resample_name: all tests passed\n");
return g_fail ? 1 : 0;
}
+7 -3
View File
@@ -59,12 +59,16 @@ static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
CHECK(r.chanMono.right() == r.chanStereo.x); CHECK(r.chanMono.right() == r.chanStereo.x);
CHECK(r.velCell.right() <= r.chanMono.x); CHECK(r.velCell.right() <= r.chanMono.x);
CHECK(r.preview.right() <= r.velCell.x); CHECK(r.preview.right() <= r.velCell.x);
CHECK(r.title.right() <= r.preview.x); CHECK(r.bake.right() <= r.preview.x);
CHECK(r.bake.width == kBakeButtonWidth);
CHECK(r.bake.y == r.preview.y); // shares the run's button baseline
CHECK(r.bake.height == r.preview.height);
CHECK(r.title.right() <= r.bake.x); // the title yields to the bake, not the preview
CHECK(r.title.x == band.x + kPad); CHECK(r.title.x == band.x + kPad);
CHECK(r.title.width > 0); CHECK(r.title.width > 0);
// Every toolbar rect sits inside the toolbar row. // Every toolbar rect sits inside the toolbar row.
const Rect items[] = {r.title, r.preview, r.velCell, r.chanMono, const Rect items[] = {r.title, r.bake, r.preview, r.velCell, r.chanMono,
r.chanStereo, r.navBrowse}; r.chanStereo, r.navBrowse};
for (const Rect& it : items) { for (const Rect& it : items) {
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom()); CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
@@ -78,7 +82,7 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
// stay inside its own row, clear of every control. // stay inside its own row, clear of every control.
CHECK(!overlaps(r.toolbar, r.rootStrip)); CHECK(!overlaps(r.toolbar, r.rootStrip));
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom()); CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
const Rect items[] = {r.preview, r.velCell, r.chanMono, r.chanStereo, const Rect items[] = {r.bake, r.preview, r.velCell, r.chanMono, r.chanStereo,
r.navBrowse}; r.navBrowse};
for (const Rect& it : items) { for (const Rect& it : items) {
CHECK(!overlaps(it, r.rootStrip)); CHECK(!overlaps(it, r.rootStrip));
+37
View File
@@ -449,7 +449,44 @@ static void testTiedUniverseIsStrictSubsetOfProtectedUniverse() {
CHECK(tiedUsageExists(state, "bank/self.wav", "rsusage_ME") == Answer::No); CHECK(tiedUsageExists(state, "bank/self.wav", "rsusage_ME") == Answer::No);
} }
// The resample's own branch off the three answers. Indeterminate must land where Yes does:
// AddDistinct disturbs no existing holder, so it is the non-destructive side.
static void testResampleLandingTakesReplaceOnlyOnADefiniteNo() {
CHECK(resampleLanding(Answer::No) == Landing::Replace);
CHECK(resampleLanding(Answer::Yes) == Landing::AddDistinct);
CHECK(resampleLanding(Answer::Indeterminate) == Landing::AddDistinct);
// Composed with the query itself, over the three states a real bake meets.
OriginLedger ledger;
ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));
// Sole holder, excluding itself -> nothing is tied -> replace.
const UsageFoldResult sole = foldLive(
{usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}})}, {"{T1}"});
const TrackingState soleState{LedgerStatus::Loaded, ledger, sole};
CHECK(resampleLanding(tiedUsageExists(soleState, "bank/src.wav", "rsusage_ME")) ==
Landing::Replace);
// A second live instance holds it -> add distinct, so that holder is undisturbed.
const UsageFoldResult shared = foldLive(
{usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}}),
usage("rsusage_OTHER", "{T2}", {UsageHold{"S-src", "bank/src.wav"}})},
{"{T1}", "{T2}"});
const TrackingState sharedState{LedgerStatus::Loaded, ledger, shared};
CHECK(resampleLanding(tiedUsageExists(sharedState, "bank/src.wav", "rsusage_ME")) ==
Landing::AddDistinct);
// A degraded ledger cannot answer -> add distinct rather than take over an entry
// whose holders are unknown.
const TrackingState degradedState{LedgerStatus::Unreadable, ledger, sole};
CHECK(tiedUsageExists(degradedState, "bank/src.wav", "rsusage_ME") ==
Answer::Indeterminate);
CHECK(resampleLanding(tiedUsageExists(degradedState, "bank/src.wav", "rsusage_ME")) ==
Landing::AddDistinct);
}
int main() { int main() {
testResampleLandingTakesReplaceOnlyOnADefiniteNo();
testPruneProtectedSet(); testPruneProtectedSet();
testLiveHoldProtectsDeReferencedCapture(); testLiveHoldProtectsDeReferencedCapture();
testUnreadableUsageBlocksPruneAndNamesIt(); testUnreadableUsageBlocksPruneAndNamesIt();