Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green

This commit is contained in:
2026-07-28 20:48:56 -04:00
parent 67a41728f3
commit 847936f813
222 changed files with 2247 additions and 2079 deletions
+126
View File
@@ -0,0 +1,126 @@
#include "core/audio/peaks.h"
#include <algorithm>
#include <climits>
#include <cmath>
#include <cstdint>
// peaks implementation.
//
// One linear pass per channel. The frame->bin partition is computed with integer
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That
// span formula distributes the remainder deterministically (earlier bins get the
// extra frames) with no rounding drift and no dropped tail — the last bin's end is
// always exactly frameCount.
namespace reasampler::audio {
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t channelCount,
std::size_t frameCount,
std::size_t binCount) {
Envelope envelope(channelCount);
if (channelCount == 0) {
return envelope; // no channels -> no envelopes
}
// Never read past what the buffer actually holds, even if the caller's
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
const std::size_t availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames);
for (std::size_t ch = 0; ch < channelCount; ++ch) {
ChannelEnvelope& bins = envelope[ch];
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
for (std::size_t b = 0; b < binCount; ++b) {
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount).
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is
// caller-controlled and unbounded, so when b >= SIZE_MAX/frames either
// multiplication could wrap. Any such bin is unreachable in practice
// (allocating that many MinMax entries would OOM first), but we guard
// explicitly to eliminate UB.
if (frames > 0 && b >= SIZE_MAX / frames) {
continue; // b*frames or (b+1)*frames would overflow; span is empty
}
const std::size_t begin = (b * frames) / binCount;
const std::size_t end = ((b + 1) * frames) / binCount;
if (begin >= end) {
continue; // empty span (binCount > frames) -> keep {0,0}
}
const AudioSample first = interleaved[begin * channelCount + ch];
AudioSample lo = first;
AudioSample hi = first;
for (std::size_t f = begin + 1; f < end; ++f) {
const AudioSample s = interleaved[f * channelCount + ch];
lo = std::min(lo, s);
hi = std::max(hi, s);
}
bins[b] = MinMax{lo, hi};
}
}
return envelope;
}
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) {
const int nbins = static_cast<int>(bins.size());
if (columnCount <= 0 || nbins == 0) return MinMax{};
// Clamp col to [0, columnCount-1].
if (col < 0) col = 0;
if (col >= columnCount) col = columnCount - 1;
// Half-open bin range for this column, mirroring computeEnvelope's exact partition.
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
const std::int64_t end64 =
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
// col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins.
const int colBinBegin = static_cast<int>(begin64);
// When the column spans no full bin (more columns than bins), use the enclosing bin
// so no column is left empty.
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
MinMax result = bins[static_cast<std::size_t>(colBinBegin)];
for (int b = colBinBegin + 1; b < clampedEnd; ++b) {
const MinMax& mm = bins[static_cast<std::size_t>(b)];
if (mm.min < result.min) result.min = mm.min;
if (mm.max > result.max) result.max = mm.max;
}
return result;
}
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
std::size_t channelCount,
std::size_t frameCount,
AudioSample linearThreshold) {
if (channelCount == 0) return kNoFrameAboveThreshold;
// Clamp to what the buffer actually holds — a caller frameCount that overstates
// the buffer must never read past the end (mirror of computeEnvelope's guard).
const std::size_t availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames);
if (frames == 0) return kNoFrameAboveThreshold;
// Scan backward: the first frame (from the end) whose loudest channel exceeds the
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
for (std::size_t f = frames; f > 0; --f) {
const std::size_t frame = f - 1;
const std::size_t base = frame * channelCount;
AudioSample peak = 0.0f;
for (std::size_t c = 0; c < channelCount; ++c) {
const AudioSample a = std::fabs(interleaved[base + c]);
peak = std::max(peak, a);
}
if (peak > linearThreshold) return frame;
}
return kNoFrameAboveThreshold;
}
} // namespace reasampler::audio
+122
View File
@@ -0,0 +1,122 @@
#pragma once
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved
// PCM. We compute our own thumbnails from the captured file rather than depending
// on REAPER's peak API: we own the file format, so this is simpler, testable, and
// dependency-free. A future bank panel (M5) calls this at whatever bin resolution
// the panel width dictates and draws one min/max envelope per channel.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
#include <cstddef>
#include <vector>
namespace reasampler::audio {
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so
// peaks consumes that directly with no lossy conversion. If a capture ever lands
// as a different depth, the caller converts to float at the boundary — the
// thumbnail core stays single-typed.
//
// NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's
// metadata struct. A `using Sample = float` here would collide at namespace scope
// wherever both headers are visible (the bank_panel module includes both). The
// audio-domain name also reads more precisely — this is one PCM sample value.
using AudioSample = float;
// One bin of a channel's envelope: the extremes of every sample that fell in it.
// min <= max always. For an empty bin (more bins than frames), both are 0.
struct MinMax {
AudioSample min = 0.0f;
AudioSample max = 0.0f;
bool operator==(const MinMax& o) const { return min == o.min && max == o.max; }
};
// One channel's envelope: exactly `binCount` bins, in time order.
using ChannelEnvelope = std::vector<MinMax>;
// Per-channel envelopes: outer index is channel (channelCount entries, order
// preserved — never mixed or folded), inner is that channel's bins.
using Envelope = std::vector<ChannelEnvelope>;
// Computes a per-channel min/max envelope from interleaved PCM.
//
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
// Size must be >= frameCount * channelCount; extra is ignored.
// channelCount channels per frame (the stride). Each channel is enveloped
// INDEPENDENTLY — no averaging, no stereo fold (precision
// invariant: channel count preserved).
// frameCount frames (samples-per-channel) to consider.
// binCount requested bins per channel. Honored exactly for any frameCount.
//
// Frame->bin partition: frames are split into `binCount` contiguous spans as
// evenly as possible; when frameCount does not divide evenly, the remainder is
// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the
// tail is never dropped and no bin reads out of bounds. When binCount > frameCount
// the trailing empty bins are {0, 0}.
//
// Defined behavior for degenerate input (no UB, no throw):
// binCount == 0 -> per channel: an empty bin vector.
// channelCount == 0 -> an empty envelope (no channels).
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t channelCount,
std::size_t frameCount,
std::size_t binCount);
// The merged min/max for display column `col` (0-based, of `columnCount` total columns)
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects
// to that column. This is the display-side collapse of an envelope computed at HIGHER
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
//
// Bin->column mapping mirrors computeEnvelope's half-open partition:
// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount).
// When that range is empty (more columns than bins), the enclosing bin
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
// [0, columnCount-1]. Pure.
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous:
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
inline constexpr std::size_t kNoFrameAboveThreshold =
static_cast<std::size_t>(-1);
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max
// absolute value across all channels of that frame — NO stereo fold, just the
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index.
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
//
// This is the boundary primitive behind the realtime tail's decay-scan trim
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is
// scanned back from the end for the last frame still above -72 dB, and the file is
// truncated one frame past it. Deliberately a separate primitive from
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail),
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
//
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
// Must hold >= frameCount * channelCount; extra is ignored, and a
// short buffer is clamped to what it actually holds (no OOB read).
// channelCount channels per frame (the stride). The per-frame test is the max
// |sample| over these channels — the frame is "above" if its
// loudest channel is above the threshold.
// frameCount frames to consider (the scan starts at the last of these).
// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the
// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB.
// A frame counts as above when its peak is STRICTLY > this.
//
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
std::size_t channelCount,
std::size_t frameCount,
AudioSample linearThreshold);
} // namespace reasampler::audio
+76
View File
@@ -0,0 +1,76 @@
// batch_capture.cpp — pure logic for M11 batch capture. See header.
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp.
#include "core/capture/batch_capture.h"
#include <algorithm>
namespace reasampler::capture {
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges) {
std::vector<CaptureUnit> units;
units.reserve(ranges.size());
int ordinal = 0;
for (const BatchRange& r : ranges) {
// Drop empty/inverted ranges — the offline backend refuses end<=start too, so
// planning one would only manufacture a guaranteed per-unit failure. Ordinals
// count kept units so the reported numbering is contiguous.
if (!(r.endSeconds > r.startSeconds)) continue;
++ordinal;
units.push_back({ordinal, r.startSeconds, r.endSeconds});
}
return units;
}
void BatchOutcome::record(int ordinal, bool ok, std::string detail) {
results_.push_back({ordinal, ok, std::move(detail)});
}
std::size_t BatchOutcome::succeeded() const {
return static_cast<std::size_t>(
std::count_if(results_.begin(), results_.end(),
[](const BatchUnitResult& r) { return r.ok; }));
}
std::size_t BatchOutcome::failed() const {
return results_.size() - succeeded();
}
std::vector<BatchUnitResult> BatchOutcome::failures() const {
std::vector<BatchUnitResult> out;
for (const BatchUnitResult& r : results_)
if (!r.ok) out.push_back(r);
return out;
}
std::string BatchOutcome::summaryLine(const std::string& noun) const {
const std::size_t n = total();
const std::size_t ok = succeeded();
if (n == 0)
return "ReaSampler batch capture: nothing to capture.";
const std::string plural = (n == 1) ? noun : noun + "s";
if (ok == n)
return "ReaSampler batch capture: " + std::to_string(ok) + " " +
plural + " captured.";
// Mixed / all-failed: report the ratio and enumerate the failed ordinals so the
// user knows exactly which units to retry. No partial corruption is implied —
// each captured unit is a complete, independent bank sample.
std::string line = "ReaSampler batch capture: " + std::to_string(ok) + " of " +
std::to_string(n) + " " + plural + " captured (" +
std::to_string(n - ok) + " failed: ";
bool first = true;
for (const BatchUnitResult& f : results_) {
if (f.ok) continue;
if (!first) line += ", ";
line += "#" + std::to_string(f.ordinal);
first = false;
}
line += ").";
return line;
}
} // namespace reasampler::capture
+100
View File
@@ -0,0 +1,100 @@
#pragma once
// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires
// N captures: one bank sample per selected item / per razor area).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW
// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas)
// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are
// unit-tested outside the DAW:
//
// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered
// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds.
// Empty/inverted ranges are DROPPED (mirrors the offline backend's own
// end>start guard) so a zero-length item/area never produces a stray render.
// Order is preserved: unit ordinals count only the KEPT units, so a batch of
// three valid items yields ordinals 1,2,3 regardless of dropped neighbors.
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary
// (succeeded / failed counts + the ordered list of failures) so the shell can
// report a mixed result with one console line and no partial-corruption
// ambiguity. The AGGREGATION is pure; the render loop that feeds it is shell.
//
// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a
// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a
// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the
// shell passes the scope straight through to each render, unchanged from the
// single-capture path.
#include <cstddef>
#include <string>
#include <vector>
namespace reasampler::capture {
// One capture in a batch: an exact source range plus its 1-based ordinal within the
// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's
// unique tag is 1-second-granular, so a fast batch could otherwise collide N files
// onto one name) and labels a failure in the summary.
struct CaptureUnit {
int ordinal = 0; // 1-based, counts kept units only
double startSeconds = 0.0; // exact — no rounding
double endSeconds = 0.0;
};
// A source range handed in by the shell (a selected item's [pos, pos+len] or one
// razor area's [start, end]). Kept as a distinct type from CaptureUnit so the input
// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share
// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with
// bank_model's SourceRange, which carries PPQ fields this planner does not need.
struct BatchRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
};
// Validates + orders a batch's source ranges into capture units. Preserves input
// order; DROPS every range with end <= start (empty/inverted) so no stray render is
// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected
// item / no razor area) yields an empty plan — the shell reports "nothing to batch"
// and writes nothing (the same no-op posture the single-capture path takes).
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
// The per-unit verdict the shell records after each render attempt, in unit order.
struct BatchUnitResult {
int ordinal = 0; // the CaptureUnit's ordinal this result is for
bool ok = false; // true iff the render + bank-add succeeded
std::string detail; // failure reason (empty on success) — for the summary
};
// Order-preserving aggregation of a batch's per-unit results. Built incrementally by
// the shell (record() after each unit) so a mid-batch failure is captured without
// aborting the remaining units (no partial corruption: each unit is independent, and
// the selection is restored on every exit path by the shell's RAII guard).
class BatchOutcome {
public:
// Records one unit's verdict. Order of calls IS the reported order.
void record(int ordinal, bool ok, std::string detail = {});
std::size_t total() const { return results_.size(); }
std::size_t succeeded() const;
std::size_t failed() const;
const std::vector<BatchUnitResult>& results() const { return results_; }
// The ordered subset of results that failed (ok == false). For the summary line.
std::vector<BatchUnitResult> failures() const;
// A single human summary line for the console (explicit-action response — allowed
// by the console policy; a batch-completion summary with failure counts qualifies,
// per-unit success spam does not). `noun` is the unit word ("item" / "razor area").
// Examples:
// all-success, 3 items : "ReaSampler batch capture: 3 items captured."
// partial, 3 of 5 : "ReaSampler batch capture: 3 of 5 items captured "
// "(2 failed: #2, #4)."
// empty plan : "ReaSampler batch capture: nothing to capture."
std::string summaryLine(const std::string& noun) const;
private:
std::vector<BatchUnitResult> results_;
};
} // namespace reasampler::capture
+287
View File
@@ -0,0 +1,287 @@
#include "core/capture/capture_paths.h"
#include <cassert>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstring> // std::memcmp
#include <filesystem>
#include <vector>
namespace reasampler::capture {
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
// Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/).
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kPrime = 1099511628211ULL;
std::uint64_t h = kOffsetBasis;
for (std::size_t i = 0; i < len; ++i) {
h ^= static_cast<std::uint64_t>(data[i]);
h *= kPrime;
}
// Format as 16-digit lowercase hex (zero-padded) for a fixed-length string.
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx",
static_cast<unsigned long long>(h));
return std::string(buf);
}
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
// Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body
// through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57).
// Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped.
// If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back
// to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash.
//
// The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates
// FNV state instead of recording geometry — no second parser, same logic.
// FNV-1a 64-bit constants (same as hashBytes).
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kPrime = 1099511628211ULL;
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
auto tagEq = [&](std::size_t off, const char* tag) -> bool {
return off + 4 <= bytes.size() &&
std::memcmp(bytes.data() + off, tag, 4) == 0;
};
auto readU32LE = [&](std::size_t off) -> std::uint32_t {
return static_cast<std::uint32_t>(bytes[off]) |
(static_cast<std::uint32_t>(bytes[off + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[off + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[off + 3]) << 24);
};
bool isWav = bytes.size() >= 12 &&
tagEq(0, "RIFF") &&
tagEq(8, "WAVE");
if (isWav) {
// Accumulate FNV-1a starting with the domain-separation tag byte 'W'.
std::uint64_t h = kOffsetBasis;
auto feedByte = [&](std::uint8_t b) {
h ^= static_cast<std::uint64_t>(b);
h *= kPrime;
};
bool haveFmt = false;
bool haveData = false;
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
// whole-file hash of different bytes that happen to be the same length.
feedByte(static_cast<std::uint8_t>('W'));
std::size_t pos = 12;
while (pos + 8 <= bytes.size()) {
const std::size_t bodyOffset = pos + 8;
const std::uint32_t bodySize = readU32LE(pos + 4);
if (tagEq(pos, "fmt ")) {
// Feed the entire fmt body (all fields, including format tag, channels,
// sample rate, bits-per-sample — everything that defines the audio format).
if (bodyOffset + bodySize <= bytes.size()) {
for (std::uint32_t i = 0; i < bodySize; ++i)
feedByte(bytes[bodyOffset + i]);
haveFmt = true;
}
} else if (tagEq(pos, "data")) {
// Feed the entire PCM payload.
if (bodyOffset + bodySize <= bytes.size()) {
for (std::uint32_t i = 0; i < bodySize; ++i)
feedByte(bytes[bodyOffset + i]);
haveData = true;
}
}
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
// Advance past this chunk's body, honoring RIFF even-byte padding.
std::size_t advance = bodySize;
if (advance & 1u) ++advance; // RIFF pad byte
if (advance > bytes.size() - bodyOffset) break; // overrun guard
pos = bodyOffset + advance;
}
if (haveFmt && haveData) {
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx",
static_cast<unsigned long long>(h));
return std::string(buf);
}
// Falls through to whole-file fallback if chunks were missing/malformed.
}
// Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old
// per-call hashBytes). No prefix tag: identical to hashBytes(data, size).
return hashBytes(bytes.data(), bytes.size());
}
std::string normalizeSlashes(const std::string& path) {
std::string out = path;
for (char& c : out) {
if (c == '\\') c = '/';
}
// Strip a single trailing slash so joins do not double up. Preserve a lone
// "/" (root) — stripping it would turn root into empty.
if (out.size() > 1 && out.back() == '/') {
out.pop_back();
}
#ifdef _WIN32
// Windows paths are case-insensitive. Fold to lowercase so that two paths
// that differ only in drive-letter or component casing compare equal (e.g.
// "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is
// preserved (the filesystem is case-sensitive; folding would be wrong).
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
#endif
return out;
}
std::string sanitizeStem(const std::string& baseName) {
std::string out;
out.reserve(baseName.size());
for (unsigned char c : baseName) {
const bool keep = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' ||
c == '-';
out.push_back(keep ? static_cast<char>(c) : '_');
}
// Collapse to a stable default if nothing usable survived (e.g. all spaces).
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
bool hasAlnum = false;
for (unsigned char c : out) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9')) {
hasAlnum = true;
break;
}
}
if (out.empty() || !hasAlnum) {
return "capture";
}
return out;
}
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag) {
const std::string dir = normalizeSlashes(projectDir);
std::string stem = sanitizeStem(baseName);
if (!uniqueTag.empty()) {
stem += "_" + sanitizeStem(uniqueTag);
}
const std::string fileName = stem + ".wav";
// Precondition: the capture shell must resolve a non-empty project directory
// before calling this function. An empty projectDir would produce a bare
// relative "reasampler_bank" path — the silent default-location fallback this
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release
// so any caller that ignores the precondition fails loudly at the render/stat
// step rather than silently writing to CWD.
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
BankPaths p;
p.fileStem = stem; // stem only — REAPER appends extension
p.fileName = fileName;
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
// directory itself; RENDER_PATTERN supplies the file name separately).
// Empty when precondition is violated (dir empty) — caller must not proceed.
p.absoluteDir = dir.empty() ? std::string{}
: dir + "/" + kBankSubfolder;
return p;
}
std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {};
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so
// the two spellings can never drift (Phase R spelling-consistency invariant).
return std::string(kBankSubfolder) + "/" + fileName;
}
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath) {
// No default-location fallback (CLAUDE.md invariant): an empty project dir or
// relative path yields empty, not a bare relative path resolved against CWD.
if (projectDir.empty() || relativePath.empty()) {
return {};
}
const std::string dir = normalizeSlashes(projectDir);
const std::string rel = normalizeSlashes(relativePath);
if (dir.empty() || rel.empty()) {
return {};
}
return dir + "/" + rel;
}
std::string projectDirOfRpp(const std::string& rppPath) {
// An unsaved project reports an empty .rpp path; keep it empty so downstream
// resolution refuses (no default-location fallback). Mirrors persist.cpp's prior
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
if (rppPath.empty()) return {};
std::string dir = std::filesystem::path(rppPath).parent_path().string();
return normalizeSlashes(dir);
}
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir) {
BankRelocation r;
if (oldProjectDir.empty() || newProjectDir.empty()) {
return r; // needed=false, empty dirs — nothing to relocate
}
const std::string oldDir = normalizeSlashes(oldProjectDir);
const std::string newDir = normalizeSlashes(newProjectDir);
r.oldBankDir = oldDir + "/" + kBankSubfolder;
r.newBankDir = newDir + "/" + kBankSubfolder;
// A Save (in place) leaves the project dir unchanged — nothing to relocate.
// Only a Save-As to a different directory needs the bank moved.
r.needed = (oldDir != newDir);
return r;
}
ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid,
const std::string& lastPath,
const std::string& currentGuid,
const std::string& currentPath) {
// 1. The GUID is the identity of record and is checked FIRST. A different
// stored GUID means a genuinely different project is active — Load ITS index.
// This catches the regression that pointer-primary classification missed:
// REAPER RECYCLES ReaProject* addresses across close/open, so a reopened /
// new project can reuse the previous project's address (sameProjectObject ==
// true) while carrying a different stored GUID. Deciding on the pointer alone
// then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is
// immune to address recycling, so it leads. Also covers new/unsaved<->saved
// transitions (one GUID empty, the other not) and switching between two
// distinct saved projects.
if (currentGuid != lastGuid) {
return ProjectTransition::Load;
}
// From here currentGuid == lastGuid (they are equal; both may be empty for
// unsaved projects). The pointer now disambiguates the same-GUID case.
// 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our
// GUID onto a distinct project object. Load its (own) index; never relocate.
// Two unsaved projects (both GUIDs empty, distinct objects) also land here —
// Load, so switching between them installs the right in-memory state.
if (!sameProjectObject) {
return ProjectTransition::Load;
}
// 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object
// identity is proven and the record identity is unchanged — only the .rpp
// moved). Also the first save of an unsaved project (both GUIDs empty, old
// path empty): SaveAsRelocate is safe there because deriveRelocationPlan
// no-ops on the empty old dir (empty-GUID safety preserved) while poll()
// mints a GUID.
if (currentPath != lastPath) {
return ProjectTransition::SaveAsRelocate;
}
// 4. Same object, same GUID, same path — Save in place / idle tick.
return ProjectTransition::NoOp;
}
} // namespace reasampler::capture
+215
View File
@@ -0,0 +1,215 @@
#pragma once
// capture_paths — the REAPER-free path arithmetic behind offline capture.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The capture shell resolves the
// current project directory via REAPER APIs, then hands the raw strings here so
// the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file
// name, absolute render dir, project-relative index path) is unit-tested outside
// the DAW.
//
// Path convention: this module works in forward-slash form and does NOT touch
// the filesystem. The bank subfolder name is a fixed constant so the same
// project always resolves the same bank location (determinism).
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace reasampler::capture {
// The project-relative bank subfolder. All captured wavs live here so the bank
// travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank).
inline constexpr const char* kBankSubfolder = "reasampler_bank";
// A resolved pair of paths for one capture: where REAPER must be told to write
// (absolute, because RENDER_FILE wants a directory REAPER can create/open) and
// what we store in the BankModel (project-relative, because the index is
// relative-paths-only — CLAUDE.md precision invariant).
struct BankPaths {
std::string absoluteDir; // <projectDir>/reasampler_bank (forward slash)
std::string relativePath; // reasampler_bank/<fileName> (index value)
std::string fileName; // <stem>.wav (full file name)
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
};
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data`
// and returns it as a 16-character lowercase hex string. Designed to fill
// Sample::contentHash so the confirm-on-last-reference guardrail
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
// file" from "another bank holds the same file." An empty buffer returns the bare
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
// files would share, but real WAV files are never empty).
std::string hashBytes(const std::uint8_t* data, std::size_t len);
// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED).
//
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a
// `bext` chunk containing the origination date/time) even when the format config blob
// requests no BWF metadata. Two renders of identical audio therefore differ in those
// bytes, making whole-file hashes diverge and preventing dedup collapse.
//
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
// hashBytes result for a different file of the same size.
//
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
// unrecognized or malformed file still gets a non-empty hash rather than silently
// skipping dedup.
//
// Called by both capture commit paths (offline and realtime) in place of the raw
// hashBytes call.
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
// Normalizes a path to forward slashes and strips any trailing slash. Empty in
// -> empty out. Pure string transform (does not consult the filesystem).
// Platform case rule: on Windows (_WIN32) the result is also lowercased so that
// paths differing only in drive-letter or component casing compare equal (Windows
// paths are case-insensitive). On macOS/Linux the case is preserved exactly (those
// filesystems are case-sensitive).
std::string normalizeSlashes(const std::string& path);
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control)
// with '_', and collapses to "capture" if nothing usable remains. Deterministic:
// the same input always yields the same stem (feeds bit-identical file naming).
std::string sanitizeStem(const std::string& baseName);
// Derives the bank paths for one capture.
// projectDir : absolute directory of the current .rpp (any slash style)
// baseName : human base for the file stem (sanitized)
// uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a
// timestamp or counter) so repeated captures do not collide.
// Also sanitized. May be empty.
// Produces "<stem>[_<tag>].wav". The relativePath is always project-relative and
// forward-slashed so it satisfies BankModel::add's relative-only invariant.
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag);
// The project-relative index spelling for a bank file KNOWN ONLY by its file name —
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder +
// "/" + fileName), so a file the capture path created and a directory listing of that
// same file resolve to the byte-identical relative string — the safety-critical
// spelling-consistency the prune core's exact-string match depends on (a divergence
// here could make a referenced file look like an orphan). fileName is a bare entry
// name (no directory component); the caller supplies forward-slash-free names from the
// folder enumeration. Empty in -> empty out.
std::string bankRelativeForName(const std::string& fileName);
// --- Persist-side path arithmetic (M4) --------------------------------------
//
// The index stores relative paths only; on project load the persist shell must
// turn each entry's relativePath back into an absolute path against the CURRENT
// project directory (so a project opened from a new location still resolves its
// bank). This is the inverse of the relativePath the capture path produced.
//
// projectDir : absolute directory of the current .rpp (any slash style)
// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav")
//
// Returns "<projectDir>/<relativePath>" forward-slashed. Returns empty when
// either input is empty (no default-location fallback — CLAUDE.md invariant) so
// a caller that ignores an unsaved/unset project fails loudly rather than
// resolving against CWD.
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath);
// The project directory that holds a .rpp: its parent directory, forward-slashed,
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the
// no-default-location invariant). This is the M4 convention persist uses to place
// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves
// audio paths the SAME way persist does rather than re-implementing the derivation.
std::string projectDirOfRpp(const std::string& rppPath);
// A relocation plan for the physical bank folder on Save-As to a new project
// location. The index's relative paths do NOT change (they are relative to the
// project dir, which is what moved with the .rpp), so relocation is purely a
// folder move: copy/move the whole bank subfolder from the old project dir to
// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
struct BankRelocation {
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
std::string newBankDir; // <newProjectDir>/reasampler_bank
bool needed = false; // false when old==new (Save in place, not Save-As)
};
// Derives the relocation plan from the old and new project directories.
// oldProjectDir : project dir the bank currently sits under (any slash style)
// newProjectDir : project dir the .rpp was just saved to (any slash style)
// `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir).
// Returns a plan with empty dirs and needed=false when either input is empty.
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir);
// --- Project-identity transition (W12 combined identity fix) -----------------
//
// What the persist timer must do on each tick. Identity rests on TWO facts,
// layered GUID-PRIMARY:
// 1. the minted GUID — content-based identity of record, stored in ext state.
// It is IMMUNE to REAPER recycling a closed project's ReaProject* address,
// so it is checked FIRST.
// 2. sameProjectObject — did the same live ReaProject* stay active across the
// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to
// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID
// onto a distinct object) vs a genuine Save-As (one object, new path).
//
// This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As
// forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its
// parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer
// RECYCLING: REAPER reuses a closed project's address, so a reopened/new project
// can present the previous project's pointer with a different stored GUID —
// pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded.
// Checking the GUID first catches recycling; the pointer then separates a fork
// (same GUID, different object -> Load) from a Save-As (same GUID, same object,
// new path -> relocate).
//
// The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a
// DIFFERENT project object with the same GUID is a fork Load, never a relocate.
enum class ProjectTransition {
NoOp, // same object, same GUID, same location — nothing to do
Load, // a different project is active — load ITS index from ext state
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
};
// Classifies what a poll tick observed.
// sameProjectObject : true iff the SAME ReaProject* stayed active across the two
// ticks (poll() computes `proj == lastProject_`). The pure
// classifier takes the bool, not the raw pointer, to stay
// REAPER-free and testable.
// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved)
// lastPath : that project's .rpp path when last seen ("" if unsaved)
// currentGuid : the GUID stored in the now-active project's ext state ("" if
// unsaved or never written)
// currentPath : the now-active project's .rpp path ("" if unsaved)
//
// Rules (evaluated in EXACTLY this order):
// 1. currentGuid != lastGuid -> Load (different record identity:
// recycled pointer w/ different GUID,
// new/unsaved<->saved, or two distinct
// saved projects)
// 2. !sameProjectObject -> Load (same GUID, different object:
// forked sibling, or two unsaved projects)
// 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID,
// new path: genuine Save-As, or first save
// of an unsaved project — relocate no-ops
// on the empty old dir, poll() mints a GUID)
// 4. otherwise -> NoOp (same object, same GUID, same path)
//
// The GUID (identity of record) leads; the pointer only disambiguates the same-GUID
// case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved
// projects never physically relocate) is preserved because an empty old project dir
// makes deriveRelocationPlan's `needed` false.
ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid,
const std::string& lastPath,
const std::string& currentGuid,
const std::string& currentPath);
} // namespace reasampler::capture
+49
View File
@@ -0,0 +1,49 @@
// insert_plan.cpp — see insert_plan.h. Pure InsertMedia mode-bit arithmetic.
#include "core/capture/insert_plan.h"
namespace reasampler::capture {
namespace {
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track).
constexpr int kBaseCurrentTrack = 0; // add to current track
constexpr int kBaseNewTrack = 1; // add new track
// Tempo-conform bits, verbatim from the header doc-comment.
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo
} // namespace
int computeInsertMode(const InsertOptions& opts) {
int mode = opts.target == InsertTarget::NewTrack ? kBaseNewTrack
: kBaseCurrentTrack;
switch (opts.conform) {
case TempoConform::None:
// No tempo bits: native length, no stretch. (Also never &4.)
return mode;
case TempoConform::Ratio1x:
mode |= kMatchTempo1x;
break;
case TempoConform::RatioHalf:
mode |= kMatchTempoHalf;
break;
case TempoConform::RatioDouble:
mode |= kMatchTempoDbl;
break;
}
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the
// caller asked NOT to preserve pitch. When conform == None we already returned
// above, so this can never fire without a tempo bit present.
if (!opts.preservePitch)
mode |= kDontPreservePitch;
return mode;
}
} // namespace reasampler::capture
+75
View File
@@ -0,0 +1,75 @@
#pragma once
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing
// the InsertMedia `mode` bitmask from a small options struct.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to
// get wrong and its bits are load-bearing for the "no silent time-stretch"
// invariant, so it is factored here and unit-tested. The REAPER-bound placement
// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is
// DAW-verified.
//
// The bit meanings below are transcribed VERBATIM from the authoritative header
// doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia):
// mode: 0=add to current track, 1=add new track, 3=add to selected items as
// takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x,
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
// &64=don't preserve pitch when matching tempo, ...
// We intentionally use only the base target (0/1) and the tempo-conform bits
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals).
#include <cstdint>
namespace reasampler::capture {
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3).
// We expose only the two placement targets M6 needs; "add as takes" (3) is a
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's
// convention for base modes 0/1 (the header names no explicit edit-cursor bit;
// see the flagged runtime assumption in insert.cpp).
enum class InsertTarget {
NewTrack, // mode base 1: add a new track for the item
CurrentTrack, // mode base 0: add to the current/selected track
};
// Tempo-conform choice. Default is None: insert at the file's native length with
// NO stretching (the precision-preserving default). The three ratios are the
// explicit opt-in "try to match project tempo" paths — never applied silently.
// Ratio1x is the ordinary "conform to tempo"; Half/Double are the octave-shifted
// variants REAPER exposes for half/double-time material.
enum class TempoConform {
None, // no tempo bits set: native length, no stretch (default)
Ratio1x, // &8: try to match tempo 1x
RatioHalf,// &16: try to match tempo 0.5x
RatioDouble,// &32: try to match tempo 2x
};
// Options that shape one InsertMedia call. Defaults encode the intended path:
// current track (user's selection), no conform, pitch preserved.
struct InsertOptions {
InsertTarget target = InsertTarget::CurrentTrack;
TempoConform conform = TempoConform::None;
// Only meaningful when conform != None. When false, adds &64 ("don't preserve
// pitch when matching tempo") so a tempo match also shifts pitch (classic
// varispeed). Default true = preserve pitch across the tempo match. Ignored
// when conform == None (no tempo bits set, so pitch is moot).
bool preservePitch = true;
};
// Computes the InsertMedia `mode` integer for the given options.
//
// Guarantees enforced here (and asserted in tests):
// * The &4 stretch-to-time-selection bit is NEVER set (no silent stretch).
// * When conform == None, NONE of the tempo bits (&8/&16/&32/&64) are set — the
// item lands at native length.
// * Exactly one base target bit pattern is used (0 or 1), never 3.
int computeInsertMode(const InsertOptions& opts);
// The forbidden stretch bit, exposed so a test can assert it is never present in
// any computed mode (the "no silent time-stretch" invariant, made checkable).
inline constexpr int kStretchToTimeSelBit = 4;
} // namespace reasampler::capture
+127
View File
@@ -0,0 +1,127 @@
// realtime_record.cpp — pure logic for the realtime-record backend (M8). See header.
// NO REAPER types; unit-tested by tests/test_realtime_record.cpp.
#include "core/capture/realtime_record.h"
namespace reasampler::capture {
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
RecordModePlan p;
// Stereo vs mono output recording, latency-compensated either way so the
// recorded file lines up with the source. A request asking for <= 1 channel
// records mono-out; anything else records stereo-out. (Higher channel counts
// still record stereo-out here — REAPER's output-record modes are mono/stereo
// only; a >2-channel realtime capture is out of scope for this increment.)
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
: kRecModeStereoOutLatComp;
switch (tap) {
case OutputTap::PostFader: p.recModeFlags = kRecOutPostFader; break;
case OutputTap::PreFx: p.recModeFlags = kRecOutPreFx; break;
case OutputTap::PostFxPreFader: p.recModeFlags = kRecOutPostFxPreFader; break;
}
return p;
}
OutputTap outputTapForWetDry(double wetDry) {
// Fully wet (1.0) taps post-fader; any dry-ward value taps pre-FX — the true
// pre-FX dry that offline render cannot produce (the realtime backend's whole
// reason to exist for the M10 null test). PostFxPreFader is an explicit future
// option, not reachable from the wet/dry axis, so it is not returned here.
return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
}
Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
Sample s;
// Same id shape as the offline path: "cap-<tag>-<fileName>" would need the file
// name; here the recorded file name is the tail of relativePath. Keep the id
// stable + unique via the tag, and include the relative path tail so two
// captures with the same tag (impossible in practice) still differ.
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
s.displayName = cap.displayName;
s.relativePath = cap.relativePath; // project-relative (invariant)
s.sourceMode = cap.sourceMode;
s.sourceRange.startSeconds = cap.startSeconds;
s.sourceRange.endSeconds = cap.endSeconds;
// PPQ/beats deferred (musical-placement concern) — identical to the offline path.
s.wetDry = cap.wetDry;
s.trackGuids = cap.trackGuids;
s.channelCount = cap.channelCount;
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
s.lengthSeconds = cap.endSeconds - cap.startSeconds;
s.captureTempo = cap.captureTempo;
s.captureTimeSigNum = cap.captureTimeSigNum; // L7 F1 meter stamp (0/0 = unstamped)
s.captureTimeSigDenom = cap.captureTimeSigDenom;
s.tier = Tier::Scratch; // captures land in scratch by default
// contentHash set by the caller (capture_realtime.cpp) after the file is
// finalized and on disk — the hash is over the finished file bytes. Left empty
// here because sampleFromRecordedCapture runs before the file exists (the
// mapping is pure / DAW-free); the shell patches it in after the move+trim.
// Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the
// offline path: a realtime record of wet output is not a single played note, so
// no root note is derivable; loop points are set by a later explicit action.
s.createdTimestamp = cap.createdTimestamp;
return s;
}
RecordPhase advanceRecordPhase(RecordPhase current,
const RecordTickInputs& inputs,
double rangeStartSeconds,
double rangeEndSeconds) {
switch (current) {
case RecordPhase::Recording: {
// Transport stopped while we still expected to be recording -> the user
// (or REAPER) stopped early. Move to the flush wait and finalize whatever
// was captured up to the stop.
if (!inputs.transport.recording) return RecordPhase::Finalizing;
// Reached the range end (latency-compensated play position). >= (not >)
// so a cursor landing exactly on the end completes.
if (inputs.transport.playPosition >= rangeEndSeconds)
return RecordPhase::Finalizing;
// Self-defense (review §3): the transport is running but the play cursor
// is not advancing to the end (stuck / looping). Without this the machine
// stays in Recording forever, leaking the temp track + armed sink. Force
// the flush wait once wall-clock exceeds the nominal duration + margin.
const double ceiling =
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
return RecordPhase::Recording;
}
case RecordPhase::Finalizing: {
// The transport is stopped; wait for REAPER to flush/close the recorded
// take on the audio thread. Finalize (move + Sample) only once the file
// exists AND is stable (review §2) — moving it early races the flush and
// yields a truncated / missing capture.
if (inputs.fileReady) return RecordPhase::Done;
// Bound the wait: a file that never stabilizes fails cleanly rather than
// hanging the in-flight state for the session.
if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
return RecordPhase::Failed;
return RecordPhase::Finalizing;
}
// Terminal phases are sticky: once the verdict is in, a later tick (a stray
// extra call before the shell has finished tearing down) must not flip it.
case RecordPhase::Done:
case RecordPhase::Failed:
default:
return current;
}
}
bool isStopRequested(RecordPhase phase) {
return phase != RecordPhase::Recording;
}
bool isTerminalPhase(RecordPhase phase) {
return phase == RecordPhase::Done || phase == RecordPhase::Failed;
}
} // namespace reasampler::capture
+238
View File
@@ -0,0 +1,238 @@
#pragma once
// realtime_record — the REAPER-free logic behind the realtime-record backend (M8).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The realtime backend (capture.cpp)
// drives the transport, the temp track, the send routing, and the file move —
// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong
// pieces are split out here and unit-tested outside the DAW:
//
// 1. the record-mode/recipe bookkeeping: given a capture scope + a desired
// FX-tap point (post-fader / pre-FX / post-FX-pre-fader), the I_RECMODE and
// I_RECMODE_FLAGS integer values the temp track must carry.
// 2. the recorded-file -> Sample mapping: given a finished capture (the
// recorded file's project-relative path + the request's own bounds/format),
// the populated Sample handed to bank_model. Mirrors the inline Sample
// population OfflineRenderBackend does — factored out so it is tested once,
// without a DAW, and shared shape with the offline path is guaranteed.
//
// The I_RECMODE / I_RECMODE_FLAGS bit MEANINGS are transcribed verbatim from
// reaper_plugin_functions.h line ~2197-2198 (see kRecMode* constants); the CHOICE
// of which values each scope needs is this module's logic and is tested.
#include <cstdint>
#include <string>
#include <vector>
#include "core/model/bank_model.h" // Sample, SourceMode (pure)
namespace reasampler::capture {
using model::Sample;
using model::Tier;
using model::SourceMode;
// --- I_RECMODE values (verbatim from SDK header ~2197) -----------------------
//
// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none,
// 3=stereo out w/latency compensation, 4=midi output, 5=mono out,
// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace.
//
// We record a track's OUTPUT (the scoped signal routed into the temp track),
// latency-compensated, so the recorded file lines up sample-accurately with the
// source. Stereo vs mono is chosen by the request's channel count.
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp
// --- I_RECMODE_FLAGS values (verbatim from SDK header ~2198) ------------------
//
// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader).
//
// This is the ONLY documented pre-FX tap in the whole SDK — offline render has no
// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md).
// The realtime backend is therefore the true pre-FX "dry" path.
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet)
inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader
// The tap point on the source track's output the temp track records from.
// Orthogonal to the record mode (stereo/mono); this only sets the &3 flags bits.
enum class OutputTap {
PostFader, // fully wet, after this track's fader (kRecOutPostFader)
PreFx, // true dry, before this track's FX (kRecOutPreFx)
PostFxPreFader, // wet FX, before the fader (kRecOutPostFxPreFader)
};
// The concrete record-mode values a temp track must carry to capture the scoped
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags`
// sets the &3 output-recording tap bits (higher bits are left at their default 0
// here — we only own the tap-point bits).
struct RecordModePlan {
int recMode = kRecModeStereoOutLatComp;
int recModeFlags = kRecOutPostFader;
};
// Maps (channelCount, tap) to the record-mode values.
// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp.
// tap -> the &3 output-recording bits.
// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested
// without a DAW; the shell reads the request and applies these via
// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any
// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing
// capability). Kept pure + separate from recordModePlanFor so the wet/dry ->
// tap decision is tested on its own; PostFxPreFader is not selected by wetDry
// (it is an explicit future option, not on the wet/dry axis).
OutputTap outputTapForWetDry(double wetDry);
// --- Recorded-file -> Sample mapping ----------------------------------------
//
// The inputs a finished realtime capture yields, gathered by the shell into a
// pure struct so the Sample population is a single tested transform (mirror of
// the inline population in OfflineRenderBackend::capture).
struct RecordedCapture {
// Project-relative path of the recorded file (relative-paths-only invariant;
// the shell resolves REAPER's recorded absolute path back to project-relative).
std::string relativePath;
// The disambiguating tag that named the file (feeds the Sample id, so id and
// file name stay consistent — same discipline as the offline path).
std::string uniqueTag;
// Echoed from the request (exact bounds — no re-measuring the file).
SourceMode sourceMode = SourceMode::Realtime;
double startSeconds = 0.0;
double endSeconds = 0.0;
double wetDry = 1.0;
std::string displayName;
std::vector<std::string> trackGuids;
int channelCount = 0;
int sampleRate = 0; // 0 when the project rate was unknown (as offline)
double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo)
// Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime).
// 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out).
int captureTimeSigNum = 0;
int captureTimeSigDenom = 0;
std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock)
};
// Builds the Sample for a finished realtime capture. Deliberately identical in
// shape to OfflineRenderBackend's population: exact request bounds (no rounding),
// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start.
// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline).
Sample sampleFromRecordedCapture(const RecordedCapture& cap);
// --- Async record-phase state machine (M8 rework) ----------------------------
//
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport on
// REAPER's audio thread and returns immediately — it does NOT block until the range
// completes). The completion decision — "given where the transport is now, should
// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and
// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is
// factored out here; the REAPER shell only reads the transport/clock/file and applies
// the verdict (stop, wait for the file to flush, then finalize/abort + restore).
//
// The lifecycle has TWO waits, not one:
// 1. the RECORD wait (Recording): the transport is running; we wait for the play
// cursor to reach the range end — OR the user stops early — OR a wall-clock
// safety ceiling trips (a started-but-never-advancing transport, §3 of review).
// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes
// the recorded take on the AUDIO thread — the file may not be fully written/closed
// for a tick or two. We defer the file move until the file exists AND is stable
// (§2 of review), bounded by a flush ceiling so a file that never appears fails
// cleanly rather than hanging.
// Where an in-progress capture is in its lifecycle.
// Recording — live: transport running, shell keeps ticking.
// Finalizing — live-but-stopped: transport halted, shell stops the transport once
// then ticks waiting for the recorded file to flush/stabilize.
// Done — terminal: the file is flushed + stable, finalize (move + Sample) now.
// Failed — terminal: the flush ceiling tripped without a stable file — give up
// (RenderFailed) + restore. (A record that produced NO file at all also
// lands here via the shell's finalize returning RenderFailed.)
// Only Recording and Finalizing are live phases the shell advances per tick; Done and
// Failed are the shell's verdict to act on (finalize-or-fail, then restore).
enum class RecordPhase {
Recording,
Finalizing,
Done,
Failed
};
// A distilled transport reading for the pure transition, so the state machine never
// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition`
// is GetPlayPositionEx (latency-compensated what-you-hear position).
struct TransportReading {
bool recording = false;
double playPosition = 0.0;
};
// Everything the pure transition needs beyond the current phase, gathered by the
// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling
// decision (the shell only reads and reports; it never decides a transition itself).
struct RecordTickInputs {
TransportReading transport;
// Wall-clock seconds since begin() (the shell reads a steady clock). Drives the
// record safety ceiling: a transport that starts but never advances to the range
// end (stuck / looping) would otherwise keep the machine in Recording forever.
double elapsedSeconds = 0.0;
// Wall-clock seconds spent in the Finalizing phase (since the transport stop).
// Drives the flush ceiling: bound the deferred-finalize wait so a file that never
// stabilizes fails cleanly instead of hanging.
double finalizingSeconds = 0.0;
// Whether the recorded take's file exists AND is stable/closed this tick (the
// shell resolves the take source path and checks size-stable-across-a-tick).
// Only consulted in Finalizing.
bool fileReady = false;
};
// --- Safety ceilings (named constants, review §2/§3) -------------------------
//
// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form
// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in,
// or transport latency) never trips it; tight enough that a stuck transport is force-
// terminated within a few seconds of overrun.
inline constexpr double kRecordMarginSeconds = 5.0;
// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the
// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the
// take on the audio thread within a tick or two in practice; this is a generous bound.
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
// The pure transition: given the current phase, this tick's inputs, and the record
// range end, return the next phase. Total + deterministic.
//
// From Recording:
// * recording AND cursor < end AND under the record ceiling -> Recording (wait)
// * recording AND cursor >= end -> Finalizing (reached end)
// * NOT recording -> Finalizing (stopped early)
// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced)
// From Finalizing:
// * fileReady -> Done (flushed + stable)
// * over the flush ceiling without a stable file -> Failed (give up)
// * otherwise -> Finalizing (keep flushing)
// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a
// late tick before teardown finishes cannot flip the verdict (the idempotence the
// shell's single-restore relies on).
RecordPhase advanceRecordPhase(RecordPhase current,
const RecordTickInputs& inputs,
double rangeStartSeconds,
double rangeEndSeconds);
// True once the shell must STOP the transport and begin the flush wait — i.e. the
// phase has left Recording (Finalizing/Done/Failed). Used by the shell to fire the
// (idempotent) transport stop exactly on the Recording -> Finalizing edge.
bool isStopRequested(RecordPhase phase);
// True for the phases the shell must ACT on to conclude (finalize-or-fail + restore).
// Only Done and Failed are terminal; Recording and Finalizing are live.
bool isTerminalPhase(RecordPhase phase);
} // namespace reasampler::capture
+224
View File
@@ -0,0 +1,224 @@
// render_settings.cpp — pure logic for the three-scope capture action family. See header.
// NO REAPER types; unit-tested by tests/test_render_settings.cpp.
#include "core/capture/render_settings.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace reasampler::capture {
double autoTrimEndRatio() {
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
// the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio,
// "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119.
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
}
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
TailRenderSettings t;
switch (mode) {
case TailMode::None:
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off,
// disable-all normalize (the current default), no trim.
t.tailFlag = kTailFlagNone;
t.tailMs = 0.0;
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
return t;
case TailMode::Auto:
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence
// bit (32768) — every other postprocessing bit clear. A fixed-threshold
// trailing-silence trim is a pure boundary decision (it scales/limits/fades
// nothing), so it re-introduces none of the coloring the disable-all bit
// guarded against, and two identical requests trim at the identical sample
// -> bit-identical repeats hold (spec §surgical normalize).
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd;
t.trimEnd = autoTrimEndRatio();
return t;
case TailMode::Manual:
// Fixed tail, no trim -> keep the disable-all normalize exactly as the
// no-tail path does. Clamp to the 8 s cap even here: the runaway guard
// applies whether the length came from the Auto default or an explicit
// request (spec §Manual override). Negative requests floor to 0.
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
return t;
}
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
return t;
}
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
double manualTailMs) {
switch (mode) {
case TailMode::None:
// Exact — no extra recording (byte-identical to today's realtime capture).
return rangeEndSeconds;
case TailMode::Auto:
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
return rangeEndSeconds + kMaxTailSeconds;
case TailMode::Manual:
// Fixed window: range + the set length, clamped to the 8 s cap (the same
// runaway guard the offline Manual path applies). Negative floors to 0.
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
}
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
return rangeEndSeconds;
}
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
// handled by fxBypassPlanFor, not by these render bits.
RenderSettingsChoice c;
switch (mode) {
case SourceMode::MasterMix:
case SourceMode::TimeSelection:
// Master IS the mix — wet-only; &(1|2)==0, no source bits.
c.settings = kRenderMasterMix;
c.supported = true;
return c;
case SourceMode::SelectedTracks:
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
c.settings = kRenderSelTracksViaMaster;
c.supported = true;
return c;
case SourceMode::SelectedItems:
// Selected media items, rendered to ONE file (single-file bit) so a
// multi-item selection yields a single bank entry, not N wavs.
c.settings = kRenderSelItems | kRenderSingleFile;
c.supported = true;
return c;
case SourceMode::RazorArea:
// Render razor edits to ONE file (same single-file rationale as items).
c.settings = kRenderRazorEdits | kRenderSingleFile;
c.supported = true;
return c;
case SourceMode::Realtime:
// Not an offline-render source — the realtime backend (M8) owns it.
c.settings = kRenderMasterMix;
c.supported = false;
return c;
}
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
c.supported = false;
return c;
}
SourceMode sourceModeForScope(CaptureScope scope) {
switch (scope) {
case CaptureScope::Item: return SourceMode::SelectedItems;
case CaptureScope::Track: return SourceMode::SelectedTracks;
}
return SourceMode::SelectedItems; // unreachable for a valid enum; fail closed
}
RangeSource inferRangeSource(bool hasRazorArea) {
// Razor wins when present; otherwise the time selection. Orthogonal to scope.
return hasRazorArea ? RangeSource::Razor : RangeSource::TimeSelection;
}
FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
FxBypassPlan p;
switch (scope) {
case CaptureScope::Item:
// Item = take/item FX ONLY. Bypass the item's own track FX, every
// ancestor's FX, and the master's FX. (Take FX live in the item and
// are always rendered — there is no track to bypass them from.)
p.bypassSelfFx = true;
p.bypassAncestorFx = true;
p.bypassMaster = true;
return p;
case CaptureScope::Track:
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass
// every ancestor (parent/folder) and the master. Parent/master GAIN
// still applies (I_FXEN is FX-only) — documented boundary.
p.bypassSelfFx = false;
p.bypassAncestorFx = true;
p.bypassMaster = true;
return p;
}
return p; // unreachable; bypass nothing (fail to full-chain, never over-bypass)
}
std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
std::vector<RazorRange> ranges;
std::istringstream in(razorString);
// The string is space-separated TRIPLES: <start> <end> <envGuidString>.
// A track-audio area's third token is the literal two-char string `""`; an
// envelope-lane area's is a GUID `{…}`. We keep only track-audio triples.
std::string startTok, endTok, guidTok;
while (in >> startTok >> endTok >> guidTok) {
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only).
// A track-audio area's GUID token is the empty quoted string `""`.
if (guidTok != "\"\"") continue;
// Parse the two time tokens. std::stod throws on garbage — guard so one
// malformed triple does not abort the whole parse.
double start = 0.0, end = 0.0;
try {
std::size_t sp = 0, ep = 0;
start = std::stod(startTok, &sp);
end = std::stod(endTok, &ep);
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse
// is a malformed area, not a valid range.
if (sp != startTok.size() || ep != endTok.size()) continue;
} catch (...) {
continue;
}
if (end > start) ranges.push_back({start, end}); // drop empty/inverted
}
return ranges;
}
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
if (ranges.empty()) return {0.0, 0.0};
RazorRange u = ranges.front();
for (const RazorRange& r : ranges) {
if (r.startSeconds < u.startSeconds) u.startSeconds = r.startSeconds;
if (r.endSeconds > u.endSeconds) u.endSeconds = r.endSeconds;
}
return u;
}
const std::vector<CaptureActionDef>& captureActionTable() {
// Built once (function-local static): two SCOPE actions, item + track. Both
// exact bounds by default; the tail mode a capture applies is read from the
// docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT
// a per-action variant. Ids are FOREVER-STABLE — never edit a shipped string.
// Each action infers its range (razor-else-time) at fire time and enforces its
// FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET /
// CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in
// main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise
// mirror-unregistered) — to capture the master you render a track.
static const std::vector<CaptureActionDef> table = {
// Item scope — item/take FX only. Suffix + phrase are channel-agnostic; the shell
// composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name.
{"CAPTURE_ITEM",
"capture selected item(s)", "item",
CaptureScope::Item},
// Track scope — item FX + the track's own FX.
{"CAPTURE_TRACK",
"capture selected track(s)", "track",
CaptureScope::Track},
};
return table;
}
} // namespace reasampler::capture
+265
View File
@@ -0,0 +1,265 @@
#pragma once
// render_settings — the REAPER-free logic behind the capture action family.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and
// action layer (main.cpp) read the actual DAW state (time selection, selected
// tracks/items, razor strings, the ancestor-track chain) and hand the raw values
// here so the genuinely-pure, easy-to-get-wrong pieces are unit-tested outside
// the DAW:
//
// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only).
// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound.
// 3. range inference: razor-present -> razor union, else time selection. Range
// is a SOURCE choice orthogonal to the capture scope.
// 4. the FX-scope bypass plan: given a scope + an ancestor-chain length, which
// tracks' FX to bypass so each scope hears only the FX it should (the M7
// "items captured through parent FX" defect is corrected here).
// 5. the capture-action table (id string, description, scope) — the taxonomy,
// in one place so main.cpp iterates it instead of hand-listing.
//
// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from
// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of
// which bits each source mode sets is this module's logic and is tested.
#include <string>
#include <vector>
#include "core/model/bank_model.h" // SourceMode (pure enum)
namespace reasampler::capture {
using model::SourceMode;
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
//
// Only the bits this module actually uses are named. Values are the documented bit
// weights; the DOC of each is the SDK header's, not a guess.
inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits
inline constexpr int kRenderSelItems = 32; // &32 selected media items
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no
// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
// mechanism (see fxBypassPlan below) — bypassing the FX-enable of the tracks that
// fall outside a scope — NOT by any render bit. All capture actions render wet
// (post the FX that remain enabled); the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
//
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release
// decay past the range end. Every offline capture renders custom-time-bounds, so
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047).
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside
// the DAW exactly like renderSettingsFor; the backend just applies them.
//
// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051):
// &32768 = trim ending silence (the surgical Auto path)
// &(4<<16) = disable all render postprocessing (the None/Manual path)
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render
// custom bounds unconditionally, so this is the only tail bit that ever applies.
inline constexpr int kTailFlagNone = 0;
inline constexpr int kTailFlagCustomBounds = 1; // &1
// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed
// region is inaudible decay, loud enough to not chase a reverb's infinite noise
// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from
// this dB, never the reverse.
inline constexpr double kAutoTrimThresholdDb = -72.0;
// Max tail rendered past the range end. The runaway guard: a non-decaying or
// looping signal never crosses the trim threshold, so this caps the render.
// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths.
inline constexpr double kMaxTailSeconds = 8.0;
inline constexpr double kMaxTailMs = 8000.0;
// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20).
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and
// a future config change to the dB does not require hand-recomputing the ratio.
//
// std::pow is not constexpr before C++26, so this is a function, not a constant.
// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets).
double autoTrimEndRatio();
// The three tail states (docs/product/capture-tail.md §The three tail states):
// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The
// default and the ONLY mode for null-test / verify captures.
// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical
// normalize). The user-facing tail-on option (panel toggle).
// Manual — a fixed tail length (clamped to the 8 s cap), no trim.
enum class TailMode {
None,
Auto,
Manual,
};
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS
// the backend already sets. `trimEnd` is meaningful only when the trim-end normalize
// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads
// these four fields straight onto GetSetProjectInfo.
struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
double tailMs = 0.0; // RENDER_TAILMS
int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
};
// Maps a tail mode (+ the requested manual tail ms) to its RENDER_* values.
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
// The REALTIME record-window end (in project seconds) a tail mode records to, given
// the request's exact range end (docs/product/capture-tail.md §The realtime path).
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so
// the window end is where the transport actually stops:
// None -> rangeEndSeconds (exact — no extra recording).
// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later).
// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim).
// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and
// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned
// end to the record time selection. Shared -72 dB / 8 s constants are the same ones
// the offline tail uses (single source of truth).
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
double manualTailMs);
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
// for SourceMode::Realtime (that is the M8 backend, not offline render).
struct RenderSettingsChoice {
int settings = kRenderMasterMix;
bool supported = true; // false => not an offline-render source (e.g. Realtime)
};
// Maps a source mode to its RENDER_SETTINGS value (which content the render
// covers). FX scoping is orthogonal — done by fxBypassPlan, not by these bits.
// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest
// as the seam for future dry work (M10 null test).
//
// CONFIRMED (SDK header ~3041):
// MasterMix / TimeSelection -> master mix (0).
// SelectedTracks -> &128 selected tracks via master.
// SelectedItems -> &32 | single-file (one wav, not one-per-item).
// RazorArea -> &4096| single-file.
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// --- Capture scope: the FX-scope invariant (Daniel, critical) ----------------
//
// Two FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope.
// Item -> item/take FX ONLY (no track, no parent/folder, no master FX).
// Track -> item FX + the selected track's OWN track FX (no parent/folder/master).
// There is NO master scope: to capture the master you render a track instead. The
// master track's FX/gain/pan are still NEUTRALIZED as part of the out-of-scope
// chain for both item and track captures (bypassMaster below) — master is a
// bypass target, not a capture scope.
enum class CaptureScope {
Item,
Track,
};
// The render source mode each scope drives. Item captures selected items, Track
// captures selected tracks (via master).
SourceMode sourceModeForScope(CaptureScope scope);
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
//
// Every scope action infers its render range the same way: if a razor area is
// present, use the razor union; otherwise use the time selection. Razor is a
// range SOURCE, not a capture mode (the M7 four-mode model conflated them).
enum class RangeSource {
Razor, // a razor area is present -> use its union bound
TimeSelection, // no razor -> use the time selection
};
// Picks the range source. Pure so the "razor wins when present" rule is tested
// without a DAW; the shell supplies whether any razor area was found.
RangeSource inferRangeSource(bool hasRazorArea);
// --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
//
// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the
// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX,
// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to
// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and
// clears I_FXEN on each flagged track, snapshotting first (RAII restore).
//
// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan.
// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN
// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item
// capture rendered via master does NOT bake in the parent/folder/master fader
// level (Daniel: the capture is likely re-routed through that chain later). PAN
// is deliberately left untouched (D_PAN is coupled to D_WIDTH/D_PANLAW — a clean
// neutralize is non-trivial; flagged as a follow-up, not half-done). This plan
// selects the SET; the guard applies both the FX bypass and the gain neutralize.
struct FxBypassPlan {
bool bypassSelfFx = false; // the captured track's own FX
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
bool bypassMaster = false; // the master track's FX
};
FxBypassPlan fxBypassPlanFor(CaptureScope scope);
// A single razor-edit area: a time range on one track (envelope GUID ignored —
// razor captures target track-audio areas, not envelope lanes).
struct RazorRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
};
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
// TRIPLES of <start> <end> <envGuidString>. The envelope GUID is "" (an empty
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a
// GUID like {…} for an envelope-lane area.
//
// Returns only the track-audio ranges (envelope-lane triples are skipped — razor
// captures target track audio, not envelope lanes). Malformed/short trailing tokens are ignored, not fatal.
// A range with end <= start is dropped (no negative/empty areas leak through).
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
// The union bound (min start, max end) of a set of razor ranges — the exact
// window the offline render must cover so every area is inside the rendered file.
// Returns {0,0} for an empty input (caller treats that as "no razor area").
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
//
// One row per bindable SCOPE action: item and track. The range each captures
// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action
// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads
// at fire time (see tail_control + bank_panel), so a single pair of actions covers
// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
//
// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the
// tail after the family prefix) and a description PHRASE (the label after the "ReaSampler:
// " lead). The registering shell composes the full, channel-qualified id/name via
// app_version's channelCommandId / channelActionName (commandIdPrefix + suffix /
// actionDisplayPrefix + phrase). This keeps the pure table free of any channel branch:
// stable rebuilds the exact shipped id "CEREBELLUM_REASAMPLER_CAPTURE_TRACK" from
// prefix + "CAPTURE_TRACK"; beta yields "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK".
//
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id) — never
// change a shipped value. baseName feeds the file stem (sanitized by capture_paths).
struct CaptureActionDef {
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
const char* baseName; // file-stem base for this capture
CaptureScope scope; // FX scope (item / track)
};
// The capture-action table. Iterated by main.cpp to register the family and route
// each fired command back to its definition. Kept here (pure) so the taxonomy is
// one testable list, not scattered registration code.
//
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture
// the master you render a track. Razor is an inferred range, not a mode, and each
// scope enforces its FX-scope invariant via fxBypassPlanFor. The tail mode each
// capture applies is read from the docked-panel setting, not baked into the row.
const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler::capture
+131
View File
@@ -0,0 +1,131 @@
// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor.
#include "core/capture/tail_control.h"
#include <algorithm>
#include <cstdio>
#include "core/json/json.h"
namespace reasampler::capture {
TailMode cycleTailMode(TailMode current) {
switch (current) {
case TailMode::None: return TailMode::Auto;
case TailMode::Auto: return TailMode::Manual;
case TailMode::Manual: return TailMode::None;
}
return TailMode::None; // unreachable for a valid enum; fail to the safe default
}
double clampManualMs(double manualMs) {
// Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a
// negative request to 0, cap at the 8 s ceiling.
return std::clamp(manualMs, 0.0, kMaxTailMs);
}
double adjustManualMs(double current, int notches, double stepMs) {
// Clamp the stepped value so both scroll directions saturate at the bounds rather
// than running away (the same [0, kMaxTailMs] guard clampManualMs enforces).
return clampManualMs(current + notches * stepMs);
}
std::string tailToggleLabel(const TailSetting& setting) {
switch (setting.mode) {
case TailMode::None: return "Tail: Off";
case TailMode::Auto: return "Tail: Auto";
case TailMode::Manual: {
// Append the CLAMPED length in seconds to one decimal so the readout can
// never show an over-cap value even if manualMs was stored past the cap.
const double seconds = clampManualMs(setting.manualMs) / 1000.0;
char buf[32];
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds);
return std::string(buf);
}
}
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
}
// ---------------------------------------------------------------------------
// JSON round-trip
// ---------------------------------------------------------------------------
//
// The setting is a flat object of one enum + one double, riding the shared
// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader —
// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17
// significant digits (%.17g) — the shortest form that round-trips every IEEE-754
// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit.
// deserialize stays forgiving in outcome: any parse failure returns nullopt so
// the caller falls back to a default, exactly as an absent ext-state key does.
namespace {
// The persisted integer for a mode. Stable forever (stored in the .rpp): never
// renumber these values or an already-saved project reads back the wrong mode.
int modeToInt(TailMode m) {
switch (m) {
case TailMode::None: return 0;
case TailMode::Auto: return 1;
case TailMode::Manual: return 2;
}
return 0;
}
std::optional<TailMode> modeFromInt(int v) {
switch (v) {
case 0: return TailMode::None;
case 1: return TailMode::Auto;
case 2: return TailMode::Manual;
default: return std::nullopt; // unknown enumerant -> malformed -> default
}
}
} // namespace
std::string serializeTailSetting(const TailSetting& setting) {
// Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}.
std::string out;
{
json::Writer w(out);
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
} // Writer closes the object here (see bank_model's NRVO note)
return out;
}
std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
json::Reader r(blob);
if (!r.consume('{')) return std::nullopt;
int modeInt = 0;
double ms = 0.0;
bool haveMode = false, haveMs = false;
r.skipWs();
if (!r.consume('}')) {
do {
std::string key;
if (!r.parseKey(key)) return std::nullopt;
if (key == "mode") {
if (!r.parseInt(modeInt)) return std::nullopt;
haveMode = true;
} else if (key == "manualMs") {
if (!r.parseDouble(ms)) return std::nullopt;
haveMs = true;
} else {
if (!r.skipValue()) return std::nullopt; // forward-compat
}
} while (r.consume(','));
if (!r.consume('}')) return std::nullopt;
}
if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default
const std::optional<TailMode> mode = modeFromInt(modeInt);
if (!mode) return std::nullopt;
TailSetting out;
out.mode = *mode;
out.manualMs = ms;
return out;
}
} // namespace reasampler::capture
+70
View File
@@ -0,0 +1,70 @@
#pragma once
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
// toggle. The panel shell (bank_panel.cpp) owns the SWELL window, LICE drawing, and
// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length
// clamp, and the toggle's label text — lives here so it is unit-tested outside the
// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
// without REAPER.
#include <optional>
#include <string>
#include "core/capture/render_settings.h" // TailMode (pure enum) — the three-state tail contract
namespace reasampler::capture {
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a
// project with no stored tail setting (older / never-adjusted) falls back to on load.
inline constexpr double kDefaultManualTailMs = 2000.0;
// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough
// that a few notches cover the useful range, fine enough to dial a length precisely.
// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs.
inline constexpr double kManualStepMs = 250.0;
// The panel's current tail setting: the mode plus the length used ONLY when the
// mode is Manual. Held as in-memory panel/session state (bank_panel.cpp), default
// None so a capture with no explicit choice stays exact-bounds / byte-identical to
// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is
// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest.
struct TailSetting {
TailMode mode = TailMode::None;
double manualMs = kDefaultManualTailMs;
};
// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is
// pinned by a test and the panel's click handler owns no enum arithmetic of its own.
// An out-of-range value (unreachable for a valid enum) cycles back to None.
TailMode cycleTailMode(TailMode current);
// The effective manual length a Manual capture uses: `manualMs` clamped to
// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies).
// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
double clampManualMs(double manualMs);
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to
// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust
// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler
// owns no arithmetic of its own. Meaningful only for TailMode::Manual.
double adjustManualMs(double current, int notches, double stepMs);
// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the
// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" —
// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are
// test-pinned, including the boundary lengths (0.0s, 8.0s).
std::string tailToggleLabel(const TailSetting& setting);
// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail
// setting per-project alongside the bank and view model. Kept pure/testable here —
// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a
// compact object; deserialize returns std::nullopt on malformed input so the caller
// (persist) falls back to a default setting, exactly as an absent key does.
std::string serializeTailSetting(const TailSetting& setting);
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
} // namespace reasampler::capture
+160
View File
@@ -0,0 +1,160 @@
// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor.
#include "core/capture/wav_trim.h"
#include <cstring> // std::memcpy, std::memcmp
namespace reasampler::capture {
namespace {
// Little-endian readers. Bounds are checked by the caller before each read; these
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
}
std::uint32_t readU32LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint32_t>(b[off]) |
(static_cast<std::uint32_t>(b[off + 1]) << 8) |
(static_cast<std::uint32_t>(b[off + 2]) << 16) |
(static_cast<std::uint32_t>(b[off + 3]) << 24);
}
bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char* tag) {
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
}
// WAVE format tags we accept as 32-bit float (see wav_trim.h FORMAT ASSUMPTION).
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
} // namespace
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
WavLayout out;
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
if (bytes.size() < 12) return out;
if (!tagEquals(bytes, 0, "RIFF")) return out;
if (!tagEquals(bytes, 8, "WAVE")) return out;
bool haveFmt = false;
std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0;
std::uint32_t sampleRate = 0;
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
// Walk the sub-chunks after "WAVE" (offset 12). Each is: id(4) size(4) body(size),
// body padded to an even byte count (RIFF word alignment). Stop cleanly if a
// header would run past the buffer — a malformed/truncated file is "invalid",
// never an OOB read.
std::size_t pos = 12;
while (pos + 8 <= bytes.size()) {
const std::size_t bodyOffset = pos + 8;
const std::uint32_t bodySize = readU32LE(bytes, pos + 4);
if (tagEquals(bytes, pos, "fmt ")) {
// fmt body: at least 16 bytes (PCM/float common fields).
if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out;
fmtTag = readU16LE(bytes, bodyOffset + 0);
channels = readU16LE(bytes, bodyOffset + 2);
sampleRate = readU32LE(bytes, bodyOffset + 4);
bitsPerSample = readU16LE(bytes, bodyOffset + 14);
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
if (fmtTag == kWaveFormatExtensible) {
if (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) {
extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24);
}
}
haveFmt = true;
} else if (tagEquals(bytes, pos, "data")) {
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
// Reject if it runs past the buffer (truncated / lying header).
if (bodyOffset + bodySize > bytes.size()) return out;
if (!haveFmt) return out; // data before fmt — not a WAV we parse
// Plain IEEE-float tag (0x0003): accept as-is.
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
// float and must be rejected to prevent mis-decoding as float.
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
(fmtTag == kWaveFormatExtensible &&
extensibleSubFormatTag == kWaveFormatIeeeFloat);
if (!floatTag || bitsPerSample != 32 || channels == 0) return out;
out.valid = true;
out.channelCount = channels;
out.sampleRate = sampleRate;
out.dataByteOffset = bodyOffset;
out.dataByteLength = bodySize;
out.riffSizeFieldOffset = 4;
out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32)
return out;
}
// Advance past this chunk's body, honoring RIFF even-byte padding. Guard the
// additions against size_t overflow (a hostile bodySize near SIZE_MAX).
std::size_t advance = bodySize;
if (advance & 1u) ++advance; // pad byte
if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop
pos = bodyOffset + advance;
}
return out; // no data chunk found -> invalid
}
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount) {
std::vector<AudioSample> out;
if (!layout.valid) return out;
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t totalFrames = layout.frameCount();
if (startFrame >= totalFrames) return out;
// Clamp the requested span to the frames that actually exist.
const std::size_t avail = totalFrames - startFrame;
const std::size_t frames = (frameCount < avail) ? frameCount : avail;
if (frames == 0) return out;
const std::size_t firstByte =
layout.dataByteOffset + startFrame * bytesPerFrame;
out.resize(frames * layout.channelCount);
// memcpy each float (LE on target hosts — see header's byte-order note).
for (std::size_t i = 0; i < out.size(); ++i) {
float f = 0.0f;
std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u);
out[i] = f;
}
return out;
}
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) {
WavTruncatePlan plan;
if (!layout.valid) return plan;
const std::size_t totalFrames = layout.frameCount();
if (keptFrames > totalFrames) return plan; // never grow
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t keptDataBytes = keptFrames * bytesPerFrame;
plan.valid = true;
plan.newFileByteLength = layout.dataByteOffset + keptDataBytes;
plan.dataSizeFieldOffset = layout.dataSizeFieldOffset;
plan.newDataSize = static_cast<std::uint32_t>(keptDataBytes);
plan.riffSizeFieldOffset = layout.riffSizeFieldOffset;
// RIFF size counts everything after the 8-byte "RIFF"+size prefix.
plan.newRiffSize = static_cast<std::uint32_t>(plan.newFileByteLength - 8);
return plan;
}
} // namespace reasampler::capture
+103
View File
@@ -0,0 +1,103 @@
#pragma once
// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
//
// WHY THIS EXISTS (docs/product/capture-tail.md §The realtime path). The realtime
// backend records a generous tail window, then trims the trailing decay by
// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is
// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk
// size and the `data` sub-chunk size) must be patched to the kept byte count, or
// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking,
// format verification, and the size-field patch offsets — is exactly the fiddly,
// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER
// shell (capture_realtime.cpp) does only the file I/O: read the bytes, call the
// pure parse, run the decay scan, call the pure plan, write the truncated bytes.
//
// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV
// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project
// record format, which the manual procedure sets to WAV/32-bit-float). This parser
// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt `
// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE
// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything
// else (a different depth, a non-WAV, a compressed source) is reported invalid and
// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a
// file it does not understand. This is deliberately conservative.
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::capture {
using audio::AudioSample;
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the
// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field
// is meaningful only when valid.
struct WavLayout {
bool valid = false;
std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride)
std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed)
// The `data` chunk: byte offset of its first PCM byte within the file, and its
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4).
std::size_t dataByteOffset = 0;
std::size_t dataByteLength = 0;
// Byte offset of the two little-endian uint32 size fields the truncate patch
// rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk
// size (the 4 bytes immediately before dataByteOffset).
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
std::size_t dataSizeFieldOffset = 0;
std::size_t frameCount() const {
const std::size_t bytesPerFrame = static_cast<std::size_t>(channelCount) * 4u;
return bytesPerFrame ? dataByteLength / bytesPerFrame : 0;
}
};
// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything
// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk,
// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only
// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB).
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
// Copies `frameCount` interleaved float frames starting at `startFrame` out of the
// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes).
// Clamps to the frames the buffer actually holds — never reads past `data`. Returns
// empty for an invalid layout or an out-of-range start. The floats are read
// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would
// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux
// on x86/ARM-LE) is little-endian and REAPER writes LE WAV.
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount);
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte
// length and the two size-field values to patch. `valid` is false if the layout is
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
// clamps beforehand; this guards it too).
struct WavTruncatePlan {
bool valid = false;
std::size_t newFileByteLength = 0; // truncate the file to exactly this length
std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32)
std::uint32_t newDataSize = 0; // kept PCM byte length
std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32)
std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes
// the 8-byte "RIFF"+size prefix)
};
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV.
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure +
// total. The shell applies it: patch the two size fields in the byte buffer, then
// truncate the file to newFileByteLength.
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
} // namespace reasampler::capture
@@ -0,0 +1,51 @@
// master_gain.cpp — see master_gain.h. Pure math; no LICE/VST3/REAPER includes.
#include "core/instrument/engine/master_gain.h"
#include "core/util/clamp01.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
namespace reasampler::instrument::engine {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
double masterGainDbFromNorm(double norm) {
norm = clamp01(norm);
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
}
double masterGainNormFromDb(double db) {
if (!(db > kMasterGainMinDb)) return 0.0; // -inf, NaN, and the floor all read 0
return clamp01((db - kMasterGainMinDb) / (kMasterGainMaxDb - kMasterGainMinDb));
}
double masterGainLinearFromNorm(double norm) {
norm = clamp01(norm);
if (norm <= 0.0) return 0.0; // TRUE silence at the bottom — not an epsilon
return std::pow(10.0, masterGainDbFromNorm(norm) / 20.0);
}
double masterGainNormFromLinear(double linear) {
if (!std::isfinite(linear) || linear <= 0.0) return 0.0;
return masterGainNormFromDb(20.0 * std::log10(linear));
}
void formatMasterGainLabel(double norm, char* buf, std::size_t len) {
if (!buf || len == 0) return;
norm = clamp01(norm);
if (norm <= 0.0) {
std::snprintf(buf, len, "-inf");
return;
}
const double db = masterGainDbFromNorm(norm);
std::snprintf(buf, len, "%+.1fdB", db);
}
} // namespace reasampler::instrument::engine
+58
View File
@@ -0,0 +1,58 @@
// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain.
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1)
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value,
// and the audio-thread multiply can never drift.
//
// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper
// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence,
// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb
// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm
// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is
// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain
// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it.
//
// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed
// output — these functions run on the UI/state threads only.
#pragma once
#include <cstddef>
namespace reasampler::instrument::engine {
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
inline constexpr double kMasterGainMinDb = -60.0;
inline constexpr double kMasterGainMaxDb = 24.0;
// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
double masterGainMaxLinear();
// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep
// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure.
double masterGainDbFromNorm(double norm);
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb,
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
// to the same true-zero bottom. +24 -> 1. Pure.
double masterGainNormFromDb(double db);
// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly
// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure.
double masterGainLinearFromNorm(double norm);
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at
// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0
// — the floor IS the -inf detent; values between true-zero and the floor cannot be represented
// on the knob and collapse to the bottom. unity -> ~0.714; masterGainMaxLinear() -> 1.
// Out-of-range/non-finite input clamps. Pure.
double masterGainNormFromLinear(double linear);
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes
// including the terminator. Pure.
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::instrument::engine
+434
View File
@@ -0,0 +1,434 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2
// route-(b) rationale (WDL drags <windows.h>), and the GA-Preserve root cause that replaced
// the naive dual-tap OLA with correlation-aligned splices.
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
//
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
// sample (source rate -> duration preserved). ONE active read tap advances by the shift
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of
// one window (+window toward older content for up-shifts, -window toward the writer for
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on
// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine,
// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure
// sine the correlation snaps the jump to an (integer + fraction) period count, so the output
// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity
// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the
// stream through with ZERO added latency; a silence-warmed one is a clean window delay.
#include "core/instrument/engine/pitch_shift.h"
#include <algorithm>
#include <cmath>
#include <limits>
namespace reasampler::instrument::engine {
namespace {
constexpr double kPi = 3.14159265358979323846;
} // namespace
void PitchShifter::configure(std::int64_t windowFrames) {
window_ = windowFrames;
if (window_ <= 1) {
// Pass-through: no ring, process() returns input unchanged.
ring_.clear();
ringLen_ = 0;
writePos_ = 0;
posA_ = posB_ = 0.0;
fading_ = false;
fadePos_ = 0;
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
filled_ = 0;
ratio_ = 1.0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
return;
}
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
ringLen_ = 2 * window_;
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
// Geometry (all quarters of the window):
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade.
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
// - corrFrames_: the correlation segment length. At an up-splice the reference segment
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists
// between the tap and the writer — the cap expresses that safety rather than leaving
// it coincidental. 512 bounds the splice burst.
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
maxLag_ = window_ / 4;
dLow_ = window_ / 4;
dHigh_ = ringLen_ - window_ / 4;
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512));
fadeLen_ = 0;
reset();
}
void PitchShifter::reset() {
if (window_ > 1) {
// Zero the ring and seed the active tap one window behind the writer — the exact
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there
// forever and either shift direction has maximal drift room. No history is declared
// (filled_ = 0): follow with prime() or warm() before streaming.
std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = 0;
posA_ = static_cast<double>(ringLen_ - window_);
posB_ = posA_;
fading_ = false;
fadePos_ = 0;
fadeLen_ = 0;
} else {
writePos_ = 0;
posA_ = posB_ = 0.0;
fading_ = false;
fadePos_ = 0;
fadeLen_ = 0;
}
filled_ = 0;
ratio_ = 1.0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true;
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
// window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is
// continuous at the freeze frame (no gain step); see the re-anchor block below.
if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
const double tOld =
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
double dB = static_cast<double>(writePos_) - posB_;
const double len = static_cast<double>(ringLen_);
while (dB < 0.0) dB += len;
while (dB >= len) dB -= len;
// Clamp in double before the int64 cast (matches splice() pattern; guards against UB
// when dB/ratio_ is very large, e.g. near-unity ratio at a high sample rate).
double left = (dB - 2.0) / ratio_;
if (left > static_cast<double>(fadeFrames_)) left = static_cast<double>(fadeFrames_);
const std::int64_t leftFrames = left > 1.0 ? static_cast<std::int64_t>(left) : 1;
const std::int64_t newFadeLen = std::min(fadeLen_, fadePos_ + leftFrames);
// Re-anchor: tOld < 1 because we are mid-fade, so newFadePos < newFadeLen (still fading).
fadePos_ = static_cast<std::int64_t>(tOld * static_cast<double>(newFadeLen));
fadeLen_ = newFadeLen;
}
}
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
if (window_ <= 1) return; // pass-through needs no priming
// Clamp to one window: the intended call primes exactly window() frames, and delay ==
// count must stay inside the safe band so the seed does not itself trigger a splice.
if (count < 0) count = 0;
if (count > window_) count = window_;
std::fill(ring_.begin(), ring_.end(), 0.0f);
for (std::int64_t i = 0; i < count; ++i) ring_[static_cast<std::size_t>(i)] = src[i];
// Writer continues after the primed span; the tap parks ON src[0] (delay == count), so
// the very first process() output is src[0] — zero structural latency at every ratio.
writePos_ = count % ringLen_;
posA_ = posB_ = 0.0;
fading_ = false;
fadePos_ = 0;
fadeLen_ = 0;
filled_ = count;
tailFrozen_ = false; // a fresh note-on always starts with a live writer
lastSplice_ = SpliceEvent{};
// ratio_ deliberately untouched: the voice sets it per frame around the prime.
}
void PitchShifter::warm() {
if (window_ <= 1) return; // pass-through needs no warm-up
// A prime() with one window of silence: same geometry (tap parked mid-band one window
// behind the writer), the zeros declared as valid history. At unity this is a bit-exact
// window() delay; an up-shift plays ~a window of silence before speaking (the pre-GA2
// onset) — stream callers with access to the upcoming source should prime() instead.
std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = window_ % ringLen_;
posA_ = posB_ = 0.0;
fading_ = false;
fadePos_ = 0;
fadeLen_ = 0;
filled_ = window_;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::setShiftRatio(double ratio) {
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run the tap backward/stall)
}
double PitchShifter::readTap(double pos) const {
// Fractional linear interpolation with ring wrap.
double p = pos;
const double len = static_cast<double>(ringLen_);
while (p < 0.0) p += len;
while (p >= len) p -= len;
const std::int64_t i0 = static_cast<std::int64_t>(p);
const double frac = p - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (i1 >= ringLen_) i1 = 0;
const double s0 = static_cast<double>(ring_[static_cast<std::size_t>(i0)]);
const double s1 = static_cast<double>(ring_[static_cast<std::size_t>(i1)]);
return s0 + (s1 - s0) * frac;
}
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward
// older content, -window_ = jump toward the writer), refined by a correlation search so
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content.
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best,
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_
// multiply-adds, once per splice.
const std::int64_t d = static_cast<std::int64_t>(delay);
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
// what real history exists (still many source periods with a full-window prime). The floor of
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
std::int64_t jump = nominalJump;
if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
if (jump > maxJump) jump = maxJump;
if (jump < 1) jump = 1;
}
// The correlation reference reads FORWARD from the tap; keep it strictly behind the
// writer even when the trigger undershot dLow_ by a large per-frame drift (extreme
// up-ratios): d - corr must stay >= 0.
const std::int64_t corr = std::max<std::int64_t>(1, std::min<std::int64_t>(corrFrames_, d - 1));
const std::int64_t iA =
((static_cast<std::int64_t>(posA_) % ringLen_) + ringLen_) % ringLen_;
auto scoreAt = [&](std::int64_t lag) -> double {
std::int64_t ia = iA;
std::int64_t ic = ((iA - jump + lag) % ringLen_ + ringLen_) % ringLen_;
double s = 0.0, ec = 0.0;
for (std::int64_t k = 0; k < corr; ++k) {
const double a = static_cast<double>(ring_[static_cast<std::size_t>(ia)]);
const double c = static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
s += a * c;
ec += c * c;
if (++ia >= ringLen_) ia = 0;
if (++ic >= ringLen_) ic = 0;
}
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward
// the higher-energy lag, so on a decaying tail every up-splice would prefer the
// loudest candidate over the best-ALIGNED one — a small level step per splice that
// the amplitude-complementary fade cannot hide. The reference segment's energy is
// constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full
// normalized form. A zero-energy candidate scores 0 (splicing into silence is benign).
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
};
std::int64_t bestLag = 0;
double bestScore = -std::numeric_limits<double>::infinity();
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4) {
const double s = scoreAt(lag);
if (s > bestScore) {
bestScore = s;
bestLag = lag;
}
}
const std::int64_t coarse = bestLag;
for (std::int64_t lag = coarse - 3; lag <= coarse + 3; ++lag) {
if (lag == coarse || lag < -maxLag_ || lag > maxLag_) continue;
const double s = scoreAt(lag);
if (s > bestScore) {
bestScore = s;
bestLag = lag;
}
}
// SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
// silence) keeps the integer lag, which is already benign there.
double frac = 0.0;
{
const double sM = scoreAt(bestLag - 1);
const double sP = scoreAt(bestLag + 1);
const double den = sM - 2.0 * bestScore + sP;
if (den < 0.0) {
frac = 0.5 * (sM - sP) / den;
if (frac > 0.5) frac = 0.5;
if (frac < -0.5) frac = -0.5;
}
}
// Hand the current position to the outgoing tap and relocate the active one.
posB_ = posA_;
double p = posA_ - static_cast<double>(jump) + static_cast<double>(bestLag) + frac;
const double len = static_cast<double>(ringLen_);
while (p < 0.0) p += len;
while (p >= len) p -= len;
posA_ = p;
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st =
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal
// fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
// mid-fade is covered by the same margin for any realistic per-frame bias.
//
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL
// ratio (there is no retreating write head), in EITHER shift direction — so the drain
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity
// included: splices fire in the frozen tail because the delay now drains at unity too).
fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
if (drainRate > 0.0) {
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
// at very high sample rates (where headroom/drainRate could overflow int64).
const double safeDbl = headroom > 0.0
? std::min(headroom / drainRate, static_cast<double>(fadeFrames_))
: 1.0;
fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl));
}
fading_ = true;
fadePos_ = 0;
// Record the decision for a linked follower channel (T1-01): the follower applies this
// verbatim so both channels share one lag and one splice schedule.
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
}
void PitchShifter::applySplice(const SpliceEvent& ev) {
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no
// correlation search of our own. The master's jump was clamped against ITS filled_/delay,
// which match ours by the lockstep contract (identical configure/prime/ratio history);
// the fade length likewise derives only from shared geometry + ratio.
posB_ = posA_;
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
const double len = static_cast<double>(ringLen_);
while (p < 0.0) p += len;
while (p >= len) p -= len;
posA_ = p;
fadeLen_ = std::max<std::int64_t>(1, ev.fadeLen);
fading_ = true;
fadePos_ = 0;
lastSplice_ = ev; // observable mirror (tests assert follower == master per frame)
}
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); }
AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) {
return processImpl(in, &master);
}
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer;
// 5 plain fields, negligible on the RT path).
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
// 1. Write the incoming sample at the write head (source rate). One more slot of the
// ring now holds valid history (capped at the ring length once it has wrapped).
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
// NOTHING (the ring keeps its all-real final two windows) and hold the write head;
// the read/splice/fade machinery below runs unchanged over the frozen content.
if (!tailFrozen_) {
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
}
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
double out = readTap(posA_);
if (fading_) {
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
const double gNew = 0.5 * (1.0 - std::cos(kPi * t));
out = gNew * out + (1.0 - gNew) * readTap(posB_);
if (++fadePos_ >= fadeLen_) fading_ = false;
} else if (linked != nullptr) {
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the
// master channel did this frame. Lockstep state means our own trigger would have
// fired on the same frame; applying the master's decision keeps the two rings
// sample-aligned (one shared lag, one shared schedule).
if (linkedEv.fired) {
applySplice(linkedEv);
} else {
// Self-healing fallback (review rider): the master not firing normally means this
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever
// renders a mono block mid-note, this follower channel is skipped for that block
// while the master keeps advancing — its writePos_/filled_ falls behind and, with
// only the `if (linkedEv.fired)` path above, could never resync. So check this
// follower's OWN tap distance against the safe band and splice via its own search
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
// never triggers: the master's trigger fires first and this whole `if` is false.
double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
}
}
} else {
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At
// unity the delay is frozen at window/2 and neither trigger ever fires.
double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
}
}
// 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
// tap(s) by the shift ratio.
if (!tailFrozen_) {
++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0;
}
const double len = static_cast<double>(ringLen_);
posA_ += ratio_;
while (posA_ >= len) posA_ -= len;
if (fading_) {
posB_ += ratio_;
while (posB_ >= len) posB_ -= len;
}
return static_cast<AudioSample>(out);
}
} // namespace reasampler::instrument::engine
+230
View File
@@ -0,0 +1,230 @@
#pragma once
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
//
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency-
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump
// lands on an integer period count and the output stays a single shifted tone.
//
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
// route (a) `WDL_SimplePitchShifter`. But its include chain
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
// the SHELL, never in the pure core.
//
// WHY PRIME WITH REAL CONTENT (GA2-Preserve onset fix, 2026-07). Splices RELOCATE the tap
// into ring HISTORY — at note onset a silence-warmed ring has none, so every early splice
// jumped into zeros: a burst/gap/burst stutter for the first ~2 windows of every off-root
// note (the DAW "zero-sample gaps in the first few ms"; at +48 st the ~300 Hz gap cadence
// reads as a square-ish buzz). But this engine is NOT a streaming context: the caller owns
// the whole decoded sample, so the FUTURE of the stream is known at note-on. `prime()`
// pre-fills the ring with the actual first window of upcoming source and parks the tap on
// its oldest frame — output frame 0 IS source frame 0 (zero structural latency at every
// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
// land in unwritten silence.
//
// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the
// mirror problem lived at the note END. When the source ran out, the caller held the LAST
// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on.
// Splices landing in or referenced against it were unalignable, so the tap alternated
// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau
// displaced real ring history (the DAW report: periodic troughs "almost like ring
// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at
// the source: the WRITER parks, the ring keeps its all-real final two windows, and the
// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's
// own note end. See freezeTail() below.
//
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
// wav_trim do the same).
//
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
// thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring
// (bounded, allocation-free — safe on the audio thread at note-on). `process()` does
// NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time
// correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag
// range) that fires once per splice cadence (window / |ratio-1| frames), never per frame.
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::instrument::engine {
using audio::AudioSample;
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG
// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the
// follower applies exactly this decision instead of running its own search. Both channels
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice:
// stereo image wander at the splice cadence plus comb coloration on any mono sum.
struct SpliceEvent {
bool fired = false; // a splice was scheduled on this frame
std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed)
std::int64_t lag = 0; // correlation best integer lag
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
};
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
//
// The default-constructed shifter is INERT: with no configure() it passes input through
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
// byte-identical to the pre-S16 engine.
class PitchShifter {
public:
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames`
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running
// state. A larger window = fewer splices and a deeper alignment search; a PRIMED shifter
// has no added latency regardless (see prime()); the shell picks it from kPreserveWindowMs.
void configure(std::int64_t windowFrames);
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO
// structural latency at every ratio, and splices always have `count` frames of real
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()].
// When the PLAYABLE source is shorter than one window, prime only the real span and call
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
// RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured.
// The current shift ratio is left untouched.
void prime(const AudioSample* src, std::int64_t count);
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
// declare that window of silence as valid history. Kept for callers with no access to the
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking —
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a
// bit-exact window() delay. No-op when unconfigured.
void warm();
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
void setShiftRatio(double ratio);
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is
// live), then advances the write head by one and the tap(s) by the shift ratio. When the
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in);
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process()
// except the splice decision is NOT computed here — when `master.fired` is true this
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is
// considered. The caller must process the master channel FIRST each frame and pass its
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their
// ring state then advances in lockstep, so the follower's own trigger would have fired
// on the same frame anyway; skipping its search only removes the second correlation
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
// The splice decision made by the most recent process()/processLinked() call (fired ==
// false when that frame spliced nothing). Feed to a follower channel's processLinked().
const SpliceEvent& lastSplice() const { return lastSplice_; }
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
// their input and write nothing, but read, splice, and crossfade exactly as before over
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices
// landing in or referenced against it were unalignable, so the tap alternated real-tone /
// dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the
// note end as the plateau displaced real history). With the writer frozen the padding
// never enters the ring: every splice stays waveform-aligned against real content and
// the output remains a continuous tone — the final <= one window recycles the frozen
// tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the
// caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent;
// RT-safe (flag + bounded arithmetic, no allocation); cleared by reset()/prime()/warm().
void freezeTail();
bool tailFrozen() const { return tailFrozen_; }
// Reset running state to silence (ring zeroed, heads re-seeded mid-band, fill count zeroed)
// WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window.
// Follow with prime() (or warm()) before streaming: a bare reset has no declared history,
// so an immediate up-shift would starve its splices.
void reset();
// True once configure() sized a real ring (window > 1). A pass-through shifter is false.
bool configured() const { return window_ > 1; }
std::int64_t window() const { return window_; }
private:
double readTap(double pos) const; // fractional ring read, linear interp
// Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled
// span for up-jumps) and start the crossfade. `delay` is the tap's current delay behind
// the writer (the caller just computed it for the trigger test). Records the decision in
// lastSplice_ for a linked follower channel.
void splice(std::int64_t nominalJump, double delay);
// Apply a master channel's already-computed splice decision verbatim (no search) —
// the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_.
void applySplice(const SpliceEvent& ev);
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked);
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
std::int64_t ringLen_ = 0; // ring length (2 * window_): splice + search headroom
std::int64_t writePos_ = 0; // integer write head into the ring (source rate)
double posA_ = 0.0; // active read tap (advances at the shift ratio)
double posB_ = 0.0; // outgoing tap during a splice crossfade
bool fading_ = false; // a splice crossfade is in flight
std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeLen_)
std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length (window_/4)
std::int64_t fadeLen_ = 0; // LIVE crossfade length for the in-flight splice —
// ratio-scaled at splice time so an up-shift's outgoing
// tap can never drain into the writer mid-fade
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind
// the writer BY CONSTRUCTION at an up-splice)
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
std::int64_t filled_ = 0; // frames of VALID history behind the writer (prime count
// + frames streamed, capped at ringLen_). splice() clamps
// its up-jump to this so no splice lands in unwritten
// silence — the GA2 onset-gap fix.
double ratio_ = 1.0; // current shift ratio (>0)
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01):
// cleared at the top of every frame, set on a splice
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
// recycles the ring's frozen real tail, splices still
// aligned. With the writer parked, a tap drains toward it
// at ratio_ (not ratio_-1) per frame — splice() scales the
// live fade by that rate.
};
} // namespace reasampler::instrument::engine
+994
View File
@@ -0,0 +1,994 @@
// sampler_core — pure sampler engine implementation. See sampler_core.h for the
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.
#include "core/instrument/engine/sampler_core.h"
#include <cmath>
namespace reasampler {
// ---------------------------------------------------------------------------
// pitchRatio
// ---------------------------------------------------------------------------
double pitchRatio(int note, int rootNote) {
// Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0.
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
}
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
// Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields
// (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument
// to std::pow is bit-identical to pitchRatio(note, rootNote) — the 100% default is byte-for-
// byte unchanged from the pre-S-VIEW-6 engine. keyTrack == 0.0 -> offset 0 -> ratio 1.0 on
// every key (no tracking); keyTrack == 2.0 -> doubled offset. Root note stays unity always.
const double semis = static_cast<double>(note - rootNote) * keyTrack;
return std::pow(2.0, semis / 12.0);
}
// ---------------------------------------------------------------------------
// Keymap
// ---------------------------------------------------------------------------
ZoneResolution Keymap::resolve(int note, int velocity) const {
(void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1.
for (std::size_t i = 0; i < zones.size(); ++i) {
const KeyZone& z = zones[i];
if (note >= z.lowNote && note <= z.highNote) {
return ZoneResolution{true, i};
}
}
return ZoneResolution{false, 0};
}
Keymap Keymap::singleSampleChromatic(SampleData sample) {
const int root = sample.rootNote;
Keymap km;
km.samples.push_back(std::move(sample));
KeyZone zone;
zone.lowNote = 0;
zone.highNote = 127;
zone.rootNote = root;
zone.sampleIndex = 0;
km.zones.push_back(zone);
return km;
}
// ---------------------------------------------------------------------------
// AdsrEnvelope
// ---------------------------------------------------------------------------
void AdsrEnvelope::noteOn() {
stage_ = Stage::Attack;
level_ = 0.0;
framesInStage_ = 0;
}
void AdsrEnvelope::noteOff() {
if (stage_ == Stage::Idle || stage_ == Stage::Finished ||
stage_ == Stage::Release) {
return; // already released / not sounding.
}
// Release from the CURRENT level — release-before-sustain releases from the
// partial attack/decay level, not from sustainLevel.
releaseFrom_ = level_;
stage_ = Stage::Release;
framesInStage_ = 0;
}
double AdsrEnvelope::tick() {
switch (stage_) {
case Stage::Idle:
case Stage::Finished:
level_ = 0.0;
return 0.0;
case Stage::Attack: {
if (params_.attackFrames <= 0) {
level_ = 1.0;
} else {
level_ = static_cast<double>(framesInStage_) /
static_cast<double>(params_.attackFrames);
if (level_ > 1.0) level_ = 1.0;
}
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.attackFrames) {
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
stage_ = Stage::Hold;
framesInStage_ = 0;
level_ = 1.0;
}
return out;
}
case Stage::Hold: {
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
if (params_.holdFrames <= 0) {
stage_ = Stage::Decay;
framesInStage_ = 0;
// Fall through to Decay this frame so no extra unity sample is emitted for a
// zero-length hold (preserving the exact pre-S15 sample-for-sample shape).
level_ = 1.0;
// Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion).
return tick();
}
level_ = 1.0;
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.holdFrames) {
stage_ = Stage::Decay;
framesInStage_ = 0;
level_ = 1.0;
}
return out;
}
case Stage::Decay: {
if (params_.decayFrames <= 0) {
level_ = params_.sustainLevel;
} else {
const double t = static_cast<double>(framesInStage_) /
static_cast<double>(params_.decayFrames);
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
}
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.decayFrames) {
stage_ = Stage::Sustain;
framesInStage_ = 0;
level_ = params_.sustainLevel;
}
return out;
}
case Stage::Sustain:
level_ = params_.sustainLevel;
return level_;
case Stage::Release: {
if (params_.releaseFrames <= 0) {
level_ = 0.0;
stage_ = Stage::Finished;
return 0.0;
}
const double t = static_cast<double>(framesInStage_) /
static_cast<double>(params_.releaseFrames);
level_ = releaseFrom_ * (1.0 - t);
if (level_ < 0.0) level_ = 0.0;
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.releaseFrames) {
stage_ = Stage::Finished;
level_ = 0.0;
}
return out;
}
}
return 0.0; // unreachable; silences a warning.
}
// ---------------------------------------------------------------------------
// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function.
// ---------------------------------------------------------------------------
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve) {
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
curve_ = curve;
finished_ = (playLength_ <= 0);
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A
// negative fade is treated as 0. When both fades together exceed the play length, shrink
// the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp),
// then the fade-in — never letting either go negative or the sum exceed the span.
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
if (fi > playLength_) fi = playLength_;
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
fadeIn_ = fi;
fadeOut_ = fo;
}
double TriggerEnvelope::amplitudeAt(double sourceOffset) {
if (finished_ || sourceOffset < 0.0 ||
sourceOffset >= static_cast<double>(playLength_)) {
// At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
return 0.0;
}
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_).
// Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length).
// The offset is fractional (the read head is fractional under repitch), so the ramps are
// smooth rather than stepped.
double amp = 1.0;
const double foStart = static_cast<double>(playLength_ - fadeOut_);
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
amp = (curve_ == FadeCurve::EqualPower)
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power
: phase;
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_); // 0..1
amp = (curve_ == FadeCurve::EqualPower)
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power
: (1.0 - phase);
}
return amp;
}
// ---------------------------------------------------------------------------
// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled.
// ---------------------------------------------------------------------------
double PitchEnvelope::tick() {
if (!params_.enabled) return 0.0;
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
const double peak = params_.peakSemitones;
double offset;
if (pos_ < a) {
// Attack: 0 -> peak over attackFrames (rise into the peak).
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
} else if (pos_ < a + d) {
// Decay: peak -> 0 over decayFrames (settle to base pitch).
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
offset = peak * (1.0 - t);
} else {
offset = 0.0; // past attack+decay: at base pitch forever.
}
++pos_;
return offset;
}
// ---------------------------------------------------------------------------
// Voice
// ---------------------------------------------------------------------------
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The
// prime scratch (one window, reused per channel) is sized here for the same reason: start()
// assembles the first window of the upcoming source stream into it with zero allocation.
shiftL_.configure(windowFrames);
shiftR_.configure(windowFrames);
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
}
bool Voice::sustainLoopUsable() const {
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
const SampleLoop& loop = sample_->loop;
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
}
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack, const VelocityCurve& velocityCurve,
bool declickTakeover) {
// Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT
// REFERENCE — the last rendered output — and mark the compensation PENDING iff this
// start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself
// is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between
// this reference and the new voice's raw output that frame (seedDeclick), so the
// boundary frame reproduces the old level EXACTLY — whatever the new envelope does
// (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever
// value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by
// (1 newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got
// ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case
// on Trigger / zero-attack zones.] A fresh start (idle voice) clears the declick state —
// no phantom ramp. lastOut{L,R}_ are deliberately NOT zeroed here: a SECOND same-block
// takeover (two steals of this voice with no frame rendered between) must record the
// same pre-cut reference, not a phantom 0. The next rendered frame overwrites lastOut.
if (declickTakeover && active_) {
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
declickPending_ = true;
} else {
declickPending_ = false;
}
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
// includes the running declick's contribution via lastOut (it tracks post-declick output).
declickActive_ = false;
declickWeight_ = 0.0;
active_ = true;
releasing_ = false;
amplitudeDone_ = false;
note_ = note;
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The
// clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear
// velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity.
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
sample_ = &sample;
const ZonePlayParams& p = sample.play;
playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine;
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start);
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
// --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
//
// All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
// the live sample rate.
//
// Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
// (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
// every DAW rate — now trivially true, since the times are wall-clock seconds. ---
if (playMode_ == PlayMode::Gate) {
env_.configure(p.adsr);
env_.noteOn();
playEnd_ = 0; // unused in Gate
} else {
// Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)).
double frac = p.trigger.lengthFraction;
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
if (frac > 1.0) frac = 1.0;
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
std::int64_t playLen = static_cast<std::int64_t>(
static_cast<double>(span) * frac + 0.5); // round
if (playLen < 0) playLen = 0;
if (playLen > span) playLen = span;
playEnd_ = start + playLen;
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
kDefaultFadeCurve);
}
// --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn();
// --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters
// with the first window of the ACTUAL upcoming source stream (loop-unrolled under the
// sustain-loop wrap rule, silence past the sample end — that silence IS the true
// stream there). The tap parks on source frame `start`, so the voice speaks on output
// frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window
// of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed
// ring made every early splice jump into zeros). The rings and the prime scratch were
// allocated off-thread by presizePreserveShifters (the engine calls it at
// construction); this path is a bounded copy — NO allocation here. Varispeed voices
// never touch the shifters (advanceFrame checks configured()), so a Varispeed
// instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
const std::int64_t w = shiftL_.window();
const bool loopWrap = sustainLoopUsable();
const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
// Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at
// feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and
// freezes the writer there (GA3) — but the prime used to pull a FULL window bounded
// only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an
// up-shifted tap could play it, transposed, before the voice freed), and a
// shorter-than-window sample got zero padding declared as valid history (splices
// landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window
// material). So bound the prime by the same playable span and, when that span is
// shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3
// machinery then recycles the real short tail, its designed behavior. The sustain-
// loop path is unbounded by construction (the wrap keeps q inside the loop forever).
const std::int64_t primeBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
const std::int64_t primeCount =
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
// Both channels walk identical SOURCE positions (the walk depends only on loop geometry,
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
std::int64_t p = start;
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
std::int64_t q = start;
for (std::int64_t i = 0; i < primeCount; ++i) {
if (loopWrap) {
while (q >= loop.end) q -= loopLen;
}
// q < frameCount holds by construction on the non-loop path (primeCount is
// bounded); the guard stays as a belt for the loop-wrap walk.
primeBuf_[static_cast<std::size_t>(i)] =
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
++q;
}
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
if (ch == 0) p = q; // capture the end position once from channel 0's walk
}
// Per-frame feed continues at `p` (== the feed bound when the prime exhausted the
// playable span — advanceFrame's own exhaustion test then holds from frame 0).
feedPos_ = p;
if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is ALREADY exhausted at prime time.
shiftL_.freezeTail();
if (stereoSample) shiftR_.freezeTail();
}
}
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
}
void Voice::retune(int note, int rootNote, double keyTrack) {
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a legato
// phrase is one gesture, one strike (classic mono-synth behavior).
if (!active_) return;
note_ = note;
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
}
void Voice::release() {
if (!active_) return;
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
if (playMode_ == PlayMode::Trigger) return;
releasing_ = true;
env_.noteOff();
}
void Voice::hardStop() {
// CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots
// that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation.
active_ = false;
}
double Voice::tickAmplitude() {
double amp;
if (playMode_ == PlayMode::Gate) {
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
} else {
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades
// land on the same source frames under either engine's read rate. The voice ALSO frees on
// readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders.
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true;
}
return amp;
}
void Voice::seedDeclick(double newOutL, double newOutR) {
// First frame after a takeover restart: ARM the bounded blend. The weight starts at 1.0
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
// the new envelope's first value. Each subsequent frame adds `w*(ref outCurrent)` then
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
// is impossible even if outCurrent rises while the weight is still significant.
// [Rev 1 stored the frozen difference (ref x₀); if outₙ rose while that residue was
// still large the sum could exceed full scale. The ±2.0 clamp there was the only guard
// and it silently broke the boundary identity when |x₀| > 1. The bounded blend removes
// both the overshoot hole and the need for a clamp on the stored value.]
// newOutL/R are used only to decide whether an active ramp exists (the seed is purely
// the weight 1.0; ref was clamped to ±1 at start()). The ±2 clamp on the difference is
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
declickPending_ = false;
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state)
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here.
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
}
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
// per frame and scale all channels equally (a voice is one envelope). The head advances by
// exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
if (!active_ || sample_ == nullptr) {
if (stereo) outR = 0.0f;
return 0.0f;
}
const std::vector<AudioSample>& pcm = sample_->frames;
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
// Read the second channel only for a genuinely stereo sample; a mono sample plays
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
const bool haveR = stereo && sample_->channelCount() == 2;
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
// Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
const SampleLoop& loop = sample_->loop;
const bool loopUsable = sustainLoopUsable();
if (loopUsable) {
const double loopLen = static_cast<double>(loop.end - loop.start);
while (readPos_ >= static_cast<double>(loop.end)) {
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
}
}
// TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
// trigger envelope also finishes at the same frame count; either latches the voice idle.
const bool triggerRanOff =
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
// Ran off the sample end with no usable loop -> voice is done. Peer path of the
// epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting —
// dropping it would re-introduce a step on exactly the path the ramp exists for (a
// restart whose new play span ends within the ~4 ms ramp). The voice stays active only
// until the ramp floors; with no declick (the common case, and the entire opt-out
// baseline) this is byte-identical to the plain idle-out.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
if (declickActive_) {
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref 0) == w*ref.
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
const double l = declickWeight_ * declickRefL_;
const double r = declickWeight_ * declickRefR_; // same weight for both channels
declickWeight_ *= kDeclickDecay;
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
declickActive_ = false;
active_ = false;
}
lastOutL_ = l;
lastOutR_ = stereo ? r : l;
if (stereo) outR = static_cast<AudioSample>(r);
return static_cast<AudioSample>(l);
}
active_ = false;
if (stereo) outR = 0.0f;
return 0.0f;
}
// Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
const double amp = tickAmplitude();
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0)
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read
// (no per-frame transcendental), byte-identical to pre-S16.
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// PRESERVE: feed the shifters the SOURCE stream at unity rate (duration held) and
// TRANSPOSE the output by 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to
// the shift amount, not the read rate — pitch bends, duration unchanged (S16
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so
// there is nothing to interpolate). Past the last real frame the shifter's writer is
// FROZEN (GA3 wind-down below) — it recycles the real tail it already holds.
if (loopUsable) {
const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
}
// GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one
// window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the
// user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_
// reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here,
// a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic
// troughs at the splice cadence, growing toward the note end as the plateau displaced
// real ring history). Instead FREEZE the shifter's writer: no padding ever enters the
// ring, and the splice machinery keeps recycling the frozen all-real tail, every jump
// still waveform-aligned — a continuous tone through the final window and the release,
// bounded by the voice's own end (readPos_ >= frameCount / playEnd_ frees it). The
// sustain-loop path never gets here: the wrap above keeps feedPos_ < loop.end forever.
const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
const bool exhausted = feedPos_ >= feedBound;
if (exhausted) shiftL_.freezeTail(); // idempotent; input below is ignored while frozen
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
outL = shiftedL * gain;
if (stereo) {
if (haveR && shiftR_.configured()) {
// Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel
// 0's splice decisions via processLinked — one correlation search, one lag, one
// splice schedule for both channels (standard stereo SOLA). An independent
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
// every splice: stereo image wander at the splice cadence + mono-sum combing.
// Each shifter is still processed EXACTLY ONCE per output frame (never twice —
// that would advance its heads twice and corrupt the state). Gated on haveR so
// a MONO sample never touches shiftR_ — start() only primes it for genuinely
// stereo samples, and a stale un-primed ring must not leak a previous note.
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
gain;
} else {
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
// value from the mono feed; mirror it to R. Do NOT call shiftL_.process again
// this frame.
outRlocal = shiftedL * gain;
}
}
++feedPos_;
// Preserve advances the read head at the SOURCE rate (duration preserved).
ratio_ = 1.0;
} else {
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
// envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
// envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
//
// Linear interpolation between the two bracketing SOURCE frames at the read head. For
// the loop case, the second point wraps to loopStart so the seam is continuous.
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (loopUsable && i1 >= loop.end) {
i1 = loop.start; // seamless wrap for the interpolation partner.
}
const bool i0ok = (i0 >= 0 && i0 < frameCount);
const bool i1ok = (i1 >= 0 && i1 < frameCount);
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
outL = srcL * gain;
if (stereo) {
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
outRlocal = srcR * gain;
}
ratio_ = baseRatio_ * envFactor;
}
// Takeover declick (Phase S GA fix, rev 2, bounded-blend revision): on the FIRST frame
// after a takeover/steal restart, seed the blend weight at 1.0 so this frame's output is
// outₙ*(1w) + ref*w = out*(11) + ref*1 = ref (exact boundary identity).
// Each subsequent frame the blend add is `w*(ref outCurrent)` and then w decays by
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
// [Rev 1 added the frozen difference (ref x₀) ungated; if outₙ rose while the residue
// was still large the sum could exceed ±1 by up to ~+3.8 dB on an extreme retrig.]
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
if (declickActive_) {
const double addL = declickWeight_ * (declickRefL_ - outL);
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
outL += addL;
if (stereo) outRlocal += addR;
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
declickActive_ = false;
}
}
if (stereo) outR = static_cast<AudioSample>(outRlocal);
// Track the value this voice actually contributed THIS frame (post-gain, incl. any running
// declick) — a future takeover restart seeds its declick from exactly this. In a mono
// render the R track mirrors L (dual-mono semantics, matching the stereo mirror of a mono
// sample), so a later stereo takeover still has a sane R seed.
lastOutL_ = outL;
lastOutR_ = stereo ? outRlocal : outL;
readPos_ += ratio_;
// A finished amplitude envelope frees the voice — unless a takeover declick still rings:
// the envelope contributes 0 from here on, so the remaining frames are the bare ramp
// fading out (bounded: the ramp floors within ~4 ms). Baseline (no declick) unchanged.
if (amplitudeDone_ && !declickActive_) {
active_ = false;
}
return static_cast<AudioSample>(outL);
}
AudioSample Voice::renderFrame() {
AudioSample discard = 0.0f;
return advanceFrame(/*stereo=*/false, discard);
}
void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
r = 0.0f;
l = advanceFrame(/*stereo=*/true, r);
}
// ---------------------------------------------------------------------------
// VoiceEngine
// ---------------------------------------------------------------------------
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap,
std::int64_t preserveWindowFrames,
VoiceMode voiceMode, MonoTrigger monoTrigger,
bool takeoverDeclick)
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
: voices_(voiceMode == VoiceMode::Mono ? 1
: (maxVoices == 0 ? 1 : maxVoices)),
keymap_(keymap),
preserveVoiceCap_(preserveVoiceCap),
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
takeoverDeclick_(takeoverDeclick) {
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
// allocation point for the shifter rings across the engine's lifetime.
// MONO: voices_.size() == 1, so the loop below sizes exactly one voice regardless of
// maxVoices — the Poly path sizes the whole pool as before.
if (preserveWindowFrames > 1) {
for (std::size_t i = 0; i < voices_.size(); ++i) {
voices_[i].presizePreserveShifters(preserveWindowFrames);
}
}
}
std::size_t VoiceEngine::activePreserveVoices() const {
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
// that have finished their note but are still ringing out a declick tail. A ramp-only
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
// be dropped (kNoVoice return at :797-800) during the narrow ~4 ms window the ramp lives.
std::size_t n = 0;
for (const Voice& v : voices_) {
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
}
return n;
}
std::size_t VoiceEngine::allocateVoice() {
// 1. A free (idle) voice, lowest index for determinism.
for (std::size_t i = 0; i < voices_.size(); ++i) {
if (!voices_[i].active()) return i;
}
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
// else the oldest voice overall. "Oldest" = smallest startOrder.
std::size_t bestReleasing = kNoVoice;
std::uint64_t bestReleasingOrder = 0;
std::size_t bestOverall = kNoVoice;
std::uint64_t bestOverallOrder = 0;
for (std::size_t i = 0; i < voices_.size(); ++i) {
const std::uint64_t order = voices_[i].startOrder();
if (voices_[i].releasing()) {
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
bestReleasing = i;
bestReleasingOrder = order;
}
}
if (bestOverall == kNoVoice || order < bestOverallOrder) {
bestOverall = i;
bestOverallOrder = order;
}
}
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
}
void VoiceEngine::removeHeld(int note) {
for (std::size_t i = 0; i < heldCount_; ++i) {
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
// Shift the notes above it down one slot (press order preserved).
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
--heldCount_;
return;
}
}
}
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
// held note and corrupt the stack. Mirrored in monoNoteOff.
if (note < 0 || note > 127) return kNoVoice;
const ZoneResolution res = keymap_.resolve(note, velocity);
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
const KeyZone& zone = keymap_.zones[res.zoneIndex];
if (zone.sampleIndex >= keymap_.samples.size()) return kNoVoice;
const SampleData& sample = keymap_.samples[zone.sampleIndex];
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
// byte for storage only; the voice start below receives the caller's value untouched.
removeHeld(note);
if (heldCount_ < heldStack_.size()) {
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
static_cast<std::uint8_t>(vclamped)};
}
Voice& v = voices_[0];
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
// means another note was already physically held — the exact "takeover within a phrase"
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones:
// Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot
// still ringing after the last key-up was silently RETUNED in place instead of
// re-attacked. NOTE: a one-held-note same-note re-press (heldCount_ becomes 1 after the
// removeHeld/re-push above — so heldCount_ < 2) re-attacks rather than retuning, which is
// the correct fresh-phrase behavior for that edge case.) Same-sample requirement unchanged.
//
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
// ramp rather than restarting the new note, producing a silent note on the common
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
// the new note-on restarts the voice normally (monoNoteOn falls through to start() below).
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
v.playingSample() == &sample) {
v.retune(note, zone.rootNote, zone.keyTrack);
return 0;
}
// RETRIGGER takeover / first note of a phrase / cross-sample legato: (re)start the voice.
// The declick opt-in rides every mono restart: start() self-gates it on the voice being
// ACTIVE, so a first-note fresh start never ramps — only a hard cut of a sounding tone.
v.start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
/*declickTakeover=*/takeoverDeclick_);
v.setStartOrder(nextStartOrder_++);
return 0;
}
void VoiceEngine::monoNoteOff(int note) {
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
if (note < 0 || note > 127) return;
removeHeld(note);
Voice& v = voices_[0];
// Releasing a note that is not the sounding one (a lower held note or an already-released
// note) changes nothing audible.
if (!v.active() || v.releasing() || v.note() != note) return;
if (heldCount_ == 0) {
v.release(); // last finger up: gate off (Trigger zones ignore this and play through).
return;
}
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
const HeldNote fb = heldStack_[heldCount_ - 1];
const ZoneResolution res = keymap_.resolve(fb.note, fb.velocity);
if (!res.matched || keymap_.zones[res.zoneIndex].sampleIndex >= keymap_.samples.size()) {
v.release(); // defensive: only resolving notes are pushed, so this shouldn't happen.
return;
}
const KeyZone& zone = keymap_.zones[res.zoneIndex];
const SampleData& sample = keymap_.samples[zone.sampleIndex];
if (monoTrigger_ == MonoTrigger::Legato && v.playingSample() == &sample) {
v.retune(fb.note, zone.rootNote, zone.keyTrack); // glide back, no re-attack
return;
}
// Retrigger (or cross-sample) fallback: re-strike the fallen-back-to note at its own
// original velocity. Peer restart site of monoNoteOn's takeover — same declick opt-in
// (the fallback also hard-cuts the sounding tone).
v.start(fb.note, fb.velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
/*declickTakeover=*/takeoverDeclick_);
v.setStartOrder(nextStartOrder_++);
}
std::size_t VoiceEngine::noteOn(int note, int velocity) {
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
const ZoneResolution res = keymap_.resolve(note, velocity);
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
const KeyZone& zone = keymap_.zones[res.zoneIndex];
if (zone.sampleIndex >= keymap_.samples.size()) {
return kNoVoice; // zone points at a missing sample — refuse rather than UB.
}
const SampleData& sample = keymap_.samples[zone.sampleIndex];
// S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
// rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
// Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
// NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
activePreserveVoices() >= preserveVoiceCap_) {
return kNoVoice;
}
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
// The takeover declick rides the STEAL restart too (GA fix): start() self-gates on the
// voice being active, so a free-voice start never ramps — only an at-cap steal, which is
// the same hard cut of a sounding tone as the mono retrig takeover.
const std::size_t v = allocateVoice();
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
/*declickTakeover=*/takeoverDeclick_);
voices_[v].setStartOrder(nextStartOrder_++);
return v;
}
void VoiceEngine::noteOff(int note) {
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
// so a re-triggered note releases its newest instance first and older tails ring.
std::size_t target = kNoVoice;
std::uint64_t bestOrder = 0;
for (std::size_t i = 0; i < voices_.size(); ++i) {
if (voices_[i].active() && !voices_[i].releasing() &&
voices_[i].note() == note) {
const std::uint64_t order = voices_[i].startOrder();
if (target == kNoVoice || order > bestOrder) {
target = i;
bestOrder = order;
}
}
}
if (target != kNoVoice) voices_[target].release();
}
void VoiceEngine::allNotesOff() {
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
// restarts and sustains forever with no key held), then gate off every active voice.
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
heldCount_ = 0;
for (Voice& v : voices_) {
if (v.active()) v.release();
}
}
void VoiceEngine::allSoundsOff() {
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
// bounded by the pool size.
heldCount_ = 0;
for (Voice& v : voices_) {
v.hardStop();
}
}
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
// The VST3 process callback hands us the host's output channel buffer here, so the
// audio thread never touches the heap (S4 real-time discipline).
if (out == nullptr || frameCount == 0) return;
for (Voice& voice : voices_) {
if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) {
if (!voice.active()) break;
out[f] += voice.renderFrame();
}
}
}
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
if (left == nullptr || right == nullptr || frameCount == 0) return;
for (Voice& voice : voices_) {
if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) {
if (!voice.active()) break;
AudioSample l = 0.0f, r = 0.0f;
voice.renderFrameStereo(l, r);
left[f] += l;
right[f] += r;
}
}
}
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
// Off-thread / test path: grow the buffer (this allocates — never call under
// process), zero-fill the appended span, then delegate to the RT mix loop so both
// overloads share exactly one summation path.
const std::size_t base = out.size();
out.resize(base + frameCount, 0.0f);
render(out.data() + base, frameCount);
}
std::size_t VoiceEngine::activeVoiceCount() const {
std::size_t n = 0;
for (const Voice& v : voices_) {
if (v.active()) ++n;
}
return n;
}
} // namespace reasampler
+770
View File
@@ -0,0 +1,770 @@
#pragma once
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice
// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap
// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note
// with loop-point-aware sustain.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL,
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell
// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from
// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced
// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i).
//
// It shares the `AudioSample` float alias from peaks — the one house precedent for a
// pure module leaning on peaks for the audio-domain type (wav_trim does the same). The
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
// core does no file I/O — it is handed decoded sample frames and produces audio frames.
#include <array>
#include <cstddef>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start)
namespace reasampler {
// Q-W1 interim: the engine deps live in their sub-namespace homes now; sampler_core
// re-namespaces in its own split wave (Q-W2v).
using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders
// per-channel. A PERFORMANCE choice the instrument owns (component state), never written
// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain
// value so the shell (bus negotiation, state) and the engine share one spelling; the core
// itself never branches on it — the mode only picks which render overload the shell drives.
enum class ChannelMode { Mono, Stereo };
// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's
// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE
// priority over a held-note stack (classic mono synth: a new note takes the voice over; the
// release of the top note falls back to the most-recent still-held note). A PERFORMANCE
// choice the instrument owns (component state), never a bank fact. Default Poly preserves
// current behavior.
enum class VoiceMode { Poly, Mono };
// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable).
// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps
// the envelope running when a note is taken over while another is held — pitch moves without
// a re-attack (and the fallback on top-note release glides back the same way). Legato applies
// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts
// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample
// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger.
enum class MonoTrigger { Retrigger, Legato };
// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count.
// One spelling shared by the engine, the component-state (de)serializer, and the editor's
// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool.
inline constexpr int kMinVoiceCount = 1;
inline constexpr int kMaxVoiceCount = 32;
inline constexpr int kDefaultVoiceCount = 16;
// ---------------------------------------------------------------------------
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
// with the rest of the engine machinery; only the value structs need to precede SampleData.
// ---------------------------------------------------------------------------
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
struct AdsrParams {
std::int64_t attackFrames = 0;
std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
std::int64_t decayFrames = 0;
double sustainLevel = 1.0; // 0..1
std::int64_t releaseFrames = 0;
};
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
enum class PlayMode { Gate, Trigger };
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
struct TriggerParams {
double lengthFraction = 1.0; // (0,1] of the post-start span to play
std::int64_t fadeInFrames = 0; // 0->1 ramp at the head
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd
};
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
// so a third curve can join without a signature change.
enum class FadeCurve { EqualPower, Linear };
// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
enum class PitchEngine { Varispeed, Preserve };
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
// engine" holds for the core's own regression tests (an octave up still halves duration in the
// bare engine); the Preserve product default is layered on above at (de)serialization.
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first
// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix).
// One knob, resolved at voice allocation.
inline constexpr double kPreserveWindowMs = 50.0;
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
struct PitchEnvParams {
bool enabled = false;
std::int64_t attackFrames = 0;
std::int64_t decayFrames = 0;
double peakSemitones = 0.0; // signed depth at the peak
};
// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
struct ZonePlayParams {
PlayMode playMode = PlayMode::Gate;
AdsrParams adsr; // Gate: the AHDSR envelope
TriggerParams trigger; // Trigger: %-length + fades
PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
};
// ---------------------------------------------------------------------------
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
// govern playback. The shell decodes the on-disk WAV and fills this; the core
// never touches a file.
// ---------------------------------------------------------------------------
// A loop over [start, end) frames, half-open. A zero-length loop (start == end)
// is the "no sustain loop" marker — a held note past the sample end goes silent
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
struct SampleLoop {
bool hasLoop = false;
std::int64_t start = 0; // first looped frame (inclusive)
std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end
};
// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample).
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical
// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was
// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio.
struct SampleData {
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
int sampleRate = 0; // frames per second (for reference; ratio is
// note-relative, so rate cancels for repitch).
// 0 is explicitly invalid — every consumer must
// receive a real rate before use.
int rootNote = 60; // MIDI note recorded at (plays at unity here)
SampleLoop loop; // sustain loop, if any
// Initial read position (frame offset) a voice starts playback at — frame 0 by
// default, so an unset start point is exactly the pre-S11 behavior. S11 makes this
// an instrument-side per-zone override (the "start point" marker); S15 builds on it
// (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
std::int64_t startFrame = 0;
// S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
// envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
// Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
ZonePlayParams play;
// 2 iff a matching-length second channel exists; else 1. A framesR of a different
// length than frames is treated as absent (mono) — a malformed pair never half-plays.
int channelCount() const {
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
}
};
// ---------------------------------------------------------------------------
// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves
// to at most one zone; a zone names which SampleData to play and the root note to
// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the
// sample's own root. Tier-1: several zones, each a key range with its own root.
//
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built:
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and
// resolve() gains the velocity dimension it already receives but currently ignores
// for selection. The (note, velocity) signature and the "resolve to a zone, then a
// sample within it" shape are already in place — Tier 2 fills in the second step
// without changing callers or the voice engine. See the report note.
// ---------------------------------------------------------------------------
// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with
// the root note to repitch from (defaults to the sample's own root, overridable in
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention).
struct KeyZone {
int lowNote = 0;
int highNote = 127;
int rootNote = 60; // repitch reference for this zone
// S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is
// standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror
// of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in
// Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at
// unity, a deliberate behavior change from the pre-r10 linear map.
VelocityCurve velocityCurve = VelocityCurve::flat();
std::size_t sampleIndex = 0; // index into Keymap::samples
};
// Result of resolving a (note, velocity). `matched == false` means the note falls in
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0.
struct ZoneResolution {
bool matched = false;
std::size_t zoneIndex = 0; // valid only when matched
};
// The keymap: the decoded samples plus the zones that map keys onto them. Owns
// resolution. Pure: no host types. Zones are tested first-match in order, so an
// earlier zone wins an overlap (deterministic, documented).
struct Keymap {
std::vector<SampleData> samples;
std::vector<KeyZone> zones;
// Resolves (note, velocity) to a zone. First zone (in order) whose [low,high]
// contains `note` wins. velocity is accepted now (Tier-2 seam) but does not
// affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
// the note.
ZoneResolution resolve(int note, int velocity) const;
// Convenience: build the Tier-0 degenerate keymap — one sample mapped
// chromatically across the whole keyboard from its own root note.
static Keymap singleSampleChromatic(SampleData sample);
};
// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`:
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0,
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
double pitchRatio(int note, int rootNote);
// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The
// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how
// far playback pitch tracks the keyboard around the root:
// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) —
// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call).
// keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes).
// keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch.
// At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity.
// Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via
// the voice's baseRatio_.
double keyTrackedRatio(int note, int rootNote, double keyTrack);
// ---------------------------------------------------------------------------
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
//
// Segment math (all linear ramps):
// Attack: 0 -> 1 over attackFrames
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
// Decay: 1 -> sustainLevel over decayFrames
// Sustain: hold sustainLevel until noteOff
// Release: currentLevel -> 0 over releaseFrames
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
// ---------------------------------------------------------------------------
class AdsrEnvelope {
public:
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
void configure(const AdsrParams& params) { params_ = params; }
// Gate on: (re)start from Attack.
void noteOn();
// Gate off: enter Release from the current level.
void noteOff();
// Advances one frame and returns the amplitude for THIS frame (before advancing).
// Once Release completes the envelope latches Finished and returns 0.0 forever
// (until the next noteOn). A single, monotonic per-frame step — the caller pulls
// one value per output frame.
double tick();
Stage stage() const { return stage_; }
bool finished() const { return stage_ == Stage::Finished; }
double level() const { return level_; }
private:
AdsrParams params_;
Stage stage_ = Stage::Idle;
double level_ = 0.0;
std::int64_t framesInStage_ = 0;
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
};
// ---------------------------------------------------------------------------
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
// FadeCurve value structs are defined above with the other per-zone params.
// ---------------------------------------------------------------------------
// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
// output and source frames coincide, but under Varispeed a transposed voice consumes source
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
class TriggerEnvelope {
public:
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
// (fadeOut anchored to the end). A zero/negative play length finishes immediately.
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
// the offset (no internal advance) so it composes with either pitch engine's read rate.
double amplitudeAt(double sourceOffset);
bool finished() const { return finished_; }
private:
std::int64_t playLength_ = 0;
std::int64_t fadeIn_ = 0;
std::int64_t fadeOut_ = 0;
FadeCurve curve_ = kDefaultFadeCurve;
bool finished_ = false;
};
// ---------------------------------------------------------------------------
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
// ---------------------------------------------------------------------------
// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
class PitchEnvelope {
public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
void noteOn() { pos_ = 0; }
// Advance one frame, return this frame's pitch offset in semitones.
double tick();
private:
PitchEnvParams params_;
std::int64_t pos_ = 0;
};
// Takeover declick (Phase S GA fix, rev 2 — audible click when a sounding voice is
// restarted). A takeover restart HARD-CUTS the sounding tone: the read head and envelope
// restart in one frame, a step discontinuity that clicks. This is the same physics on EVERY
// restart-of-a-sounding-voice path — the MONO Retrigger takeover/fallback, the mono
// cross-sample legato restart, and the POLY at-cap voice steal (the editor's preview is a
// plain engine noteOn since the PreviewCard retirement, so a preview re-fire at cap is just
// an at-cap steal). When the caller opts in (start()'s declickTakeover; the engine passes
// it on all of those restart paths when constructed with takeoverDeclick),
// the restart smooths the ACTUAL output discontinuity: start() records the last rendered
// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a
// compensation equal to (reference that frame's raw new output). The compensation is
// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary
// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first
// value (Gate attack, zero attack, or Trigger's no-fade-in instant-unity onset) and
// whatever value the new sample starts on — and the residue fades in ~2-4 ms to the -80 dB
// floor across 44.1-96 kHz (a per-FRAME DSP micro-ramp, not a stored wall-clock quantity).
// [Rev 1 decayed the OLD output gated by (1 newAmp): any restart whose new amplitude was
// instantly ~1 — a Trigger zone with no fade-in, a zero-attack Gate — got ZERO compensation
// and kept the full click. The difference seed has no such hole and needs no gate: when old
// and new levels already match, the seed is ~0 and nothing is added, so the +6 dB sum the
// gate defended against is structurally impossible.] OFF by default so the bare core stays
// byte-identical to the pre-fix engine (the regression baseline); the processor shell opts
// in for the engine, mirroring the kDefaultPitchEngine layering.
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
// ---------------------------------------------------------------------------
// A single voice: one active note playing one repitched, enveloped sample. Reads
// the sample by fractional frame position with linear interpolation, advancing by
// the pitch ratio; loops the sustain region for held notes past the loop end.
// ---------------------------------------------------------------------------
class Voice {
public:
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
// The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) —
// start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio
// thread inside process(). The warm silence pass settles the OLA taps before the first
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
// is default (Gate + Varispeed + no pitch env).
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio;
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
// `declickTakeover` (Phase S GA fix): when TRUE and this voice is currently ACTIVE (a
// takeover/steal restart, not a fresh start), smooth the restart's output discontinuity —
// the pre-cut output is recorded here and the difference-seeded compensation is armed on
// the first frame rendered after the restart (see the takeover-declick block above
// kDeclickDecay). A fresh start never declicks.
void start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack = 1.0,
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
bool declickTakeover = false);
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack.
// Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate,
// Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees
// the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample
// takeover must restart the voice instead (see MonoTrigger).
void retune(int note, int rootNote, double keyTrack = 1.0);
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
void release();
// HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless
// of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot
// instantly (which release() cannot do). RT-safe: no allocation, no lock.
void hardStop();
// True while this voice is producing (or about to produce) sound (including any
// declick ring-out tail past the note's playable span).
bool active() const { return active_; }
// True while this voice is sounding a PLAYABLE NOTE — active AND the amplitude
// envelope has not yet finished. A voice whose note has run to its end but is still
// ringing out a declick tail is active() but NOT soundingNote(). Use this to
// distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote)
// for the Preserve-cap count and the mono-Legato takeover predicate — both must ignore
// a ramp-only past-end voice or a new note-on can be dropped / silently muted.
bool soundingNote() const { return active_ && !amplitudeDone_; }
// The note this voice was started on (for note-off routing). Meaningless if idle.
int note() const { return note_; }
// Monotonic age counter — higher = started earlier relative to others. The voice
// engine uses this for its stealing policy (oldest first). Set by the engine.
std::uint64_t startOrder() const { return startOrder_; }
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
bool releasing() const { return releasing_; }
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
// meaningful while active(). NOTE: the FA1 unity-shift demotion to Varispeed is GONE —
// it was scoped to the retired PreviewCard, and since GA2 the primed shifter speaks on
// frame 0 at every ratio, so a Preserve voice keeps its shifter at every note (one code
// path, uniform onset across the keyboard).
PitchEngine pitchEngine() const { return pitchEngine_; }
// The SampleData this voice is playing (nullptr when never started). The engine's mono
// legato path compares it against the new note's resolved sample — a same-sample takeover
// retunes; a cross-sample one restarts. Identity only; callers never mutate through it.
const SampleData* playingSample() const { return sample_; }
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
// audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it
// once at construction so start() — which runs on the audio thread inside process() — never
// allocates: start() only prime()s the already-sized rings with the first window of source
// (a bounded copy). `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap
// no-op in the underlying vector.
void presizePreserveShifters(std::int64_t windowFrames);
// Renders one frame's contribution, advancing the read head and envelope by one
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the
// sample runs out with no loop. The value is already velocity- and
// envelope-scaled — the engine sums voices directly. This is the MONO path (channel
// 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
AudioSample renderFrame();
// STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances
// the read head + envelope by exactly one frame (the same single advance the mono path
// performs — the envelope ticks ONCE per frame, shared across both channels). For a mono
// sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered).
// Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions
// as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both.
void renderFrameStereo(AudioSample& l, AudioSample& r);
private:
// Shared read/advance for both render paths: computes the interpolated per-channel
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
// the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
AudioSample advanceFrame(bool stereo, AudioSample& outR);
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
double tickAmplitude();
// True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop
// inside the sample (S15 — Trigger one-shots never loop). The single source of truth for
// the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and
// the start()-time ring prime.
bool sustainLoopUsable() const;
bool active_ = false;
bool releasing_ = false;
int note_ = 0;
double velocityGain_ = 1.0;
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame)
double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr;
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
PlayMode playMode_ = PlayMode::Gate;
AdsrEnvelope env_;
TriggerEnvelope trigEnv_;
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
//
// GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the
// actual upcoming source (loop-unrolled, silence past the end) — output frame 0 is source
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_
// is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule.
// GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end;
// Trigger: playEnd_) the shifters' writers are FROZEN — no padding enters the rings and
// the splice machinery recycles the frozen real tail through the note end (see
// advanceFrame). primeBuf_ is the presized scratch the prime stream is assembled into
// (never touched outside start()).
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
PitchEnvelope pitchEnv_;
PitchShifter shiftL_;
PitchShifter shiftR_;
std::int64_t feedPos_ = 0;
std::vector<AudioSample> primeBuf_;
// Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the
// ACTUAL discontinuity — (pre-cut reference the new voice's raw output this frame) —
// applied ungated so the boundary frame reproduces the old level exactly. See the
// takeover-declick block above kDeclickDecay.
void seedDeclick(double newOutL, double newOutR);
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start()
// records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_;
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND:
// outₙ = outₙ*(1w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy)
// starts at 1.0 and decays by
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref outₙ)`, so the
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
// [Rev 1 stored the frozen difference (ref x₀); when outₙ rose while that residue
// was still large the sum could exceed full scale by up to ~+3.8 dB.]
// lastOut is NOT zeroed by start() — a second same-block takeover (no frame rendered
// between) must record the same pre-cut reference, not a phantom 0.
// The whole declick state is cleared on a fresh (non-takeover) start.
bool declickPending_ = false;
bool declickActive_ = false;
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
double declickRefR_ = 0.0;
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
double lastOutL_ = 0.0;
double lastOutR_ = 0.0;
std::uint64_t startOrder_ = 0;
};
// ---------------------------------------------------------------------------
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
//
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a
// new note-on arrives, steal in this priority order:
// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut),
// 2. else the oldest voice overall (longest-held note gives way to the new one).
// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing
// that, the note that has already had the most time.
// ---------------------------------------------------------------------------
class VoiceEngine {
public:
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
// from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
// `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
// dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
// are unaffected.
//
// `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single
// voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a same-sample
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The
// engine's config is immutable — a mode/count change rebuilds the engine off-thread through
// the processor's drain-slot reload, so ringing tails survive the swap.
//
// `takeoverDeclick` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice —
// the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample
// legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp
// (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates
// on the voice being active, so a fresh start (free voice) never ramps. Default FALSE
// keeps the bare core byte-identical to the pre-fix engine (regression baseline); the
// processor shell opts in — the same layering as the kDefaultPitchEngine product default.
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly,
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
bool takeoverDeclick = false);
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
// voice, or steals one per the policy above. Returns the index of the voice used,
// or kNoVoice for an out-of-zone (unplayed) note.
std::size_t noteOn(int note, int velocity);
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
// playing `note` (so a re-triggered same note releases the newest first, leaving
// the older tail to ring — matches hardware behavior). No-op if none match.
void noteOff(int note);
// CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice
// (Gate voices enter their AHDSR release tail; Trigger one-shots ignore release and play
// through their bounded play length). This is the mono stack's ONLY reset path — a phantom
// entry left by a lost note-off would otherwise be resurrected by the fallback and sustain
// forever with no key held. RT-safe (no allocation, bounded by maxVoices).
void allNotesOff();
// CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no
// release ramp), clears the MONO held stack, and silences even Trigger one-shots that would
// ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior.
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
void allSoundsOff();
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
// this never touches memory it does not own and NEVER allocates). This is the
// audio-thread entry point: the VST3 process callback passes the host's own output
// channel buffer, so no allocation, resize, or heap traffic happens under process.
// Voices that finish mid-block go idle and stop contributing. `out` must point at
// at least `frameCount` writable samples; a null `out` or zero count is a no-op.
void render(AudioSample* out, std::size_t frameCount);
// REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two
// buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there
// (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no
// resize, no lock. A mono sample plays dual-mono (same value to both channels, centered);
// a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono
// and stereo render paths are independent output shapes over the SAME voice pool; the active
// channel mode (mono vs stereo bus) picks which one the process callback drives per block.
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
// TEST / off-thread convenience: appends `frameCount` summed frames to `out`
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the
// real-time overload after sizing the buffer, so both paths share one mix loop.
// Does not clear existing contents — appends, matching the pre-S4 contract the
// unit tests rely on.
void render(std::vector<AudioSample>& out, std::size_t frameCount);
// Count of currently active voices (for tests / diagnostics).
std::size_t activeVoiceCount() const;
std::size_t maxVoices() const { return voices_.size(); }
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
private:
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
std::size_t allocateVoice();
// Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const;
// --- MONO mode (Phase S): last-note priority over a held-note stack ------------
// The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most
// recent = the sounding note while the voice is gated). An out-of-zone note never joins
// (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing
// a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no
// allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback
// re-strikes the fallen-back-to note at ITS original velocity.
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
// Mono note-on: push to the stack and take the voice over (legato retune on a same-sample
// takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone
// or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8).
// The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter,
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover.
std::size_t monoNoteOn(int note, int velocity);
// Mono note-off: pop from the stack; if the released note was sounding, fall back to the
// most-recent still-held note (retrigger or legato per monoTrigger_), else release.
void monoNoteOff(int note);
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
void removeHeld(int note);
std::vector<Voice> voices_;
const Keymap& keymap_;
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
bool takeoverDeclick_ = false; // GA fix: declick every restart/steal of a sounding voice
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
std::size_t heldCount_ = 0;
};
// NOTE (preview redesign): the Phase S PreviewCard — a dedicated preview voice isolated
// from the MIDI pool — is RETIRED. The editor's preview trigger is now a synthetic note-on
// at the loaded capture's root note through the SAME VoiceEngine host MIDI drives, so a
// preview is a real voice: it counts against the voice count, can steal / be stolen, and
// respects Poly/Mono + Retrigger/Legato (a deliberate reversal of the earlier isolation
// decision). The FA1 unity-Varispeed demotion in Voice::start went with it — since the GA2
// prime fix the shifter speaks on frame 0 at every ratio, so the demotion bought nothing
// but a second code path.
} // namespace reasampler
@@ -0,0 +1,272 @@
// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types.
#include "core/instrument/engine/velocity_curve.h"
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
#include <cmath> // std::fabs
#include <utility> // std::move
namespace reasampler::instrument::engine {
namespace {
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
// Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward).
double velPerPixel(const VelocityCurve::Box& box) {
const int w = std::max(0, box.width);
if (w <= 0) return 0.0;
return (kVelMax - kVelMin) / static_cast<double>(w);
}
double ampPerPixel(const VelocityCurve::Box& box) {
const int h = std::max(0, box.height);
if (h <= 1) return 0.0;
return (kAmpMax - kAmpMin) / static_cast<double>(h - 1);
}
int velToX(const VelocityCurve::Box& box, double velocity) {
const int w = std::max(0, box.width);
if (w <= 0) return box.left;
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
return box.left + static_cast<int>(frac * static_cast<double>(w) + 0.5);
}
int ampToY(const VelocityCurve::Box& box, double amp) {
const int h = std::max(0, box.height);
if (h <= 1) return box.top;
// amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1).
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
}
} // namespace
VelocityCurve VelocityCurve::flat() {
VelocityCurve c;
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A)
return c;
}
VelocityCurve VelocityCurve::linear() {
VelocityCurve c;
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127
return c;
}
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots.
for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity);
p.amp = clampAmp(p.amp);
}
std::stable_sort(pts.begin(), pts.end(),
[](const VelocityPoint& a, const VelocityPoint& b) {
return a.velocity < b.velocity;
});
// Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default.
if (pts.size() < 2) return flat();
// Force endpoints present at velocity 0 and 127 (they must exist for eval to be total).
if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
} else {
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint
}
if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
} else {
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint
}
VelocityCurve c;
c.points_ = std::move(pts);
return c;
}
namespace {
// FritschCarlson monotone-cubic tangent for one interior knot i, given the secant slopes of the
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range.
//
// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local
// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the
// weighted-harmonic-mean tangent (FritschCarlson eq. 4), which for COLLINEAR knots (dPrev==dNext)
// reduces to that common secant — so collinear control points reproduce the straight line to within
// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear().
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum
// Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case:
// dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly.
const double w1 = 2.0 * spanNext + spanPrev;
const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
}
} // namespace
double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp
const double v = clampVelocity(velocity);
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
// Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan).
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
const VelocityPoint& a = points_[i];
const VelocityPoint& b = points_[i + 1];
if (v >= a.velocity && v <= b.velocity) {
const double span = b.velocity - a.velocity;
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
// has zero width so there is no interior to blend.
if (span <= 0.0) return clampAmp(b.amp);
// --- Monotone cubic Hermite (FritschCarlson) interpolation on segment [a,b] ---------
// Curved (spline) response, not straight lines. The interpolant provably stays within
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear
// control points its tangents reduce to the secant slope — so it reproduces the straight
// line to within floating-point rounding (~1e-15), preserving linear()'s null-response
// contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate).
const double d = (b.amp - a.amp) / span; // secant of THIS segment
// Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using
// the previous segment's secant. Same for the tangent at b (0 at the last knot).
double mA = d;
if (i > 0) {
const VelocityPoint& prev = points_[i - 1];
const double spanPrev = a.velocity - prev.velocity;
if (spanPrev > 0.0) {
const double dPrev = (a.amp - prev.amp) / spanPrev;
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
} else {
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent
}
}
double mB = d;
if (i + 2 < points_.size()) {
const VelocityPoint& next = points_[i + 2];
const double spanNext = next.velocity - b.velocity;
if (spanNext > 0.0) {
const double dNext = (next.amp - b.amp) / spanNext;
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
} else {
mB = 0.0; // coincident-X successor (a step at b) -> flat tangent
}
}
// Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots
// mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight
// line to within floating-point rounding (~1e-15).
const double t = (v - a.velocity) / span;
const double t2 = t * t;
const double t3 = t2 * t;
const double h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
const double h10 = t3 - 2.0 * t2 + t;
const double h01 = -2.0 * t3 + 3.0 * t2;
const double h11 = t3 - t2;
const double y = h00 * a.amp + h10 * span * mA + h01 * b.amp + h11 * span * mB;
return clampAmp(y);
}
}
return clampAmp(points_.back().amp); // unreachable (v is between the endpoints)
}
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
// Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a
// duplicate-X point lands immediately after the existing one (a later move can separate them).
std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
return i;
}
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double amp) {
if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range)
const bool isFirst = (index == 0);
const bool isLast = (index + 1 == points_.size());
double newAmp = clampAmp(amp);
double newVel;
if (isFirst) {
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves
} else if (isLast) {
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves
} else {
// Interior point: clamp X strictly within its immediate neighbours so it can't cross them.
const double lo = points_[index - 1].velocity;
const double hi = points_[index + 1].velocity;
newVel = std::clamp(clampVelocity(velocity), lo, hi);
}
points_[index] = VelocityPoint{newVel, newAmp};
return points_[index];
}
bool VelocityCurve::deletePoint(std::size_t index) {
if (index >= points_.size()) return false;
if (index == 0 || index + 1 == points_.size()) return false; // endpoints are not deletable
points_.erase(points_.begin() + static_cast<std::ptrdiff_t>(index));
return true;
}
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const VelocityPoint& p) {
return CurvePixel{velToX(box, p.velocity), ampToY(box, p.amp)};
}
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
// ampToY pins to box.top (amp 1).
VelocityPoint p;
const int w = std::max(0, box.width);
const int h = std::max(0, box.height);
p.velocity = (w <= 0)
? kVelMin
: clampVelocity(kVelMin + static_cast<double>(x - box.left) / static_cast<double>(w) *
(kVelMax - kVelMin));
p.amp = (h <= 1)
? kAmpMax
: clampAmp(kAmpMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
(kAmpMax - kAmpMin));
return p;
}
int VelocityCurve::pointAtPixel(const Box& box, int x, int y) const {
for (std::size_t i = 0; i < points_.size(); ++i) {
const int px = velToX(box, points_[i].velocity);
const int py = ampToY(box, points_[i].amp);
if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) {
return static_cast<int>(i);
}
}
return -1;
}
VelocityCurve VelocityCurve::resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
const Box& box, int dxPixels, int dyPixels) {
VelocityCurve out = grabCurve;
if (index >= out.points_.size()) return out; // out of range -> no motion
const double velPerPx = velPerPixel(box);
const double ampPerPx = ampPerPixel(box);
if (velPerPx <= 0.0 || ampPerPx <= 0.0) return out; // degenerate box -> no motion
const VelocityPoint& grab = grabCurve.points_[index];
const double newVel = grab.velocity + static_cast<double>(dxPixels) * velPerPx;
// Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp.
const double newAmp = grab.amp - static_cast<double>(dyPixels) * ampPerPx;
out.movePoint(index, newVel, newAmp); // applies box + neighbour-X + endpoint-pin clamps
return out;
}
bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
if (points_.size() != other.points_.size()) return false;
for (std::size_t i = 0; i < points_.size(); ++i) {
if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false;
if (std::fabs(points_[i].amp - other.points_[i].amp) > eps) return false;
}
return true;
}
} // namespace reasampler::instrument::engine
+171
View File
@@ -0,0 +1,171 @@
// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's
// pixel delta back through here, committing the result to the zone through the same off-audio-thread
// path a slider edit uses.
//
// WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar
// (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per
// note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed
// linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a
// sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored
// on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack).
//
// DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded
// default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change
// vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn.
// NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response.
//
// THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval
// couldn't handle:
// * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control
// points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per
// velocity): each X falls in exactly one [p_i, p_{i+1}] segment.
// * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box).
// Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete
// refuses to remove them, and the constructors seed them.
#pragma once
#include <cstdint>
#include <vector>
// DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW
// sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the
// engine must not gain a transitive dependency on the editor's layout types. The editor hit-test /
// inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a
// Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's
// role, but one layer lower, so the coupling stays out of the engine core.
namespace reasampler::instrument::engine {
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
inline constexpr double kVelMin = 0.0;
inline constexpr double kVelMax = 127.0;
inline constexpr double kAmpMin = 0.0;
inline constexpr double kAmpMax = 1.0;
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so
// build curves through the named constructors / addPoint rather than pushing raw points.
struct VelocityPoint {
double velocity = 0.0; // X, [0,127]
double amp = 0.0; // Y, [0,1]
};
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
inline constexpr int kCurveNodeGrabRadius = 6;
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
// a MONOTONE cubic Hermite spline (FritschCarlson slope limiting) through the knots — a genuine
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the
// FritschCarlson tangents reduce to the secant slope, so the spline reproduces the straight line to
// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract
// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints
// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
class VelocityCurve {
public:
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
static VelocityCurve flat();
// The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests
// and as the Option-B seed; NOT the default (see R10-F1).
static VelocityCurve linear();
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list).
// A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly —
// a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one.
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
const std::vector<VelocityPoint>& points() const { return points_; }
std::size_t size() const { return points_.size(); }
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
// points the amp follows a MONOTONE cubic Hermite spline (FritschCarlson slope limiting) — a
// true curve that provably stays within the two knots' amp range (no overshoot below 0 / above
// 1) and reproduces the straight line to within floating-point rounding (~1e-15) for collinear
// knots. Single-valued / monotonic in X.
// Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat
// unity); a ONE-point curve returns that point's amp.
double eval(double velocity) const;
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the
// index of the inserted point. A new point at a velocity that duplicates an existing one is
// inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not
// special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint).
std::size_t addPoint(double velocity, double amp);
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X
// (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity
// argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped)
// resulting point.
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
// Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a
// point was removed.
bool deletePoint(std::size_t index);
// --- Editor hit-test + inverse map (mirror of envelope_edit) --------------------------------
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall.
// X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height
// (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module
// stays free of editor-layout types — see the header preamble.
struct Box {
int left = 0;
int top = 0;
int width = 0;
int height = 0;
};
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order
// for determinism (mirror of nodeAtPoint).
int pointAtPixel(const Box& box, int x, int y) const;
// A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping
// pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles
// at exactly the coordinates the hit-test expects (draw and grab can never drift).
struct CurvePixel {
int x = 0;
int y = 0;
};
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within
// the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a
// zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring
// pixelFromPoint's degenerate collapse.
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
// Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box.
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
const Box& box, int dxPixels, int dyPixels);
// Equality (for tests + round-trip assertions): same point count + each point equal within a
// tight epsilon.
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
private:
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named
// constructors + deserialize (see sample_map), which establish that invariant; the mutators
// preserve it.
std::vector<VelocityPoint> points_;
};
} // namespace reasampler::instrument::engine
+63
View File
@@ -0,0 +1,63 @@
// bank_sync.cpp — see bank_sync.h. Pure; standard library only.
#include "core/instrument/map/bank_sync.h"
#include <cstdint>
#include <string>
#include "core/wire/wire.h"
namespace reasampler::instrument::map {
std::int64_t parseBankGeneration(const std::string& raw) {
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading
// '+' / '-', any non-digit, an empty string, or overflow past int64 max all
// reject to the absent default (0); the guarded accumulate means a
// pathologically long digit run can never wrap into a bogus small value.
std::int64_t value = 0;
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
return value;
}
std::string formatBankGeneration(std::int64_t generation) {
// Non-negative decimal; a negative (should never be produced by the writer) formats as
// its std::to_string form and would parse back to 0, so the writer's monotonic counter
// stays in the >= 0 domain by construction.
return std::to_string(generation);
}
bool bankGenerationChanged(std::int64_t seen, std::int64_t current) {
return current != seen;
}
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
std::int64_t lastConsumed, bool resolves,
bool isFocusedTarget) {
AssignConsumeDecision d;
d.consumedGeneration = lastConsumed; // default: nothing changes
// Rule 1: no request, or not newer than what we already consumed -> nothing new.
if (!request) return d;
if (request->generation <= lastConsumed) return d;
// Rule 2: a new request, but this instance is not the target -> do not act, do NOT
// advance the marker (stay eligible if focus later lands here). No thundering herd.
if (!isFocusedTarget) return d;
// The request is new AND we are the target: it will be consumed-as-seen either way, so
// advance the marker to its generation so it is never re-evaluated.
d.consumedGeneration = request->generation;
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker
// advanced above, but no selection change.
if (!resolves) return d;
// Rule 4: new, target, resolvable -> apply the selection.
d.apply = true;
d.bankId = request->bankId;
d.sampleId = request->sampleId;
return d;
}
} // namespace reasampler::instrument::map
+107
View File
@@ -0,0 +1,107 @@
#pragma once
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a
// host-facing shell.
//
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state
// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and
// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string
// read crosses the bridge in the shell; every DECISION after — parse the generation
// stamp, decide whether it differs from what we last saw, decide whether a decoded
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
//
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
// effects (reloadInstrument, setSelectedSampleId); this module owns only the yes/no maths so
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
#include <cstdint>
#include <optional>
#include <string>
#include "core/wire/assignment_request.h" // AssignmentRequest (the decoded request this consumes)
namespace reasampler::instrument::map {
using wire::AssignmentRequest;
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value
// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the
// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation).
inline constexpr std::int64_t kBankGenerationAbsent = 0;
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp
// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never
// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing
// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored
// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT
// poll once the value is clean).
std::int64_t parseBankGeneration(const std::string& raw);
// Format a bank-generation counter for the ext-state stamp. The inverse of
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so
// the stamp is byte-stable across writes of the same value.
std::string formatBankGeneration(std::int64_t generation);
// Has the bank generation changed since the reader last saw `seen`? True when `current`
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just
// an increase): the writer is monotonic, but a project switch or reload can legitimately
// lower the value, and the reader should re-read the bank in that case too. `seen` starts at
// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 /
// first-bump refresh the spec requires).
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
// The verdict of the S8 assignment-request consume decision (below). A pure value the
// processor shell acts on: apply the selection (or not) and advance the consumed marker
// (or not). Distinct booleans because the two are NOT the same event — a request may be
// consumed-as-seen (marker advances) without being applied (it named an unresolvable
// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it
// every poll.
struct AssignConsumeDecision {
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
std::string bankId; // the request's bank (valid only when apply)
std::string sampleId; // the request's sample (valid only when apply)
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
};
// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader).
//
// `request` — the decoded assignment request (nullopt when the assign_request key
// is absent / malformed — nothing pending).
// `lastConsumed` — the generation this instance last consumed (persisted in component
// state so a re-open does not re-apply a request the user already got,
// then manually changed away from). Defaults to 0 for a fresh instance.
// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank
// sample RIGHT NOW (the shell computed this against the live bank blob).
// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's
// thundering-herd policy (e.g. only the focused-editor instance applies).
// The shell passes true when this instance should act; false suppresses
// consumption entirely so a non-target instance neither applies nor
// advances its marker (it stays eligible if it later becomes the target).
//
// RULES (all pure, order matters):
// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not
// apply, marker unchanged. (Covers the re-open case: the persisted marker == the
// request's generation, so it is not re-applied.)
// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not
// apply and do NOT advance the marker — a non-target instance must stay able to consume
// the request if focus later lands on it. (No thundering herd: only the target acts.)
// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT
// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply,
// but DO advance the marker to the request's generation so a stale/unresolvable request
// is consumed-as-seen and never re-evaluated (no error state, no selection change).
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
// advance the marker to the request's generation.
//
// The shell then: if apply, setSelectedSampleId + reloadInstrument; always persist
// consumedGeneration into component state when it advanced.
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
std::int64_t lastConsumed, bool resolves,
bool isFocusedTarget);
} // namespace reasampler::instrument::map
@@ -0,0 +1,16 @@
// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types.
#include "core/instrument/map/bridge_marshal.h"
namespace reasampler::instrument::map {
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer) {
// REAPER returns the length of the stored value; 0 means the key is absent. Guard
// both the return AND the buffer: a caller that reused a dirty buffer must not
// surface stale bytes as a value when the API reported nothing.
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
return buffer;
}
} // namespace reasampler::instrument::map
+37
View File
@@ -0,0 +1,37 @@
// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read.
// NO VST3, NO REAPER types at the boundary.
//
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out
// of a REAPER-facing shell.
//
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
// stand-in until the instrument could parse the bank properly. S4 retired it: the
// instrument now parses the "reasampler" bank blob through the SHARED bank_book /
// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module
// is back to its one honest job — the API-return decode.
//
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
// -- returns the length written (0 when the key is absent).
#pragma once
#include <optional>
#include <string>
namespace reasampler::instrument::map {
// Interpret a GetProjExtState result: the int return value (bytes the API reports for
// the key) and the buffer it filled. Returns the value only when the API reported a
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer
// untouched for an absent key, and we must not treat stale buffer contents as a hit.
//
// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it
// wrote (already truncated to the C string by the caller).
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer);
} // namespace reasampler::instrument::map
+113
View File
@@ -0,0 +1,113 @@
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
#include "core/instrument/map/note_entry.h"
#include <algorithm>
#include <cctype>
namespace reasampler::instrument::map {
namespace {
char asciiUpper(char c) {
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
std::string trim(const std::string& s) {
std::size_t a = 0;
std::size_t b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
int clampNote(long long n) {
if (n < 0) return 0;
if (n > 127) return 127;
return static_cast<int>(n);
}
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
int letterSemitone(char up) {
switch (up) {
case 'C': return 0;
case 'D': return 2;
case 'E': return 4;
case 'F': return 5;
case 'G': return 7;
case 'A': return 9;
case 'B': return 11;
default: return -1;
}
}
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name.
std::optional<int> parseNoteName(const std::string& s) {
if (s.empty()) return std::nullopt;
std::size_t i = 0;
const int base = letterSemitone(asciiUpper(s[i]));
if (base < 0) return std::nullopt; // not a letter -> not a note name
++i;
int semitone = base;
// Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs).
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
// A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is
// an accidental. '#' raises, 'b'/'B' lowers.
if (s[i] == '#') ++semitone;
else --semitone;
++i;
}
// The octave: an optional sign then digits, running to the end.
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
bool neg = false;
if (s[i] == '+' || s[i] == '-') {
neg = (s[i] == '-');
++i;
}
if (i >= s.size()) return std::nullopt;
int octave = 0;
bool anyDigit = false;
for (; i < s.size(); ++i) {
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
octave = octave * 10 + (s[i] - '0');
anyDigit = true;
}
if (!anyDigit) return std::nullopt;
if (neg) octave = -octave;
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
return clampNote(note);
}
std::optional<int> parseInteger(const std::string& s) {
if (s.empty()) return std::nullopt;
std::size_t i = 0;
bool neg = false;
if (s[i] == '+' || s[i] == '-') {
neg = (s[i] == '-');
++i;
}
if (i >= s.size()) return std::nullopt;
long long v = 0;
for (; i < s.size(); ++i) {
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
v = v * 10 + (s[i] - '0');
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
}
if (neg) v = -v;
return clampNote(v);
}
} // namespace
std::optional<int> parseNoteEntry(const std::string& text) {
const std::string s = trim(text);
if (s.empty()) return std::nullopt;
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
if (auto n = parseInteger(s)) return n;
}
return parseNoteName(s);
}
} // namespace reasampler::instrument::map
+33
View File
@@ -0,0 +1,33 @@
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The
// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit-
// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control
// or a LICE text-entry idiom) and feeds the committed string here on Enter.
//
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI
// note [0,127] and commits via the same off-thread reload as every other edit.
//
// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served):
// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and
// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60
// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel).
// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than
// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns
// nullopt (the shell keeps the old value + may flash the field).
#pragma once
#include <optional>
#include <string>
namespace reasampler::instrument::map {
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal
// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is
// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
std::optional<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::instrument::map
+972
View File
@@ -0,0 +1,972 @@
// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL /
// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core.
#include "core/instrument/map/sample_map.h"
#include <algorithm> // std::min
#include <cassert> // assert
#include <cmath> // std::isfinite (v8 master-gain validation)
#include <cstring> // std::memcpy
#include <utility> // std::move
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
namespace reasampler {
using instrument::engine::masterGainMaxLinear;
namespace {
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
// SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) {
SampleLoop out;
if (s.loop) {
out.hasLoop = true;
out.start = s.loop->start;
out.end = s.loop->end;
}
return out;
}
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
SelectedSample distill(const Sample& s) {
SelectedSample out;
out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
return out;
}
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the
// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's
// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone.
// Shared so the two resolution paths cannot drift.
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
ResolvedZone rz;
rz.relativePath = ref.relativePath;
rz.lowNote = z.lowNote;
rz.highNote = z.highNote;
// Effective root: override beats intrinsic (distill already defaulted an empty
// intrinsic to middle C).
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
// S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state —
// carried straight through and applied at play time.
rz.keyTrack = z.keyTrack;
rz.velocityCurve = z.velocityCurve;
// Effective loop / start (S11): the per-zone override wins over the intrinsic; absent
// -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B).
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
rz.startFrame = z.startPoint ? *z.startPoint : 0;
// S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap
// resolves them to frames.
rz.play = z.play;
return rz;
}
} // namespace
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) {
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
// circuit before parsing — no stored id resolves to nothing to play by design.
if (sampleId.empty()) return std::nullopt;
if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (pool first, then named — banks() is ordinal order) for the
// stored id. A sample lives in exactly one bank, so first hit wins.
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) {
return distill(*s);
}
}
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
// the editor reflects the missing pick with its empty state rather than masking it.
return std::nullopt;
}
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit) {
if (isExplicit) return current; // user's explicit choice is never fought
if (channelCount <= 0) return current; // unknown (0) or pathological -> no change
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
}
// --- Instance-owned sample references (pS self-contained playback) -------------
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
if (sampleId.empty()) return nullptr;
for (const SampleRefEntry& e : refs) {
if (e.sampleId == sampleId) return &e.ref;
}
return nullptr;
}
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map) {
std::vector<std::string> ids;
const auto addUnique = [&ids](const std::string& id) {
if (id.empty()) return;
for (const std::string& have : ids) {
if (have == id) return;
}
ids.push_back(id);
};
addUnique(selectionId);
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
return ids;
}
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids) {
if (ids.empty() || banksJson.empty()) return;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return; // malformed blob -> no-op (the instance keeps its own copies)
for (const std::string& id : ids) {
const Sample* found = nullptr;
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(id)) { found = s; break; }
}
if (!found) continue; // bank miss: NEVER strips a ref — the instance owns its copy
const SelectedSample distilled = distill(*found);
bool updated = false;
for (SampleRefEntry& e : refs) {
if (e.sampleId == id) {
e.ref = distilled;
e.displayName = found->displayName; // rename sync rides the same refresh
updated = true;
break;
}
}
if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName});
}
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
const std::optional<BankBook> book = BankBook::deserialize(*banksJson);
if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET
for (const std::string& id : ids) {
for (const Bank& b : book->banks()) {
if (b.index.query(id)) return LegacyLiftDecision::Lift;
}
}
// The blob parses and knows none of the referenced ids (or there are none): provably
// stale — a lift can never make progress against this bank.
return LegacyLiftDecision::Stale;
}
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) {
refs.erase(std::remove_if(refs.begin(), refs.end(),
[&ids](const SampleRefEntry& e) {
for (const std::string& id : ids) {
if (id == e.sampleId) return false;
}
return true;
}),
refs.end());
}
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
std::vector<SampleChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
for (const Sample& s : b.index.all()) {
out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
}
}
return out;
}
std::vector<BankChoice> listBanks(const std::string& banksJson) {
std::vector<BankChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
out.push_back(BankChoice{b.id, b.displayName});
}
return out;
}
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
const double inv = 1.0 / static_cast<double>(channelCount);
for (std::size_t f = 0; f < frames; ++f) {
double acc = 0.0;
const std::size_t base = f * stride;
for (std::size_t c = 0; c < stride; ++c) {
acc += static_cast<double>(interleaved[base + c]);
}
out[f] = static_cast<AudioSample>(acc * inv);
}
return out;
}
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
// Clamp the requested channel into the source's range: a channel past the last one reads
// the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono).
std::size_t ch = which < 0 ? 0 : static_cast<std::size_t>(which);
if (ch >= stride) ch = stride - 1;
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch];
return out;
}
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) {
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
DecodedZonePcm out;
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) {
// MONO mode: the existing downmix policy (average all source channels), one channel out.
out.monoFrames = downmixToMono(interleaved, sourceChannels);
return out; // framesR stays empty
}
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
// out-of-range channel request to the last channel, so a mono source yields L == R.
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
out.framesR = extractChannel(interleaved, sourceChannels, 1);
return out;
}
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
ZonePlayParams out;
out.playMode = stored.playMode;
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
out.trigger = stored.trigger; // source-frame / fraction, unchanged
out.pitchEngine = stored.pitchEngine;
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
return out;
}
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
SampleData data;
data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
if (!framesR.empty() && framesR.size() == data.frames.size()) {
data.framesR = std::move(framesR);
}
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
data.sampleRate = sampleRate;
data.rootNote = rootNote;
data.loop = loop;
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
data.play = resolvePlay(play, data.sampleRate);
return Keymap::singleSampleChromatic(std::move(data));
}
// --- Performance map ---------------------------------------------------------
ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map) {
ResolvedPerformance out;
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
if (banksJson.empty()) return out; // no bank -> nothing resolves
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out; // malformed -> nothing (never throw)
for (const PerformanceZone& z : map.zones) {
// Look the id up across every bank (pool + named) — a sample lives in exactly
// one bank, so first hit wins.
const Sample* found = nullptr;
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(z.sampleId)) {
found = s;
break;
}
}
if (!found) {
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
out.droppedSampleIds.push_back(z.sampleId);
continue;
}
// Distill the bank Sample to the same intrinsics shape the refs table carries, then
// run the SHARED fold — so the bank path and the refs path resolve identically.
out.zones.push_back(foldZone(z, distill(*found)));
}
return out;
}
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map) {
ResolvedPerformance out;
for (const PerformanceZone& z : map.zones) {
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
out.zones.push_back(foldZone(z, *r));
} else {
// No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the
// zone cleanly + report — the same shape as the bank path's stale-id policy.
out.droppedSampleIds.push_back(z.sampleId);
}
}
return out;
}
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
if (selectedId.empty() || map.zones.empty()) return false;
for (const PerformanceZone& z : map.zones) {
// An authored key range marks Zone-view intent — first-match order is load-bearing
// there, so the map is left exactly as authored.
if (z.lowNote != 0 || z.highNote != 127) return false;
}
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
// zone bound to the selection (preserving its params); drop the stale shadowers.
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
for (std::size_t i = 0; i < map.zones.size(); ++i) {
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
}
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
map.zones.resize(keptCount);
return true;
}
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded) {
Keymap km;
const std::size_t n = std::min(zones.size(), decoded.size());
for (std::size_t i = 0; i < n; ++i) {
// An unreadable/empty WAV drops just this zone (not the whole map).
if (decoded[i].monoFrames.empty()) continue;
SampleData data;
data.frames = decoded[i].monoFrames;
// Carry the second channel only when it length-matches channel 0 (channelCount()
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
if (!decoded[i].framesR.empty() &&
decoded[i].framesR.size() == data.frames.size()) {
data.framesR = decoded[i].framesR;
}
assert(decoded[i].sampleRate > 0 &&
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
data.sampleRate = decoded[i].sampleRate;
data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(zones[i].play, data.sampleRate);
const std::size_t sampleIndex = km.samples.size();
km.samples.push_back(std::move(data));
KeyZone zone;
zone.lowNote = zones[i].lowNote;
zone.highNote = zones[i].highNote;
zone.rootNote = zones[i].rootNote;
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
zone.sampleIndex = sampleIndex;
km.zones.push_back(zone);
}
return km; // empty zones in -> empty Keymap (silence)
}
// --- Performance-map instance state (setState/getState) -----------------------
namespace {
void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as
// two's-complement u64, mirroring the u32 signed-int idiom above).
void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
}
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++).
// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields.
std::uint64_t doubleToBits(double d) {
std::uint64_t bits;
std::memcpy(&bits, &d, sizeof(bits));
return bits;
}
double bitsToDouble(std::uint64_t bits) {
double d;
std::memcpy(&d, &bits, sizeof(d));
return d;
}
// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
// blob degrades to a partial/empty parse rather than reading out of bounds.
struct ByteReader {
const std::vector<std::uint8_t>& bytes;
std::size_t pos = 0;
bool ok = true;
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
std::uint32_t u32() {
if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
pos += 4;
return v;
}
std::uint8_t u8() {
if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; }
return bytes[pos++];
}
std::string str(std::uint32_t len) {
if (!ok || pos + len > bytes.size()) { ok = false; return {}; }
std::string s(reinterpret_cast<const char*>(bytes.data() + pos), len);
pos += len;
return s;
}
// Signed ints go on the wire as u32 two's-complement (fixed 32-bit width).
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
std::uint64_t u64() {
if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; }
std::uint64_t v = 0;
for (int b = 0; b < 8; ++b)
v |= static_cast<std::uint64_t>(bytes[pos + static_cast<std::size_t>(b)]) << (b * 8);
pos += 8;
return v;
}
// Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width).
std::int64_t i64() { return static_cast<std::int64_t>(u64()); }
// Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields
// 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob
// as "no marker" and falls through to the (also-guarded) v1 count read.
std::uint32_t peekU32() const {
if (!ok || pos + 4 > bytes.size()) return 0;
return static_cast<std::uint32_t>(bytes[pos]) |
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
}
};
// Append the zones payload — the shared body of the performance blob and the component blob,
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
// the zone count so any reader can detect the record shape independently of the envelope version
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
// through EITHER envelope with no envelope bump.
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
putU32le(out, kZonesFormatMarker);
putU32le(out, kZonesPayloadVersion);
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
for (const PerformanceZone& z : map.zones) {
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
out.push_back(z.rootOverride ? 1 : 0);
if (z.rootOverride) {
putU32le(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
}
// S11 extension: loop override (hasLoop flag + start/end), then start point.
out.push_back(z.loopOverride ? 1 : 0);
if (z.loopOverride) {
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
putU64le(out, asU64(z.loopOverride->start));
putU64le(out, asU64(z.loopOverride->end));
}
out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
// fraction. Order matches the header's v5 record spec.
const ZonePlaySeconds& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
putU64le(out, doubleToBits(pp.adsr.attackSeconds));
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
putU64le(out, doubleToBits(z.keyTrack));
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
putU32le(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) {
putU64le(out, doubleToBits(p.velocity));
putU64le(out, doubleToBits(p.amp));
}
}
}
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
// keeps the zones that parsed cleanly and drops the rest.
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
bool extended = false; // v2+: the S11 loop/start tail is present
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker
pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail
}
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last
const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
PerformanceZone z;
const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen);
z.lowNote = r.i32();
z.highNote = r.i32();
const std::uint8_t hasOverride = r.u8();
if (hasOverride) z.rootOverride = r.i32();
if (extended) {
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
z.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64();
}
if (legacyV3Play) {
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is.
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr.
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64();
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
} else if (secondsPlay) {
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
// frames; read in the emit order.
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64();
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
// already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones.
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
if (curveTail) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge
// count can't trigger a giant allocation before the bounded reads fail — the loop still
// stops on r.ok, this only caps the speculative reserve.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, amp});
}
if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts));
}
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z));
}
}
} // namespace
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
std::vector<std::uint8_t> out;
putU32le(out, kPerformanceStateVersion);
putZonesPayload(out, map);
return out;
}
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
PerformanceMap map;
ByteReader r(bytes);
const std::uint32_t version = r.u32();
if (!r.ok) return map; // no version tag -> empty
// BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes,
// no length prefix). Lift it to one full-keyboard zone playing that id.
if (version == kSelectionStateVersion) {
const std::string id = deserializeSelection(bytes);
if (!id.empty()) {
PerformanceZone z;
z.sampleId = id;
z.lowNote = 0;
z.highNote = 127;
map.zones.push_back(std::move(z));
}
return map;
}
if (version != kPerformanceStateVersion) return map; // unknown -> empty
readZonesPayload(r, map, projectRate);
return map;
}
// --- Combined component state (v3, S10) --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> out;
putU32le(out, kComponentStateVersion);
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
// stops at the mode byte is a strict prefix (see the v4 lift below).
putU64le(out, asU64(state.lastConsumedAssignGeneration));
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
out.push_back(state.previewVelocity);
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly,
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
: state.voiceCount;
out.push_back(static_cast<std::uint8_t>(vc));
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
// v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or
// negative falls back to unity; above the +24 dB cap clamps to the cap.
{
double g = state.masterGainLinear;
const double maxLin = masterGainMaxLinear();
if (!std::isfinite(g) || g < 0.0) g = 1.0;
if (g > maxLin) g = maxLin;
putU64le(out, doubleToBits(g));
}
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag,
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
// channel count); 1 = the user deliberately toggled the mode (never fought).
out.push_back(state.channelModeExplicit ? 1 : 0);
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
// written), channelCount, displayName (length-prefixed; display-only).
putU32le(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
for (const SampleRefEntry& e : state.sampleRefs) {
putU32le(out, static_cast<std::uint32_t>(e.sampleId.size()));
out.insert(out.end(), e.sampleId.begin(), e.sampleId.end());
putU32le(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end());
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.rootNote)));
out.push_back(e.ref.loop.hasLoop ? 1 : 0);
putU64le(out, asU64(e.ref.loop.start));
putU64le(out, asU64(e.ref.loop.end));
putU32le(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
}
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
putU32le(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
// unlike the v1 selection blob where the id ran to end-of-stream).
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
putZonesPayload(out, state.map);
return out;
}
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
// For v5 and later blobs it is unused. See readZonesPayload for the guard.
ComponentState out;
ByteReader r(bytes);
const std::uint32_t version = r.u32();
if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state)
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
// * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
// zone} so the old pick survives as BOTH the selection and a one-zone map.
// * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
// single-capture selection.
if (version == kSelectionStateVersion) {
out.selectionId = deserializeSelection(bytes);
if (!out.selectionId.empty()) {
PerformanceZone z;
z.sampleId = out.selectionId;
z.lowNote = 0;
z.highNote = 127;
out.map.zones.push_back(std::move(z));
}
return out;
}
if (version == kPerformanceStateVersion) {
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
return out; // channelMode stays Mono (pre-S7)
}
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
// the id length + id + zones body starts right after the version tag (no mode byte).
if (version == kSelectionZonesV3Version) {
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
}
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
// defaults to 0, so a first assign still applies for a pre-marker instance.
if (version == kSelectionZonesModeV4Version) {
const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
return out; // marker stays 0 (pre-S8/S9 reader)
}
// BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity
// byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
// previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved
// pre-S-VIEW-4 instance restores at the mid default.
if (version == kSelectionZonesModeMarkerV5Version) {
const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
out.lastConsumedAssignGeneration = r.i64();
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
}
if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version &&
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
version != kSelectionZonesModeMarkerVelV6Version) {
return out; // unknown -> empty
}
// v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
// instance.
const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
out.lastConsumedAssignGeneration = r.i64();
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
const std::uint8_t previewVel = r.u8();
if (!r.ok) return out; // truncated before the velocity byte -> empty (mid default holds)
// Clamp to the documented MIDI 1..127 range: a 0 byte (or any out-of-spec value from a
// corrupt blob) falls back to the mid default rather than silencing the preview trigger.
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
? previewVel
: kPreviewVelocityDefault;
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior.
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
const std::uint8_t vc = r.u8();
const std::uint8_t vm = r.u8();
const std::uint8_t mt = r.u8();
if (!r.ok) return out; // truncated inside the voice bytes -> empty (defaults hold)
// Out-of-range bytes fall back to the field's DEFAULT (the previewVelocity precedent
// for a corrupt blob) rather than clamping to an edge the user never chose.
out.voiceCount = (vc >= kMinVoiceCount && vc <= kMaxVoiceCount)
? static_cast<int>(vc)
: kDefaultVoiceCount;
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
}
// v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting.
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
const double g = bitsToDouble(r.u64());
if (!r.ok) return out; // truncated inside the gain double — out already carries
// mode/marker/velocity/voice fields from above; unity holds
out.masterGainLinear =
(std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9))
? g
: 1.0;
}
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
// default (false = implicit) holds, so an already-saved instance's mode is treated as the
// un-touched default and the shell may auto-default it from the loaded capture.
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
const std::uint8_t explicitByte = r.u8();
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
out.channelModeExplicit = (explicitByte == 1);
}
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it —
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway).
if (version >= kSelectionZonesRefsV10Version) {
const std::uint32_t refCount = r.u32();
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
SampleRefEntry e;
const std::uint32_t refIdLen = r.u32();
e.sampleId = r.str(refIdLen);
const std::uint32_t pathLen = r.u32();
e.ref.relativePath = r.str(pathLen);
// Range fallbacks (the refs table is the ONLY copy on the play path, so a
// corrupt field must degrade to the field's default, never poison playback —
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back
// to the middle-C default distill() uses; a negative channel count falls back
// to 0 = unknown (the GA auto-default then skips it).
const std::int32_t root = r.i32();
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
e.ref.loop.hasLoop = (r.u8() != 0);
e.ref.loop.start = r.i64();
e.ref.loop.end = r.i64();
const std::int32_t channels = r.i32();
e.ref.channelCount = channels >= 0 ? channels : 0;
const std::uint32_t nameLen = r.u32();
e.displayName = r.str(nameLen);
if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest
out.sampleRefs.push_back(std::move(e));
}
if (!r.ok) return out;
}
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the
// EMPTY default holds and the processor mints a fresh identity on first publish.
if (version >= kSelectionZonesRefsIdentityV11Version) {
const std::uint32_t guidLen = r.u32();
out.instanceGuid = r.str(guidLen);
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
}
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
return out;
}
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
std::vector<std::uint8_t> out;
out.resize(4 + sampleId.size());
const std::uint32_t v = kSelectionStateVersion;
out[0] = static_cast<std::uint8_t>(v & 0xFF);
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
return out;
}
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
if (bytes.size() < 4) return {}; // no version tag -> no selection
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
(static_cast<std::uint32_t>(bytes[1]) << 8) |
(static_cast<std::uint32_t>(bytes[2]) << 16) |
(static_cast<std::uint32_t>(bytes[3]) << 24);
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
bytes.size() - 4);
}
} // namespace reasampler
+714
View File
@@ -0,0 +1,714 @@
#pragma once
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable
// arithmetic out of a host-facing shell.
//
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
// Keymap — is pure and unit-tested here.
//
// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
// SampleData it produces). All three are pure; this stays pure.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler {
// Q-W1 interim: clean deps live in their sub-namespace homes now; sample_map
// re-namespaces in its own split wave (Q-W2v).
using audio::AudioSample;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
// The bank sample this instance is bound to, distilled from the live "banks" blob:
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet.
struct SelectedSample {
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older
// bank entries) — the GA channel-mode auto-default skips it
};
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
// project). `sampleId` is this instance's stored selection.
//
// Precedence, all pure:
// * empty / malformed banksJson -> nullopt (nothing to play)
// * sampleId empty -> nullopt (NO selection -> silence)
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
// the editor returns to the empty state)
//
// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh
// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first
// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play
// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns
// nullopt rather than silently substituting a different sample — the editor reflects the
// missing selection with its "pick a capture" empty state instead of masking it.
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId);
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
// instance's current mode, and whether the user has explicitly toggled the mode, return
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path.
// * isExplicit == true -> current (user's choice stands)
// * channelCount == 0 -> current (unknown, skip)
// * channelCount >= 2 -> Stereo
// * channelCount == 1 -> Mono
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
// --- Instance-owned sample references (pS self-contained playback) -------------
//
// THE ARCHITECTURE CORRECTION: the instrument must never go silent because the extension's
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in
// its OWN component state, a small table of everything it needs to PLAY each referenced
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop,
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also
// refreshes this table opportunistically when readable (recapture/root edits stay live),
// never a runtime lifeline.
//
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an
// instance that carries its ref — the instance keeps playing while the FILE exists (normal
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution.
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
struct SampleRefEntry {
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
// The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref
// fallback); never consulted by resolution. Empty for a table written before the field
// existed in-session (it back-fills on the next bank refresh).
std::string displayName;
};
using SampleRefs = std::vector<SampleRefEntry>;
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
// Every bank sample id this instance plays: the selection (when set) + each zone's
// sampleId, de-duplicated, selection first then map order.
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map);
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same
// distillation selectSample performs), copying the bank display name alongside the decode
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids);
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
// instance references?
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
// project's ext-state may simply not have parsed).
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
// refs table then goes non-empty and the lift never re-fires).
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
enum class LegacyLiftDecision { Retry, Lift, Stale };
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids);
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
// exactly what the instance currently plays, so it cannot grow with browsing history).
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
// One entry in the capture browser's card list: the stable id + display name plus the S2
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache,
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already
// holds: the metadata the card badge + bank filter need. Pure projection over the shared
// parse — the UI never parses JSON itself.
//
// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the
// badge shows "root: —" / no root, never a guessed value).
// - key: the optional human musical key label ("F#m"), when the bank set it.
// - bankId: the id of the bank this sample lives in (the bank filter matches on it).
struct SampleChoice {
std::string id;
std::string displayName;
std::optional<int> rootNote;
std::optional<std::string> key;
std::string bankId;
};
std::vector<SampleChoice> listSamples(const std::string& banksJson);
// One bank the filter tab strip offers: its stable id + display name, in ordinal order
// (pool first). The browser prepends an "All" tab (no id) shell-side. Empty for an empty /
// malformed blob. Pure projection over the shared parse.
struct BankChoice {
std::string id;
std::string displayName;
};
std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields:
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
// their source channel count, so a stereo (or N-channel) capture is folded to a single
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
// the least-surprising, no-clip default — a centered mono source stays unity, and a
// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride
// in -> empty out. Pure.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount);
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the
// source's last channel reads the last channel, so a mono source asked for channel 1 yields
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
//
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
//
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds {
double attackSeconds = 0.003; // tier-0 default
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060; // tier-0 default
};
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
struct PitchEnvSeconds {
bool enabled = false;
double attackSeconds = 0.0;
double decaySeconds = 0.0;
double peakSemitones = 0.0; // signed depth at the peak
};
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
struct ZonePlaySeconds {
PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
};
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0
// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default),
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
// pair never half-plays. `sampleRate` is the WAV's rate.
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
// picked single capture plays under the same default engine as a zone would. This function
// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {},
const ZonePlaySeconds& play = ZonePlaySeconds{});
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
//
// The performance map is the keymap the user authors IN the instrument: several bank
// samples zoned across the keyboard, each with a key range and a root note. It is a
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but
// OVERRIDABLE here — the override lives on the zone, never on `Sample`.
//
// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM.
// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build
// stitches the decoded frames + this map into a sampler_core Keymap.
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range,
// with an optional root-note override. rootOverride absent -> repitch from the bank
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
//
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins
// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame
// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here;
// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone.
struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive
int highNote = 127; // inclusive
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double.
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127.
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT
// preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start.
// Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7).
VelocityCurve velocityCurve = VelocityCurve::flat();
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
ZonePlaySeconds play;
};
// The instrument's performance map: an ordered list of zones. Order is authoritative for
// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's
// first-match Keymap::resolve — overlaps are neither rejected nor clamped, the earlier
// zone simply takes the contested keys; documented, deterministic).
struct PerformanceMap {
std::vector<PerformanceZone> zones;
bool empty() const { return zones.empty(); }
};
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix (issue 3a).
//
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
// control edit (ensureSampleZone). Loading a different sample used to change only the
// selection id, leaving the previous sample's full-range zone in the map — and since zone
// resolution is FIRST-MATCH in order, that stale zone shadowed every later one forever: the
// engine kept playing the old sample while the editor drew the new one's zone (matched by
// sampleId, order-blind). This function is called at every selection-change site so the zone
// the editor draws is the zone the engine plays.
//
// Rules (pure, order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not the full [0,127]) -> the map is Zone-view
// authorship; first-match order is load-bearing there — untouched, false. The Sample
// face never creates a narrow zone, so a narrow zone proves deliberate multi-zone intent.
// * else (every zone full-range — the map is purely Sample-face-shaped): keep only the
// first zone bound to `selectedId` (the selection's own params are not reset); drop
// the rest. A selection with no zone yet empties the map (the shell then plays the
// selection via the Tier-0 fast path with product defaults).
// Returns true iff the map changed (the caller republishes + reloads on true).
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
// from PerformanceZone (which names an id) — this is the id resolved against the live bank.
struct ResolvedZone {
std::string relativePath; // project-relative; the shell resolves + decodes it
int lowNote = 0;
int highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
};
// The result of resolving a performance map against the live bank blob. `zones` are the
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence
// for the whole map — and its id is reported here so the editor can flag/prune it).
struct ResolvedPerformance {
std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds;
};
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
//
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share
// foldZone, so the drift test is what keeps the shared fold honest.
ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map);
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained
// playback) — the bank-free mirror of resolvePerformance, sharing the same override-
// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref
// is dropped + reported (same stale-id shape as the bank path). Pure.
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order
// is preserved so first-match overlap resolution matches the map's authored order. A zone
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
// map). Empty zones in -> empty Keymap (silence).
struct DecodedZonePcm {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
// receive the WAV's real rate before use.
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded);
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (the existing policy: average all source
// channels). framesR EMPTY. A mono or stereo source both collapse.
// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered).
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels
// takes channels 0 and 1 (documented; the sampler's stereo image is
// the first two channels — no surround fold).
// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone
// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here.
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// --- Performance-map instance state (VST3 setState/getState) -----------------
//
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
// truncation/wrong-version by design (bounded reads, never throws across the host).
//
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
// ZONES PAYLOAD.
//
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
// key composition property: the zone-record extension is versioned inside the map blob, not on
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
// collide on a single version number.
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
// to each zone record after rootOverride:
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
// 8-byte LE loop.end (both two's-complement int64);
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
// 8-byte LE pitchEnv.peakSemitones (double);
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
// to an EMPTY map.
//
// These two functions serialize the ZONES only. Since S10 the instrument's full component
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
// below, the v3 format the processor actually reads/writes. serializePerformance/
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
// always tiny) can never collide with.
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
// full v5 play-params tail:
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
// A v1v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// transfer curve appended to each zone record after the v6 keyTrack field:
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
// A v1v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
// The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
//
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
// demoted opt-in overlay (the performance map). The component state carries both so a saved
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1.
//
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
// length + guid bytes; the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
// selection-id length + id bytes, then the CURRENT zones payload (identical to
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
// only v10 addition over v9 — the envelope grows a field,
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path —
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
//
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
// already got and then manually changed away from: on re-open the instance re-reads the pending
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
// older blob lifting to these plays byte-identically.
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
// so an older blob lifting to 1.0 plays exactly as it did.
double masterGainLinear = 1.0;
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
// for every bank sample this instance plays (see the SampleRefs block above). setState
// decodes straight from these; NO bridge/extension read is required for playback. A
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
// path once (then re-saves self-contained).
SampleRefs sampleRefs;
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
// seam). Persisted so the key is stable across sessions (records do not proliferate
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
// mints one on first publish, and RE-mints when the publish plan detects this state
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
std::string instanceGuid;
};
inline constexpr std::uint32_t kComponentStateVersion = 11;
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
// after the refs table). Mirrors the v10/v9/… series so the version branches in
// deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
// displayName bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
// lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
// v6 blob to the voice defaults {16, Poly, Retrigger}.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
// The full instance state parsed back from IBStream bytes (setState). Tolerant of
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
// above so already-saved instances restore cleanly.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Instance state (VST3 setState/getState) --------------------------------
//
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
// performance choice, held by the instrument, never written back to the bank). It is a
// single string id. serialize/deserialize keep the on-the-wire form explicit and
// versioned so a future Tier can extend it without breaking already-saved instances.
//
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
// length prefix is needed — the id runs to the end of the stream (the host tells us the
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
// by returning "" (no selection — under the S10 policy reversal an empty selection is
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
inline constexpr std::uint32_t kSelectionStateVersion = 1;
// The selected-sample id serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
// too-short, or empty -> "" (graceful no-selection).
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler
+27
View File
@@ -0,0 +1,27 @@
// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h).
#include "core/instrument/map/trigger_seam.h"
#include <algorithm>
namespace reasampler::instrument::map {
std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
std::int64_t startFrame) {
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - startFrame);
if (postStart <= 0 || lengthFraction <= 0.0) return 0;
return static_cast<std::int64_t>(lengthFraction * static_cast<double>(postStart) + 0.5);
}
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) {
if (playLength <= 0) return 0.0;
return static_cast<double>(fadeFrames) / static_cast<double>(playLength);
}
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) {
if (playLength <= 0) return 0;
return static_cast<std::int64_t>(fadeFraction * static_cast<double>(playLength) + 0.5);
}
} // namespace reasampler::instrument::map
+51
View File
@@ -0,0 +1,51 @@
// trigger_seam.h — PURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam.
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
//
// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations
// of Trigger fade lengths:
//
// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts
// that anchor directly to the voice's source-timeline read pointer.
//
// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played
// span, where the played span is:
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes;
// the engine stores frames so the voice advances correctly at the live rate.
//
// This module owns the one shared formula so the pack (frames->fractions) and unpack
// (fractions->frames) paths are provably consistent and unit-tested independently of the shell.
// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope.
//
// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that
// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range
// invariant (a drag can never produce a value a slider couldn't reach).
#pragma once
#include <cstdint>
namespace reasampler::instrument::map {
// The source-frame length of the Trigger played span:
// postStart = max(0, frameCount - startFrame)
// playLength = round(lengthFraction * postStart)
// `frameCount` is the total decoded sample length in source frames.
// `startFrame` is the effective start point (zone.startPoint, or 0 when absent).
// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span.
// Returns 0 when postStart == 0 or lengthFraction <= 0.
std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
std::int64_t startFrame);
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path).
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
// stays in envelope_edit, not here.
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path).
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
} // namespace reasampler::instrument::map
+158
View File
@@ -0,0 +1,158 @@
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
// capture_browser. No host types; only the shared Rect + BrowserLayout.
#include "core/instrument/ui/browser_scroll.h"
#include <algorithm>
#include <cctype>
namespace reasampler::instrument::ui {
namespace {
// The minimum thumb height so a very long bank still yields a grabbable thumb.
constexpr int kMinThumbHeight = 20;
char asciiLower(char c) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
} // namespace
int scrollContentHeight(const BrowserLayout& layout, int cardCount) {
if (cardCount <= 0) return 0;
const int columns = (std::max)(1, layout.columns);
const int rows = (cardCount + columns - 1) / columns; // ceil
return rows * kBrowserCardHeight;
}
int scrollMaxOffset(const BrowserLayout& layout, int cardCount) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height);
return (std::max)(0, content - gridH);
}
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) {
const int maxOff = scrollMaxOffset(layout, cardCount);
if (proposedOffset < 0) return 0;
if (proposedOffset > maxOff) return maxOff;
return proposedOffset;
}
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) {
VisibleRange vr;
if (cardCount <= 0) return vr;
const int columns = (std::max)(1, layout.columns);
const int gridH = (std::max)(0, layout.grid.height);
if (gridH <= 0 || kBrowserCardHeight <= 0) {
vr.first = 0;
vr.last = 0;
return vr;
}
if (offset < 0) offset = 0;
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
// partially scrolled off the top still draws (its lower part is visible).
const int firstRow = offset / kBrowserCardHeight;
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
// the exclusive end. A row straddling the bottom edge still draws.
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
int first = firstRow * columns;
int last = lastRow * columns;
if (first > cardCount) first = cardCount;
if (last > cardCount) last = cardCount;
if (last < first) last = first;
vr.first = first;
vr.last = last;
return vr;
}
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) {
Rect r = cardCellRect(layout, index);
if (r.right() <= r.x && r.bottom() <= r.y) return r; // empty (negative index) stays empty
return Rect::ltrb(r.x, r.y - offset, r.right(), r.bottom() - offset);
}
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar
const int maxOff = content - gridH;
if (offset < 0) offset = 0;
if (offset > maxOff) offset = maxOff;
const int trackRight = layout.grid.right();
const int trackLeft = trackRight - kScrollbarWidth;
const int trackTop = layout.grid.y;
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
// never taller than the track.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH);
// Thumb top proportional to the offset over the movable track span.
const int trackSpan = gridH - thumbH; // >= 0
int thumbTop = trackTop;
if (maxOff > 0 && trackSpan > 0) {
thumbTop = trackTop + static_cast<int>(
static_cast<long long>(offset) * trackSpan / maxOff);
}
return Rect::ltrb(trackLeft, thumbTop, trackRight, thumbTop + thumbH);
}
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset,
int dyPixels) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH);
const int trackSpan = gridH - thumbH;
if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset);
const int maxOff = content - gridH;
// A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry.
const long long deltaOffset =
(static_cast<long long>(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) /
trackSpan;
const long long proposed = static_cast<long long>(startOffset) + deltaOffset;
if (proposed < 0) return 0;
if (proposed > maxOff) return maxOff;
return static_cast<int>(proposed);
}
Rect searchBoxRect(int w) {
if (w <= 0) return Rect{};
return Rect::ltrb(0, 0, w, kSearchBoxHeight);
}
bool nameMatchesQuery(const std::string& name, const std::string& query) {
if (query.empty()) return true;
if (query.size() > name.size()) return false;
// Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine.
for (std::size_t i = 0; i + query.size() <= name.size(); ++i) {
bool match = true;
for (std::size_t j = 0; j < query.size(); ++j) {
if (asciiLower(name[i + j]) != asciiLower(query[j])) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query) {
std::vector<int> out;
out.reserve(names.size());
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
if (nameMatchesQuery(names[static_cast<std::size_t>(i)], query))
out.push_back(i);
}
return out;
}
} // namespace reasampler::instrument::ui
+107
View File
@@ -0,0 +1,107 @@
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
// into these functions.
//
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
// wheel tick or a thumb drag reaches every card; and
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
// then this search narrows within it) + the search-box rect the shell draws the field in.
//
// It holds NO card data and draws nothing — it knows only the browser layout (from
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
#pragma once
#include <string>
#include <vector>
#include "core/instrument/ui/capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect
namespace reasampler::instrument::ui {
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
inline constexpr int kScrollbarWidth = 10;
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
inline constexpr int kSearchBoxHeight = 22;
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
// ceiling for every offset the shell tracks.
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
// or above the first. Pure.
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
// yields an empty range (first==last==cardCount). Pure.
struct VisibleRange {
int first = 0; // first card index drawn (inclusive)
int last = 0; // one past the last card index drawn (exclusive)
};
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
// the visible fraction (grid height / content height) and positioned proportional to the
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
Rect searchBoxRect(int w);
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
// text). Pure — the single match predicate the shell's search narrow is built from.
bool nameMatchesQuery(const std::string& name, const std::string& query);
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
// and applies the bank filter FIRST, then feeds the surviving display names here (search
// narrows within the bank). Pure.
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query);
} // namespace reasampler::instrument::ui
@@ -0,0 +1,96 @@
// capture_browser.cpp — see capture_browser.h. Pure math; no host types.
#include "core/instrument/ui/capture_browser.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
// The left edge of tab i in a strip of the given x-origin and width divided into `count`
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly.
int tabEdge(int x, int width, int i, int count) {
return x + (i * width) / count;
}
} // namespace
BrowserLayout layoutBrowser(int w, int h) {
const int cw = std::max(0, w);
const int ch = std::max(0, h);
BrowserLayout out;
const int tabH = std::min(kBrowserTabHeight, ch);
out.tabStrip = Rect::ltrb(0, 0, cw, tabH);
out.grid = Rect::ltrb(0, tabH, cw, ch);
const int gridW = std::max(0, out.grid.width);
out.columns = std::max(1, gridW / kBrowserCardWidth);
return out;
}
Rect cardCellRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const int cols = std::max(1, layout.columns);
const int col = index % cols;
const int row = index / cols;
const int left = layout.grid.x + col * kBrowserCardWidth;
const int top = layout.grid.y + row * kBrowserCardHeight;
return Rect::ltrb(left, top, left + kBrowserCardWidth, top + kBrowserCardHeight);
}
Rect cardContentRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect cell = cardCellRect(layout, index);
return Rect::ltrb(cell.x + kBrowserCardGutter, cell.y + kBrowserCardGutter,
cell.right() - kBrowserCardGutter, cell.bottom() - kBrowserCardGutter);
}
Rect cardThumbnailRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect content = cardContentRect(layout, index);
const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height));
return Rect::ltrb(content.x, content.y, content.right(), content.y + thumbH);
}
Rect cardLabelRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect content = cardContentRect(layout, index);
const Rect thumb = cardThumbnailRect(layout, index);
return Rect::ltrb(content.x, thumb.bottom(), content.right(), content.bottom());
}
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) {
if (cardCount <= 0) return -1;
if (!contains(layout.grid, x, y)) return -1;
const int cols = std::max(1, layout.columns);
const int col = (x - layout.grid.x) / kBrowserCardWidth;
const int row = (y - layout.grid.y) / kBrowserCardHeight;
if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone)
const int index = row * cols + col;
if (index < 0 || index >= cardCount) return -1;
// Only a hit inside the card CONTENT counts — a click in the inter-card gutter misses.
if (!contains(cardContentRect(layout, index), x, y)) return -1;
return index;
}
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) {
if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{};
const Rect& strip = layout.tabStrip;
const int left = tabEdge(strip.x, std::max(0, strip.width), index, tabCount);
const int right = tabEdge(strip.x, std::max(0, strip.width), index + 1, tabCount);
return Rect::ltrb(left, strip.y, right, strip.bottom());
}
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) {
if (tabCount <= 0) return -1;
if (!contains(layout.tabStrip, x, y)) return -1;
for (int i = 0; i < tabCount; ++i) {
if (contains(filterTabRect(layout, tabCount, i), x, y)) return i;
}
return -1;
}
} // namespace reasampler::instrument::ui
+92
View File
@@ -0,0 +1,92 @@
// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3,
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is
// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail +
// name + root/key badge and routes clicks into these functions.
//
// The browser replaces the old text item-list (the named anti-pattern). It lays out N
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab
// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This
// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the
// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this
// module only "where does card i draw" / "what did the user click".
//
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping
// scroll out keeps this module the stable card/tab geometry S12 builds on.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom).
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::instrument::ui {
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list.
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter)
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter)
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
struct BrowserLayout {
Rect tabStrip; // top: the bank-filter tabs
Rect grid; // below the tabs: where the capture cards tile
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width
};
// Divide a (w x h) browser area into its regions and compute the column count. Pure: same
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a
// browser narrower than one card still lays out a single column. A zero/negative size
// yields empty rects + columns==1.
BrowserLayout layoutBrowser(int w, int h);
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect
// insets it to the drawable card. Rows past the visible grid are still computed (the shell
// clips at paint time). A negative index yields an empty rect. Pure.
Rect cardCellRect(const BrowserLayout& layout, int index);
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure.
Rect cardContentRect(const BrowserLayout& layout, int index);
// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top
// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the
// name + badge go in the remaining strip below. Pure.
Rect cardThumbnailRect(const BrowserLayout& layout, int index);
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band.
// The shell draws the display name + root/key badge here. Pure.
Rect cardLabelRect(const BrowserLayout& layout, int index);
// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y);
// --- Bank-filter tabs --------------------------------------------------------
//
// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch):
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount ==
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell
// supplies the labels and tracks which tab is active. A tab click narrows the card list to
// that bank (the shell filters its SampleChoice list before laying out cards).
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
// empty rect. Pure.
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the
// tab strip. Pure.
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
} // namespace reasampler::instrument::ui
+41
View File
@@ -0,0 +1,41 @@
// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes.
#include "core/instrument/ui/curve_popup.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
int clampDim(int want, int lo, int hi, int windowDim) {
const int clamped = (std::max)(lo, (std::min)(hi, want));
return (std::min)(clamped, (std::max)(0, windowDim));
}
} // namespace
CurvePopupLayout computeCurvePopup(int w, int h) {
CurvePopupLayout out;
const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w);
const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h);
const int left = (w - sheetW) / 2;
const int top = (h - sheetH) / 2;
out.sheet = Rect::ltrb(left, top, left + sheetW, top + sheetH);
const int titleBottom = out.sheet.y + kCurvePopupTitleH;
const int closeTop = out.sheet.y + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2;
out.close = Rect::ltrb(out.sheet.right() - kCurvePopupPad - kCurvePopupCloseSize, closeTop,
out.sheet.right() - kCurvePopupPad, closeTop + kCurvePopupCloseSize);
out.title = Rect::ltrb(out.sheet.x + kCurvePopupPad, out.sheet.y,
out.close.x - kCurvePopupPad, titleBottom);
out.curveBox = Rect::ltrb(out.sheet.x + kCurvePopupPad, titleBottom + 2,
out.sheet.right() - kCurvePopupPad,
out.sheet.bottom() - kCurvePopupPad);
return out;
}
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) {
return !contains(layout.sheet, x, y);
}
} // namespace reasampler::instrument::ui
+48
View File
@@ -0,0 +1,48 @@
// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup
// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested
// at the clamps outside the DAW, while the editor shell draws the wash + sheet through the
// L1 kit and routes clicks (close / curve box / outside-sheet dismiss) via these rects.
//
// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED
// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's
// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520),
// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP"
// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the
// remainder. The curve box rect here is the BORDER rect — the shell derives the mapping box
// through its ONE curveBoxFromRect formula (the landed inset grammar), so the popup editor
// and the Zone-panel inline editor share coordinates by construction.
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
namespace reasampler::instrument::ui {
// Fixed popup metrics (spec r11), exposed so the shell and tests agree.
inline constexpr int kCurvePopupMinW = 360;
inline constexpr int kCurvePopupMaxW = 520;
inline constexpr int kCurvePopupMinH = 260;
inline constexpr int kCurvePopupMaxH = 380;
inline constexpr int kCurvePopupTitleH = 22;
inline constexpr int kCurvePopupCloseSize = 18;
inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
struct CurvePopupLayout {
Rect sheet; // the bg/panel sheet, centered in the window
Rect title; // the caption text rect (left part of the title row)
Rect close; // the 18x18 Close (x) button, right-anchored in the title row
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect)
};
// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate
// window never yields an overhanging sheet — centered; title row + close button at the top;
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
CurvePopupLayout computeCurvePopup(int w, int h);
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test.
// The shell additionally gates on "no drag in flight" (spec). Pure.
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui
+162
View File
@@ -0,0 +1,162 @@
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
#include "core/instrument/ui/editor_geometry.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
// Spike editor layout constants. These are the editor's fixed metrics; the real
// editor (S4/S5) will parameterize as its content demands.
constexpr int kTitleBarHeight = 28;
constexpr int kButtonMargin = 10;
constexpr int kButtonWidth = 120;
constexpr int kButtonHeight = 24;
} // namespace
// contains() now lives with the shared ui::Rect (core/ui/rect.h) — same half-open
// semantics, re-exported through the header's using-declaration.
EditorLayout layoutEditor(int w, int h) {
// Clamp the surface to non-negative extents so a degenerate view can't produce
// inverted rects.
const int cw = std::max(0, w);
const int ch = std::max(0, h);
EditorLayout out;
// Title bar spans the top, clamped so it never exceeds the client height.
const int titleH = std::min(kTitleBarHeight, ch);
out.titleBar = Rect::ltrb(0, 0, cw, titleH);
// Canvas is everything below the title bar.
out.canvas = Rect::ltrb(0, titleH, cw, ch);
// Button sits at the top-left of the canvas, inset by a margin, and is clamped to
// fit inside the canvas so it never overhangs on a small view.
const int bx = out.canvas.x + kButtonMargin;
const int by = out.canvas.y + kButtonMargin;
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom());
out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom));
return out;
}
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
if (contains(layout.button, x, y)) return HitTarget::kButton;
return HitTarget::kNone;
}
Rect sampleRowRect(const EditorLayout& layout, int index) {
if (index < 0) return Rect{};
const int top = layout.canvas.y + index * kSampleRowHeight;
return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight);
}
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
if (rowCount <= 0) return -1;
// Must be within the canvas horizontally and at/below its top.
if (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
if (y < layout.canvas.y) return -1;
// Clip at the canvas bottom: clicks in the canvas's dead-zone below the last
// visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom().
if (y >= layout.canvas.bottom()) return -1;
const int index = (y - layout.canvas.y) / kSampleRowHeight;
if (index < 0 || index >= rowCount) return -1;
// Guard the bottom edge: a click below the last row's bottom is outside.
const Rect r = sampleRowRect(layout, index);
if (y >= r.bottom()) return -1;
return index;
}
// --- Keymap editor -----------------------------------------------------------
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
KeymapEditorLayout out;
out.base = layoutEditor(w, h);
const Rect& canvas = out.base.canvas;
// Split the canvas vertically: the left column is the bank-sample list, the right
// column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so
// the split point never crosses the canvas edges.
const int canvasW = std::max(0, canvas.width);
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
const int splitX = std::max(canvas.x, canvas.right() - splitW);
out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
// "Add Zone" button spans the top of the zone panel, clamped to its height.
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
out.addZoneButton =
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
out.zonePanel.y + addH);
// Zone rows stack below the button.
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
out.zonePanel.right(), out.zonePanel.bottom());
return out;
}
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
if (index < 0) return Rect{};
const int top = layout.sampleList.y + index * kSampleRowHeight;
return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(),
top + kSampleRowHeight);
}
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
if (rowCount <= 0) return -1;
const Rect& list = layout.sampleList;
if (x < list.x || x >= list.right()) return -1;
if (y < list.y || y >= list.bottom()) return -1;
const int index = (y - list.y) / kSampleRowHeight;
if (index < 0 || index >= rowCount) return -1;
const Rect r = keymapSampleRowRect(layout, index);
if (y >= r.bottom()) return -1;
return index;
}
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
if (index < 0) return Rect{};
const int top = layout.zoneRowArea.y + index * kZoneRowHeight;
return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(),
top + kZoneRowHeight);
}
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
if (zoneCount <= 0) return ZoneHit{};
const Rect& area = layout.zoneRowArea;
if (x < area.x || x >= area.right()) return ZoneHit{};
if (y < area.y || y >= area.bottom()) return ZoneHit{};
const int index = (y - area.y) / kZoneRowHeight;
if (index < 0 || index >= zoneCount) return ZoneHit{};
const Rect row = zoneRowRect(layout, index);
if (y >= row.bottom()) return ZoneHit{};
// Seven mini-buttons pinned to the right edge, right-to-left:
// delete, root+, root-, high+, high-, low+, low-
// Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select").
// The fields laid out LEFT-TO-RIGHT in slot order 0..6.
const ZoneField fields[7] = {
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
ZoneField::kDelete,
};
const int slots = 7;
const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth;
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
return ZoneHit{index, fields[slot]};
}
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
return contains(layout.addZoneButton, x, y);
}
} // namespace reasampler::instrument::ui
+142
View File
@@ -0,0 +1,142 @@
// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
//
// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing
// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so
// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch /
// tab_strip split their layout math out of the panel shell.
//
// The spike's editor is deliberately trivial (a title band + one clickable button),
// enough to PROVE the host->draw/hit-test event routing works. As the real editor
// (S4/S5) grows, its layout math accretes here, not in the shell.
#pragma once
#include "core/ui/rect.h"
namespace reasampler::instrument::ui {
// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former
// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage,
// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same
// half-open convention). Aliased here so every instrument-ui call site keeps its
// established `Rect` / `contains` spelling.
using Rect = ::reasampler::ui::Rect;
using ::reasampler::ui::contains;
// The regions the spike editor draws, derived from the current view size. All are
// clamped to the client area so a degenerate (too-small) view never yields a region
// that spills outside the surface.
struct EditorLayout {
Rect titleBar; // top band: the plugin name + a live-state readout
Rect button; // a single clickable button (proves hit-test routing)
Rect canvas; // the remaining surface below the title bar
};
// Divide a (w x h) client area into the spike editor's regions. Pure: the same
// inputs always yield the same layout. Guards tiny sizes — every returned rect stays
// within [0,w] x [0,h], and the button never overhangs the canvas.
EditorLayout layoutEditor(int w, int h);
// The editor's hit-test targets. kNone means the point landed on inert surface.
enum class HitTarget {
kNone,
kButton,
};
// Classify a click at (x, y) against a layout. The button wins only when the point is
// inside the button rect; everything else (including the title bar and empty canvas)
// is kNone in the spike.
HitTarget hitTest(const EditorLayout& layout, int x, int y);
// --- Sample-selection list (S4 Tier-0 UI) -----------------------------------
//
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows
// below the title bar; clicking a row selects that sample. This is the pure geometry:
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
// shell draws the names and routes the click into the processor's reloadInstrument.
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
inline constexpr int kSampleRowHeight = 22;
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside
// the layout's canvas. Rows beyond what the canvas can show are still computed (the
// shell clips at paint time); a negative index yields an empty rect. Pure.
Rect sampleRowRect(const EditorLayout& layout, int index);
// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click
// outside the list (above the first row, past the last, or on the title bar). Pure.
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
// --- Keymap editor (S5 Tier-1 UI) -------------------------------------------
//
// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel;
// each zone row carries small nudge/delete controls so the user can set the range and
// root note without a text field (LICE has no native numeric entry). All rectangle math
// is here so the shell only draws + routes — the mirror of the sample-list split above.
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
inline constexpr int kZoneRowHeight = 24;
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height
// The keymap editor's regions, derived from the (w x h) client area. All clamp to the
// canvas so a degenerate view yields in-bounds rects.
struct KeymapEditorLayout {
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half)
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here)
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows
Rect addZoneButton; // top of the zone panel
Rect zoneRowArea; // below addZoneButton: where zone rows stack
};
KeymapEditorLayout layoutKeymapEditor(int w, int h);
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
// sampleList. Negative index -> empty. Pure.
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure.
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative
// index -> empty. Pure.
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
// A zone row's interactive fields. The row is a horizontal strip: a label on the left,
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+,
// root-, root+, delete). kZoneNone means the click missed a control
// (e.g. on the label) — the shell may still treat that as "select this zone".
enum class ZoneField {
kZoneNone,
kLowDown,
kLowUp,
kHighDown,
kHighUp,
kRootDown,
kRootUp,
kDelete,
};
// The result of hit-testing a click against the zone rows: which zone row (or -1) and
// which field within it. A click on the "Add Zone" button is reported separately by
// addZoneHitTest — this covers only the zone rows.
struct ZoneHit {
int zoneIndex = -1;
ZoneField field = ZoneField::kZoneNone;
};
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete);
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
// True if (x, y) lands on the "Add Zone" button. Pure.
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui
+86
View File
@@ -0,0 +1,86 @@
// embed_strip.cpp — see embed_strip.h. Pure math; no host types.
#include "core/instrument/ui/embed_strip.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
int clampNote(int n) {
if (n < 0) return 0;
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
return n;
}
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right.
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1),
// which tiles adjacent zones without a seam.
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
return bandLeft + (keyEdge * bandWidth) / kEmbedKeyCount;
}
} // namespace
EmbedLayout layoutEmbed(int w, int h) {
const int cw = std::max(0, w);
const int ch = std::max(0, h);
EmbedLayout out;
// The level band takes a fixed height at the bottom, but never so much that the keymap
// above it falls below its minimum (or that the band exceeds the area). On a very short
// area the band yields to the keymap entirely.
int bandH = std::min(kEmbedLevelBandHeight, ch);
if (ch - bandH < kEmbedKeymapMinHeight) {
bandH = std::max(0, ch - kEmbedKeymapMinHeight);
}
const int keymapBottom = ch - bandH;
out.keymap = Rect::ltrb(0, 0, cw, keymapBottom);
out.levelBand = Rect::ltrb(0, keymapBottom, cw, ch);
return out;
}
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) {
const Rect& band = layout.keymap;
const int bandWidth = std::max(0, band.width);
int lo = clampNote(lowNote);
int hi = clampNote(highNote);
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
const int leftX = keyEdgeToX(band.x, bandWidth, lo);
const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1);
return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom());
}
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
int y) {
if (zoneCount <= 0 || zones == nullptr) return -1;
if (!contains(layout.keymap, x, y)) return -1;
// First covering zone in draw order wins (first-match, mirroring the core's resolve).
for (int i = 0; i < zoneCount; ++i) {
const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote);
if (contains(r, x, y)) return i;
}
return -1; // on the band but on an uncovered key
}
Rect levelFillRect(const EmbedLayout& layout, double level) {
const Rect& band = layout.levelBand;
if (band.width <= 0 || band.height <= 0) return Rect{};
double l = level;
if (l < 0.0) l = 0.0;
if (l > 1.0) l = 1.0;
const int fillW = static_cast<int>(l * band.width);
if (fillW <= 0) return Rect{};
return Rect::ltrb(band.x, band.y, band.x + fillW, band.bottom());
}
} // namespace reasampler::instrument::ui
+76
View File
@@ -0,0 +1,76 @@
// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3,
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives
// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp)
// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions.
//
// The strip is a single compact band REAPER draws inline in the track/mixer control panel
// (context TCP or MCP) via the Cockos embedded-UI surface. It shows:
// * the zone layout — each performance zone as a horizontal segment across the keyboard
// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and
// * a thin level band at the bottom — a 0..1 activity indicator the shell fills.
// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a
// click maps to the zone whose key range covers that point, or -1.
//
// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share
// one geometry idiom), so this header depends on editor_geometry.h rather than redefining
// a second rectangle type.
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's
// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle.
inline constexpr int kEmbedKeyCount = 128;
// Fixed metrics for the strip, exposed so the shell and tests agree.
inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px)
inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller
// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal
// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM —
// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are
// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed
// zone never yields an out-of-strip rect.
struct EmbedZone {
int lowNote = 0;
int highNote = 127;
};
// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to
// the area so a degenerate (tiny) size never yields a region spilling outside the surface.
struct EmbedLayout {
Rect keymap; // top: the zone-segment band (the compact keymap)
Rect levelBand; // bottom: the thin level/activity indicator
};
// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout.
// The level band takes a fixed height at the bottom (clamped so it never exceeds the area
// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or
// negative size yields empty rects (no inversion).
EmbedLayout layoutEmbed(int w, int h);
// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote]
// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect
// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g.
// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low
// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect,
// never an inverted one. Pure.
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click
// outside the keymap band or on a key not covered by any zone. When zones overlap on a
// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match
// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure.
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
int y);
// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the
// returned rect is the left sub-rectangle of levelBand whose width is level * band width
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
Rect levelFillRect(const EmbedLayout& layout, double level);
} // namespace reasampler::instrument::ui
+180
View File
@@ -0,0 +1,180 @@
// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types.
#include "core/instrument/ui/envelope_edit.h"
#include <algorithm>
#include <cstdlib> // std::abs
namespace reasampler::instrument::ui {
namespace {
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
double secondsPerPixel(const Rect& area, double totalSeconds) {
const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
return totalSeconds / static_cast<double>(w);
}
// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's
// param-domain gatePxPerSecond(area) scale — sample-length-free, matching
// envelope_overlay::gatePolyline exactly so the dragged handle tracks the cursor 1:1 (each
// node's x is affine in its own segment duration with slope gatePxPerSecond). Zero when the
// area is degenerate.
double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0;
}
// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
double levelPerPixel(const Rect& area) {
const int h = std::max(0, area.height);
if (h <= 1) return 0.0;
return 1.0 / static_cast<double>(h - 1);
}
// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors).
bool isDraggable(EnvNode n) {
switch (n) {
case EnvNode::Origin:
case EnvNode::ReleaseStart:
return false;
default:
return true;
}
}
// True when the node belongs to the envelope's active mode. Guards the degenerate cross-mode
// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a
// zero-height Trigger-mode grab of it must not write releaseSeconds (and vice versa for Gate
// nodes vs Trigger fields). Applied by BOTH the hit-test and the drag resolver so they agree.
bool nodeInMode(EnvNode n, EnvMode m) {
switch (n) {
case EnvNode::AttackEnd:
case EnvNode::HoldEnd:
case EnvNode::DecayEnd:
case EnvNode::ReleaseEnd:
return m == EnvMode::Gate;
case EnvNode::FadeInEnd:
case EnvNode::FadeOutStart:
case EnvNode::LengthEnd:
return m == EnvMode::Trigger;
case EnvNode::Origin:
case EnvNode::ReleaseStart:
return false; // never draggable in any mode (isDraggable filters these anyway)
}
return false;
}
} // namespace
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance —
// the square grab box); ties break to the earlier draw-order node (FA2). Gate nodes never
// coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only
// matters for Trigger's zero-fade-out coincidence: FadeOutStart overlays LengthEnd, WINS the
// tie, and can be dragged inward from the right edge. The mode filter keeps the degenerate
// baseline's ReleaseEnd vertex from registering as a grabbable node in Trigger mode.
NodeHit best;
int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties
bestDist = dist;
best = NodeHit{true, v.node};
}
}
return best;
}
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels) {
AmpEnvelope out = grabEnv;
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
const double secPerPx = secondsPerPixel(area, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx;
// Gate time nodes use the schematic's PARAM-DOMAIN px->seconds scale (FA2) — the reciprocal
// of the overlay's gatePxPerSecond, sample-length-free — so the dragged handle tracks the
// cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty, so gateDSec is
// well-defined past the degenerate guard above.
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
switch (node) {
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower
// level, so subtract the level delta).
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
break;
}
case EnvNode::ReleaseEnd:
out.releaseSeconds =
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
// span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the
// two fade nodes never cross (each clamps against the other), and length in [0, max].
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span.
// TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES
// (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions:
// pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate)
// unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs)
// See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula.
case EnvNode::FadeInEnd: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeInFraction,
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::FadeOutStart: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
// FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx)
// lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta.
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeOutFraction,
1.0 - std::max(0.0, grabEnv.fadeInFraction));
out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::LengthEnd: {
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
break;
}
case EnvNode::Origin:
case EnvNode::ReleaseStart:
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
}
return out;
}
} // namespace reasampler::instrument::ui
+108
View File
@@ -0,0 +1,108 @@
// envelope_edit.h — PURE node hit-test + pixel-delta→clamped-param inverse map for the S-VIEW-3
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here,
// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp)
// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back
// through here, and commits the resulting params to the zone through the same off-audio-thread
// path a slider edit uses.
//
// TWO SURFACES, ONE MODEL. envelope_overlay owns the params→polyline FORWARD map (draw); this
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node
// drag and a slider edit are two views on one source of truth and can never diverge.
//
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't:
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0.
// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels
// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider
// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are
// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider,
// so the two surfaces share one clamp by construction.
//
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart,
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the
// drawing-only ReleaseStart vertex are NOT draggable.
//
// GATE DRAG SCALE (FA2). Gate time nodes convert px->seconds via the reciprocal of the
// schematic's PARAM-DOMAIN scale (envelope_overlay's gatePxPerSecond — sample-length-free), so
// a dragged handle tracks the cursor exactly 1:1 for stages within the schematic domain (each
// node's x is affine in its own segment duration). Trigger nodes keep the full-canvas
// PCM-aligned scale. Both match the forward map in envelope_overlay. A node is only editable in
// its OWN mode: Gate nodes ignore drags while the envelope is in Trigger mode and vice versa
// (guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds).
//
// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from
// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX /
// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
namespace reasampler::instrument::ui {
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
inline constexpr int kNodeGrabRadius = 6;
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
struct EnvClampBounds {
double maxAttackSeconds = 4.0; // upper bound of the attack slider
double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 4.0;
// Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell
// that caps a fade below the full span (e.g. 0.5) shares that cap with its slider.
double maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0;
double maxLengthFraction = 1.0;
// sustainLevel is always [0,1] — no shell knob needed, kept implicit.
};
// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles).
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable),
// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex
// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev
// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes
// never coincide (the forward map enforces kGateNodeSepPx separation, so every Gate handle is
// individually grabbable in every state); the tie-break matters only for Trigger's zero-fade-out
// coincidence, where FadeOutStart overlays LengthEnd, wins the tie, and can be dragged inward
// from the right edge. Pure.
struct NodeHit {
bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
};
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` —
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe:
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX),
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time,
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack).
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1].
// dyPixels is IGNORED for every time-only node.
// * Non-draggable node (Origin / ReleaseStart), a node from the OTHER mode (a Gate node while
// grabEnv.mode is Trigger, or vice versa), a zero-width/zero-height area, or
// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion).
// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure
// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution).
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
} // namespace reasampler::instrument::ui
+175
View File
@@ -0,0 +1,175 @@
// envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types.
#include "core/instrument/ui/envelope_overlay.h"
#include "core/util/clamp01.h"
#include <algorithm>
namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return area.x;
if (t < 0.0) t = 0.0;
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the
// last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
double px = (t / totalSeconds) * static_cast<double>(w);
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
return area.x + static_cast<int>(px + 0.5);
}
int gateTimedWidth(const Rect& area) {
const int w = std::max(0, area.width);
if (w <= 0) return 0;
const int sustainPx =
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
return std::max(1, w - sustainPx);
}
double gatePxPerSecond(const Rect& area) {
const int timedW = gateTimedWidth(area);
if (timedW <= 0) return 0.0;
// Usable width = timed region minus the four per-segment separation bases and the last
// in-bounds column, floored at 1 px so the scale never degenerates; the domain is the four
// stages end-to-end at their schematic maxima (param-domain scale — sample-length-free).
const double usable =
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds);
}
int levelToY(const Rect& area, double level) {
const int h = std::max(0, area.height);
if (h <= 0) return area.y;
if (level < 0.0) level = 0.0;
if (level > 1.0) level = 1.0;
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The
// range spans (h-1) pixels so both endpoints land ON a drawable row.
const int span = h - 1;
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
return area.y + static_cast<int>(dy);
}
namespace {
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
EnvVertex v;
v.node = node;
v.x = timeToX(area, totalSeconds, t);
v.y = levelToY(area, level);
v.level = level;
return v;
}
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space —
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in
// DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a
// huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge).
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0;
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
EnvVertex v;
v.node = node;
v.x = area.x + static_cast<int>(px + 0.5);
v.y = levelToY(area, level);
v.level = level;
return v;
}
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
// Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively).
const double a = std::max(0.0, env.attackSeconds);
const double h = std::max(0.0, env.holdSeconds);
const double d = std::max(0.0, env.decaySeconds);
const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel);
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved
// sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment
// gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually
// grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain
// plateau is the fixed reserve between DecayEnd and ReleaseStart.
const int W = std::max(1, area.width);
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx);
const double pps = gatePxPerSecond(area);
double xAttack = sep + a * pps; // AttackEnd
double xHold = xAttack + sep + h * pps; // HoldEnd
double xDecay = xHold + sep + d * pps; // DecayEnd (sustain node)
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
// Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT
// preserving the minimum gaps, so trailing nodes stay individually separated instead of
// piling on the last column. The re-floor pass only bites when the canvas is too narrow to
// hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation).
const double xMax = static_cast<double>(W - 1);
if (xRelease > xMax) {
xRelease = xMax;
xPlateau = std::min(xPlateau, xRelease - sep);
xDecay = std::min(xDecay, xPlateau - sustainPx);
xHold = std::min(xHold, xDecay - sep);
xAttack = std::min(xAttack, xHold - sep);
xAttack = std::max(xAttack, sep);
xHold = std::max(xHold, xAttack + sep);
xDecay = std::max(xDecay, xHold + sep);
xPlateau = std::max(xPlateau, xDecay + sustainPx);
xRelease = std::max(xRelease, xPlateau + sep);
}
std::vector<EnvVertex> pts;
pts.reserve(6);
pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0));
pts.push_back(gateVtx(EnvNode::AttackEnd, area, xAttack, 1.0));
pts.push_back(gateVtx(EnvNode::HoldEnd, area, xHold, 1.0));
pts.push_back(gateVtx(EnvNode::DecayEnd, area, xDecay, sus)); // sustain node
pts.push_back(gateVtx(EnvNode::ReleaseStart, area, xPlateau, sus)); // plateau end
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0));
return pts;
}
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
// The played span is lengthFraction of the whole sample; fades are fractions OF that span.
const double len = clamp01(env.lengthFraction);
double fadeIn = clamp01(env.fadeInFraction);
double fadeOut = clamp01(env.fadeOutFraction);
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
const double playSeconds = len * totalSeconds;
const double tFadeInEnd = fadeIn * playSeconds;
const double tFadeOutStart = playSeconds - fadeOut * playSeconds; // where fade-out begins
std::vector<EnvVertex> pts;
pts.reserve(4);
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
pts.push_back(vtx(EnvNode::FadeInEnd, area, totalSeconds, tFadeInEnd, 1.0));
pts.push_back(vtx(EnvNode::FadeOutStart, area, totalSeconds, tFadeOutStart, 1.0)); // unity end
pts.push_back(vtx(EnvNode::LengthEnd, area, totalSeconds, playSeconds, 0.0)); // playEnd
return pts;
}
} // namespace
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) {
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line.
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
}
// Gate is a param-domain schematic — totalSeconds only gates the degenerate branch above
// (no loaded duration -> baseline); Trigger is PCM-aligned and consumes it.
return env.mode == EnvMode::Gate ? gatePolyline(env, area)
: triggerPolyline(env, area, totalSeconds);
}
} // namespace reasampler::instrument::ui
+229
View File
@@ -0,0 +1,229 @@
// envelope_overlay.h — PURE amp-envelope → polyline geometry for the S-VIEW-3 Sample-view
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue
// and draws the node handles (via envelope_edit's hit-test).
//
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform (Simpler / Phase-Plant
// grammar):
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
// sustain plateau, release sustain->0. Since there is no held note-off to draw
// against, Gate is a BOUNDED SCHEMATIC (FA2): a fixed fraction of the canvas
// width (kGateSustainDisplayFraction) is RESERVED for the sustain plateau, and
// the remaining "timed" width carries A/H/D AND the release at the PARAM-DOMAIN
// scale — the timed width represents 4 x kGateStageMaxSeconds (the four stage
// sliders end-to-end at their maxima), NOT the sample's duration, so the layout
// is identical for a 0.3s and a 10s capture. Each segment additionally gets a
// kGateNodeSepPx pixel base, so consecutive nodes NEVER coincide: every Gate
// node is individually grabbable at ANY param values, including the tier-0
// defaults (hold 0 / decay 0). A -> (H) -> D -> S-plateau -> R all render INSIDE
// the canvas and the release is a visible, draggable segment.
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
// to playEnd (= lengthFraction of the post-start span). Trigger keeps the
// waveform's exact time base so the shape lines up with the PCM under it.
// The horizontal axis is TIME (Gate: schematic, see above; Trigger: wall-clock across the rect);
// the vertical axis is LEVEL (0 at rect bottom, 1 at rect top).
//
// BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas:
// x in [area.x, area.right()-1], y in [area.y, area.bottom()-1] (half-open rect convention).
// No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no
// longer needed (and never fires) in the shell.
//
// FA2 CONTRACT CHANGE — WAVE B SHELL AUTHOR, READ THIS:
// * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart
// remain the only non-draggable anchors).
// * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when
// v.x >= waveArea.right()" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd
// (Trigger, at full length / zero fade-out) now land at area.right()-1 and MUST get handles.
// * Gate's x-axis is SCHEMATIC, not PCM-aligned: the timed region is scaled to the param
// domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every
// segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the
// waveform under it — do not label it as if it did. Trigger's x-axis IS still PCM-aligned.
// * Gate nodes never coincide (min-separation, above), so every Gate handle is individually
// grabbable in every state. nodeAtPoint (envelope_edit) resolves to the NEAREST node within
// the grab radius with a draw-order tie-break; the tie-break only matters for the one
// remaining coincidence, Trigger's zero-fade-out (FadeOutStart overlays LengthEnd at the
// right edge and wins the tie, so the fade can be dragged open from zero).
//
// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on
// sample_map / sampler_core (which would drag bank_book / wav_trim in). The shell reads the
// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here.
// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no-
// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input
// is the total sample duration in seconds, which the shell resolves once from the live rate and
// the frame count and passes in — this module never sees a sample rate.
//
// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
namespace reasampler::instrument::ui {
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
enum class EnvMode { Gate, Trigger };
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the
// forward map (draw) and inverse map (edit) name the same nodes.
//
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart
// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level);
// ReleaseStart is a drawing-only plateau-end vertex (the schematic note-off);
// release is edited by dragging ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out
// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal.
enum class EnvNode {
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor)
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds,
// Y sets sustainLevel)
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) —
// a DRAWING vertex only, not a draggable handle (release is edited at
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of
// DecayEnd — the schematic note-off — Y = sustain level)
ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds
FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction
FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) —
// X sets fadeOutFraction
LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction
};
// The amp-envelope params the overlay draws — the small view struct the shell packs from the
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include).
//
// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1.
// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required.
//
// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span;
// lengthFraction is the played span as a fraction of the
// post-start sample length (matching TriggerParams).
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES:
// fadeInFrames (int64_t) — 0->1 ramp length in source frames
// fadeOutFrames (int64_t) — 1->0 ramp length in source frames
// AmpEnvelope stores them as FRACTIONS of the played span:
// fadeInFraction = fadeInFrames / playLengthFrames
// fadeOutFraction = fadeOutFrames / playLengthFrames
// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)).
// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a
// converter on BOTH directions:
// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate)
// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs)
// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion.
//
// Unused fields for the active mode are ignored.
struct AmpEnvelope {
EnvMode mode = EnvMode::Gate;
// Gate (AHDSR), seconds + a dimensionless sustain level.
double attackSeconds = 0.003;
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060;
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length.
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy.
double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams)
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above)
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above)
};
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin.
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
struct EnvVertex {
EnvNode node = EnvNode::Origin;
int x = 0; // pixel x inside the overlay rect
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0)
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection)
bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level;
}
};
// The fraction of the canvas width RESERVED for the Gate sustain-plateau display (FA2). The
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining
// width is the "timed" region A/H/D/R map onto at the schematic param-domain scale. One
// constant shared by the forward map (here) and the inverse map (envelope_edit).
inline constexpr double kGateSustainDisplayFraction = 0.15;
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen
// larger than envelope_edit's kNodeGrabRadius (6) so a click dead-on a node can never tie with
// its neighbour. Shared by the forward map and the drag inverse.
inline constexpr int kGateNodeSepPx = 8;
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its
// slider max lands exactly at the canvas edge. Drag safety does NOT depend on this constant
// (param clamps are caller-supplied in envelope_edit); only layout does.
inline constexpr double kGateStageMaxSeconds = 2.0;
// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve,
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width,
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a
// zero/negative-width area; otherwise > 0 (the usable width floors at 1 px). The ONE px<->sec
// scale shared by the forward map (gatePolyline) and the drag inverse (envelope_edit), so a
// dragged handle tracks the cursor 1:1.
double gatePxPerSecond(const Rect& area);
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds`
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive);
// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads
// left-to-right in draw order, Origin first.
//
// TIME BASE (FA2).
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED
// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release
// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment
// carrying a kGateNodeSepPx base so consecutive nodes never coincide — plus a FIXED sustain
// plateau of (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic
// note-off). Stages beyond the schematic domain (a stored stage > kGateStageMaxSeconds)
// compress from the RIGHT preserving the minimum gaps, so trailing nodes stay individually
// separated instead of piling on the last column; only a canvas too narrow to hold the
// minimum gaps at all sacrifices separation (in-bounds wins).
// * Trigger: the waveform's exact time base (PCM-aligned). The played span is
// lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past
// the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge).
//
// BOUNDS: every vertex is inside the canvas — x in [area.x, area.right()-1], y in
// [area.y, area.bottom()-1]. Nothing maps past area.right() (the pre-FA2 release tail is gone). A
// degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline
// [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same
// polyline.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds);
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds ->
// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins
// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test
// (envelope_edit) use, so the drawn handle and its grab region agree.
int timeToX(const Rect& area, double totalSeconds, double t);
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is
// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline
// and the node hit-test share.
int levelToY(const Rect& area, double level);
} // namespace reasampler::instrument::ui
+149
View File
@@ -0,0 +1,149 @@
// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types.
#include "core/instrument/ui/keyboard_strip.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
int clampNote(int n) {
if (n < 0) return 0;
if (n > kStripKeyCount - 1) return kStripKeyCount - 1;
return n;
}
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer
// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling
// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX).
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
return bandLeft + (keyEdge * bandWidth) / kStripKeyCount;
}
} // namespace
StripLayout layoutStrip(int w, int h) {
const int cw = std::max(0, w);
const int ch = std::max(0, h);
StripLayout out;
out.keys = Rect::ltrb(0, 0, cw, ch);
return out;
}
int keyLeftX(const StripLayout& layout, int note) {
const Rect& band = layout.keys;
const int bandWidth = std::max(0, band.width);
// note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to
// get a key's right edge, and 128 maps to the band right.
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
return keyEdgeToX(band.x, bandWidth, edge);
}
Rect keyRect(const StripLayout& layout, int note) {
const int n = clampNote(note);
const int leftX = keyLeftX(layout, n);
const int rightX = keyLeftX(layout, n + 1);
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
}
Rect rootMarkerRect(const StripLayout& layout, int rootNote) {
return keyRect(layout, rootNote);
}
int keyAtPoint(const StripLayout& layout, int x, int y) {
const Rect& band = layout.keys;
if (!contains(band, x, y)) return -1;
const int bandWidth = std::max(0, band.width);
if (bandWidth <= 0) return -1;
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide
// the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127).
const int offset = x - band.x;
int note = (offset * kStripKeyCount) / bandWidth;
return clampNote(note);
}
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
int lo = clampNote(lowNote);
int hi = clampNote(highNote);
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
const int leftX = keyLeftX(layout, lo);
const int rightX = keyLeftX(layout, hi + 1);
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
}
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) {
const Rect bar = zoneBarRect(layout, lowNote, highNote);
if (!contains(bar, x, y)) return ZoneGrab::kNone;
const int barW = bar.width;
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so
// a click exactly on the midpoint resizes low (deterministic).
if (barW < 2 * kStripEdgeGrabWidth) {
const int mid = bar.x + barW / 2;
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
}
if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
return ZoneGrab::kBody;
}
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
int count, int x, int y) {
if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{};
if (!contains(layout.keys, x, y)) return ZoneBarHit{};
for (int i = 0; i < count; ++i) {
const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y);
if (g != ZoneGrab::kNone) return ZoneBarHit{i, g};
}
return ZoneBarHit{}; // on the band but on no bar
}
bool isNaturalKey(int note) {
// Clamp to the valid MIDI range before indexing.
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
// The 12-semitone pattern of natural (white) keys within an octave, starting at C:
// positions 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B) are natural;
// positions 1(C#) 3(D#) 6(F#) 8(G#) 10(A#) are accidental.
static constexpr bool kNatural[12] = {
true, // 0 C
false, // 1 C#
true, // 2 D
false, // 3 D#
true, // 4 E
true, // 5 F
false, // 6 F#
true, // 7 G
false, // 8 G#
true, // 9 A
false, // 10 A#
true, // 11 B
};
return kNatural[n % 12];
}
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
if (dxPixels == 0) return clampNote(startNote);
const int bandWidth = std::max(0, layout.keys.width);
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag
// agree across the full strip, even on non-divisible-by-128 widths. The proportional
// key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to
// the nearest key (half-key drag flips at the key centre) is achieved by adding
// bandWidth/2 to the absolute pixel delta before dividing — identical to the old
// formula except keyWidth is now derived from the same linear map (exact rational)
// rather than the truncated-integer bandWidth/128 that caused drift at the far end.
const int half = bandWidth / 2;
int shift;
if (dxPixels > 0) {
shift = (dxPixels * kStripKeyCount + half) / bandWidth;
} else {
shift = -(((-dxPixels) * kStripKeyCount + half) / bandWidth);
}
return clampNote(startNote + shift);
}
} // namespace reasampler::instrument::ui
+131
View File
@@ -0,0 +1,131 @@
// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first
// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle +
// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the
// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into
// these functions.
//
// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span
// idiom embed_strip uses). It serves TWO faces of the S10 editor:
// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on
// the strip, click-a-key (or drag the marker) sets the capture's root note; and
// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over
// the keys it covers, with edge-grab resize handles + a body move-handle so a drag
// sets low/high (edges) or moves the span (body), and a key-click sets the zone root.
//
// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures
// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through
// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the
// shell re-drawing the in-flight note; one coherent edit lands on release.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom),
// so this header depends on editor_geometry.h rather than redefining a rectangle type.
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named
// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay
// independent — the editor strip may grow octave labels/metrics the embed strip never does.
inline constexpr int kStripKeyCount = 128;
// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started
// within this many pixels of the bar's left/right edge resizes that edge; a drag started
// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body
// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges.
inline constexpr int kStripEdgeGrabWidth = 6;
// The strip's regions, derived from the (w x h) band the shell allots it. The keys band
// takes the whole area today (a future octave-label lane can carve a sub-band here without
// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
struct StripLayout {
Rect keys; // the key band: the 128-key span maps linearly across keys.width
};
// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or
// negative size yields empty rects (no inversion).
StripLayout layoutStrip(int w, int h);
// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key
// span maps linearly across keys.width; key N occupies the half-open pixel range
// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's
// right edge (so a key's right edge is keyLeftX(note+1)). Pure.
int keyLeftX(const StripLayout& layout, int note);
// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note),
// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range)
// note clamps to [0,127]. Pure.
Rect keyRect(const StripLayout& layout, int note);
// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`,
// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point
// so the shell's intent (this is the root marker, not just any key) reads at the call site,
// and so a future marker shape (a triangle over the key) has one place to change. Pure.
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs
// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure.
int keyAtPoint(const StripLayout& layout, int x, int y);
// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote]
// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes
// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band
// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect.
// Pure.
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag
// edits: an edge resizes that boundary; the body moves the whole span; none means the grab
// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a
// deselect).
enum class ZoneGrab {
kNone, // the point is not on this zone's bar
kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low
kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high
kBody, // on the bar but not an edge -> move the whole span
};
// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the
// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within
// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth)
// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact
// midpoint of a narrow bar (deterministic). Pure.
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on,
// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in
// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The
// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their
// length. Pure — no host containers at the boundary (a raw pointer pair, like
// embed_strip::zoneAtPoint).
struct ZoneBarHit {
int zoneIndex = -1;
ZoneGrab grab = ZoneGrab::kNone;
};
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
int count, int x, int y);
// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time
// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note
// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to
// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins
// the result to startNote (no motion). This is the single arithmetic behind edge-resize,
// body-move (apply to both edges with the SAME delta so the span is preserved), and
// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the
// key centre. Returns startNote unchanged for dxPixels==0.
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
// Returns true when `note` (0..127) is a NATURAL (white) key in standard 12-tone equal
// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127]
// range are clamped to [0,127] before classification (i.e. this never throws/UBs on a
// bad input). The 12 semitone positions within an octave:
// Natural (white): 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B)
// Accidental (black): 1(C#) 3(D#) 6(F#) 8(G#) 10(A#)
// Used by the shell to overlay the two-tone bright/dark piano-key pattern over the
// pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types.
bool isNaturalKey(int note);
} // namespace reasampler::instrument::ui
+156
View File
@@ -0,0 +1,156 @@
// knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes.
#include "core/instrument/ui/knob_deck.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
// already carries its own breathing room around the 28px knob), plus the optional row
// toggle after a kDeckToggleGap.
int knobRowWidth(const DeckGroupDesc& g) {
int w = static_cast<int>(g.cellIds.size()) * kDeckCellW;
if (g.rowToggle.id >= 0) {
if (w > 0) w += kDeckToggleGap;
w += 2 * g.rowToggle.segWidth;
}
return w;
}
// The caption-row width: the caption reserve plus the optional caption toggle.
int captionRowWidth(const DeckGroupDesc& g) {
int w = g.captionWidth;
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
return w;
}
// Place one group's inner geometry given its box.
DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
DeckGroupLayout out;
out.id = g.id;
out.box = box;
const int captionTop = box.y + kDeckGroupPadY;
const int innerLeft = box.x + kDeckGroupPadX;
const int innerRight = box.right() - kDeckGroupPadX;
// Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home).
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH);
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x;
}
// Knob row: fixed cells left-to-right, then the optional row toggle.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
int x = innerLeft;
for (int id : g.cellIds) {
DeckCellLayout c;
c.id = id;
c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH);
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c);
x += kDeckCellW;
}
if (g.rowToggle.id >= 0) {
if (!g.cellIds.empty()) x += kDeckToggleGap;
const int segW = g.rowToggle.segWidth;
const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2;
const Rect seg0 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH);
const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH);
out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1};
}
return out;
}
} // namespace
int deckGroupWidth(const DeckGroupDesc& g) {
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
}
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth) {
if (groups.empty()) return 0;
int rows = 1;
int x = 0;
for (const DeckGroupDesc& g : groups) {
const int w = deckGroupWidth(g);
if (x > 0 && x + kDeckGroupGap + w > availWidth) {
++rows;
x = w;
} else {
x += (x > 0 ? kDeckGroupGap : 0) + w;
}
}
return rows;
}
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth) {
const int rows = deckRowCount(groups, availWidth);
if (rows == 0) return 0;
return rows * kDeckGroupH + (rows - 1) * kDeckRowGap;
}
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth) {
DeckLayout out;
if (groups.empty()) return out;
int x = left;
int y = top;
bool rowHasGroup = false;
out.rowCount = 1;
for (const DeckGroupDesc& g : groups) {
const int w = deckGroupWidth(g);
if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) {
// Wrap: whole trailing group onto the next row (mirror of deckRowCount).
++out.rowCount;
x = left;
y += kDeckGroupH + kDeckRowGap;
rowHasGroup = false;
}
if (rowHasGroup) x += kDeckGroupGap;
const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
out.groups.push_back(layoutGroup(g, box));
x = box.right();
rowHasGroup = true;
}
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
return out;
}
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
for (const DeckGroupLayout& g : layout.groups) {
if (!contains(g.box, x, y)) continue;
if (g.captionToggle.id >= 0) {
if (contains(g.captionToggle.seg0, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
if (contains(g.captionToggle.seg1, x, y))
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1};
}
if (g.rowToggle.id >= 0) {
if (contains(g.rowToggle.seg0, x, y))
return {DeckHitKind::RowToggle, g.rowToggle.id, 0};
if (contains(g.rowToggle.seg1, x, y))
return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
}
for (const DeckCellLayout& c : g.cells) {
if (c.id >= 0 && contains(c.cell, x, y)) return {DeckHitKind::Knob, c.id, -1};
}
return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap)
}
return {};
}
} // namespace reasampler::instrument::ui
+133
View File
@@ -0,0 +1,133 @@
// knob_deck.h — PURE knob-deck layout + hit-test for the r11 Sample-face recomposition
// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like
// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids.
// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws each
// group (fence, caption, compact toggles, knobs) through the L1 kit and routes clicks/drags
// via the hit-test. The KNOB PRIMITIVE itself (value<->needle-angle, vertical drag) is
// param_slider's (FA4); a knob cell here is just a rect — the shell composes the two.
//
// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each
// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's
// compact mode toggle right-anchored IN the caption row — this is where the not-full-width
// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band
// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row
// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar,
// vertically centered). Groups that must keep stable geometry across a mode flip reserve
// blank cells (id -1): the AMP ENVELOPE group always spans 5 cells so Gate<->Trigger never
// reflows its neighbours.
//
// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that
// does not fit the remaining width starts a new deck row (whole groups only, never split).
// The first group of a row always places even if wider than the row (degenerate width).
// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and
// give the ELASTIC HERO the rest (r11 band order).
#pragma once
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — the shared geometry idiom
namespace reasampler::instrument::ui {
// Fixed deck metrics (spec r11), exposed so the shell and tests agree.
inline constexpr int kDeckCellW = 48; // one knob cell
inline constexpr int kDeckCellH = 58;
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell
inline constexpr int kDeckCellLabelH = 12; // the Micro label band under the knob
inline constexpr int kDeckCaptionH = 20; // the group caption row
inline constexpr int kDeckToggleH = 18; // compact toggle segment height
inline constexpr int kDeckGroupPadX = 6; // group box horizontal inner padding
inline constexpr int kDeckGroupPadY = 4; // group box vertical inner padding
inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
// One group box: padding + caption + gap + cell row + padding.
inline constexpr int kDeckGroupH =
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
// A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent.
struct DeckToggleDesc {
int id = -1; // shell control id returned by the hit-test; -1 = no toggle
int segWidth = 44; // px per segment
};
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// is a RESERVED BLANK cell (geometry held, never hit — the AMP ENVELOPE Trigger face).
// `captionWidth` is the px the shell reserves for the caption text (this module does not
// measure text — the house constant-metrics pattern).
struct DeckGroupDesc {
int id = 0; // shell group id (opaque here)
int captionWidth = 60;
DeckToggleDesc captionToggle; // right-anchored in the caption row; id -1 = none
std::vector<int> cellIds; // knob cells; -1 = blank reserve
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
};
// --- Laid-out geometry ---------------------------------------------------------------
struct DeckToggleLayout {
int id = -1;
Rect seg0; // left segment
Rect seg1; // right segment
};
struct DeckCellLayout {
int id = -1;
Rect cell; // the full 48x58 cell
Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it)
Rect label; // the 12px label band beneath the knob
};
struct DeckGroupLayout {
int id = 0;
Rect box; // the fenced group box
Rect caption; // caption text rect (left part of the caption row)
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent
};
struct DeckLayout {
std::vector<DeckGroupLayout> groups;
int rowCount = 0;
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups
};
// The width of one group box: the wider of its caption row (caption + gap + toggle) and its
// knob row (cells + gap + row toggle), plus the horizontal padding. Pure.
int deckGroupWidth(const DeckGroupDesc& g);
// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group
// wrap (a group that does not fit the remaining row width starts a new row; the first group
// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an
// empty list. The shell bottom-anchors a band of exactly this height. Pure.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule.
// Every rect is absolute. Pure — same inputs, same layout.
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth);
// --- Hit-test --------------------------------------------------------------------------
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle };
struct DeckHit {
DeckHitKind kind = DeckHitKind::None;
int id = -1; // the control id of the hit element (cell id / toggle id)
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
};
// The deck element a point lands on: a knob CELL (the whole 48x58 cell — friendlier than the
// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption-
// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
// Pure — the shell's routing entry point.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui
+160
View File
@@ -0,0 +1,160 @@
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16
// editor parameter panel. No host types; only the shared Rect + contains().
#include "core/instrument/ui/param_slider.h"
#include "core/util/clamp01.h"
#include <algorithm>
#include <cmath>
namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) {
std::vector<ControlRow> out;
if (controls.empty() || panel.width <= 0 || panel.height <= 0) return out;
out.reserve(controls.size());
// The label column is clamped so a narrow panel still leaves a control column.
const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width / 2));
int rowTop = panel.y;
for (const ControlDesc& d : controls) {
ControlRow r;
r.id = d.id;
r.kind = d.kind;
const int rowBottom = rowTop + kControlRowHeight;
r.row = Rect::ltrb(panel.x, rowTop, panel.right(), rowBottom);
r.label = Rect::ltrb(panel.x, rowTop, panel.x + labelW, rowBottom);
r.control = Rect::ltrb(panel.x + labelW, rowTop, panel.right(), rowBottom);
out.push_back(r);
rowTop = rowBottom + kControlRowGap;
}
return out;
}
Rect toggleSegmentRect(const Rect& control, int seg) {
if (seg < 0 || seg >= kToggleSegments) return Rect{};
const int w = control.width;
if (w <= 0 || control.height <= 0) return Rect{};
const int segW = w / kToggleSegments;
const int left = control.x + seg * segW;
// The last segment absorbs the width remainder so the segments tile the whole control.
const int right = (seg == kToggleSegments - 1) ? control.right() : left + segW;
return Rect::ltrb(left, control.y, right, control.bottom());
}
int toggleSegmentHitTest(const Rect& control, int x, int y) {
if (!contains(control, x, y)) return -1;
for (int seg = 0; seg < kToggleSegments; ++seg) {
if (contains(toggleSegmentRect(control, seg), x, y)) return seg;
}
return -1;
}
Rect sliderTrackRect(const Rect& control) {
// Inset a half-handle at each end so the handle stays fully inside the control at value
// 0 and 1. The handle CENTER ranges across [track.x, track.right()].
const int half = kSliderHandleWidth / 2;
if (control.width <= kSliderHandleWidth || control.height <= 0) return Rect{};
return Rect::ltrb(control.x + half, control.y, control.right() - half, control.bottom());
}
Rect sliderHandleRect(const Rect& control, double value) {
const Rect track = sliderTrackRect(control);
if (track.width <= 0) return Rect{};
if (value < 0.0) value = 0.0;
if (value > 1.0) value = 1.0;
const int span = track.width; // handle-center movable span
const int centerX = track.x + static_cast<int>(value * span + 0.5);
const int half = kSliderHandleWidth / 2;
return Rect::ltrb(centerX - half, control.y, centerX - half + kSliderHandleWidth,
control.bottom());
}
double valueAtPoint(const Rect& control, int x) {
const Rect track = sliderTrackRect(control);
const int span = track.width;
if (span <= 0) return 0.0;
if (x <= track.x) return 0.0;
if (x >= track.right()) return 1.0;
return static_cast<double>(x - track.x) / static_cast<double>(span);
}
// --- Radial knob (Wave A FA4) ---------------------------------------------------------
namespace {
constexpr double kPi = 3.14159265358979323846;
// Normalize an angle in degrees to [0, 360).
double normDeg(double deg) {
deg = std::fmod(deg, 360.0);
if (deg < 0.0) deg += 360.0;
// Guard: fmod can return exactly 360.0 on some implementations due to floating-point
// rounding; fold it back to 0.
if (deg >= 360.0) deg -= 360.0;
return deg;
}
} // namespace
KnobGeometry computeKnob(const Rect& cell) {
if (cell.width <= 0 || cell.height <= 0) return KnobGeometry{};
KnobGeometry g;
g.centerX = (cell.x + cell.right()) / 2.0;
g.centerY = (cell.y + cell.bottom()) / 2.0;
g.radius = (std::min)(cell.width, cell.height) / 2.0;
return g;
}
bool knobHitTest(const KnobGeometry& knob, int x, int y) {
if (knob.radius <= 0.0) return false;
const double dx = x - knob.centerX;
const double dy = y - knob.centerY;
// Boundary exclusive: matches the module's half-open Rect convention.
return dx * dx + dy * dy < knob.radius * knob.radius;
}
double knobSweepDeg(const KnobArc& arc) {
const double sweep = normDeg(arc.endDeg) - normDeg(arc.startDeg);
// An end at-or-behind the start wraps clockwise past 12 o'clock; equal angles mean a
// full circle.
return sweep <= 0.0 ? sweep + 360.0 : sweep;
}
double knobValueAngleDeg(const KnobArc& arc, double value) {
return normDeg(normDeg(arc.startDeg) + knobSweepDeg(arc) * clamp01(value));
}
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value) {
// Clock angle -> screen direction: 0° points up (-y), 90° points right (+x).
const double rad = knobValueAngleDeg(arc, value) * kPi / 180.0;
return KnobPoint{knob.centerX + knob.radius * std::sin(rad),
knob.centerY - knob.radius * std::cos(rad)};
}
double knobDragValue(double startValue, int dyPixels, int dragRangePixels) {
const double start = clamp01(startValue);
if (dragRangePixels <= 0) return start;
// Screen y grows downward: an upward drag (negative dy) increases the value.
return clamp01(start - static_cast<double>(dyPixels) / dragRangePixels);
}
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
for (const ControlRow& r : rows) {
if (r.kind == ControlKind::Toggle) {
if (contains(r.control, x, y)) return r.id;
} else if (r.kind == ControlKind::Knob) {
if (knobHitTest(computeKnob(r.control), x, y)) return r.id;
} else { // Slider — the interactive area is the track
if (contains(sliderTrackRect(r.control), x, y)) return r.id;
}
}
return -1;
}
} // namespace reasampler::instrument::ui
+180
View File
@@ -0,0 +1,180 @@
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the
// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell
// draws each row (label + track/segments + handle) and routes clicks/drags into these
// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping.
//
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal
// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value
// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that
// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle
// angle; the shell converts each control's engine value (frames, seconds, a fraction, a
// signed semitone depth) to/from that 0..1 with its own domain knowledge (this module stays
// engine-free so it tests without the audio core).
//
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
#pragma once
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::instrument::ui {
// Fixed control-panel metrics, exposed so the shell and tests agree.
inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap)
inline constexpr int kControlRowGap = 4; // vertical gap below each row
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
// A control is one of three shapes. Toggle = a two-segment selector (the active segment
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value;
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY
// (up = increase).
enum class ControlKind { Toggle, Slider, Knob };
// One control the shell places in the panel, in stack order. `id` is the shell's own control
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
// shell routes the interaction to the right engine param — this module never interprets it.
struct ControlDesc {
int id = 0;
ControlKind kind = ControlKind::Slider;
};
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
// into segments via toggleSegmentRect). `index` is the control's position in the stack.
struct ControlRow {
int id = 0;
ControlKind kind = ControlKind::Slider;
Rect row; // the full row (label column + control column)
Rect label; // the label column at the left
Rect control; // the control column to the right of the label (track / toggle area)
};
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A
// row whose top falls past the panel bottom is still returned (the shell clips at paint /
// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty
// control list or a degenerate panel yields an empty vector. Pure.
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls);
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect,
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
// control rect yields an empty rect. Pure.
Rect toggleSegmentRect(const Rect& control, int seg);
// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a
// miss (outside the control area). Pure.
int toggleSegmentHitTest(const Rect& control, int x, int y);
// The slider track sub-rect inside a slider control's `control` rect: the control inset so the
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle
// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
// empty rect. Pure.
Rect sliderTrackRect(const Rect& control);
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
Rect sliderHandleRect(const Rect& control, double value);
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range).
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate
// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
double valueAtPoint(const Rect& control, int x);
// --- Radial knob (Wave A FA4) --------------------------------------------------------------
//
// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
// angles mean a full 360° sweep.
//
// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping
// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the
// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles
// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout
// spec lands.
inline constexpr double kKnobArcStartDeg = 210.0; // value 0 — 7 o'clock
inline constexpr double kKnobArcEndDeg = 150.0; // value 1 — 5 o'clock (clockwise wrap)
// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep.
inline constexpr int kKnobDragRangePixels = 128;
// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above.
struct KnobArc {
double startDeg = kKnobArcStartDeg;
double endDeg = kKnobArcEndDeg;
};
// A knob's circle within its control cell: center + radius in pixel space (doubles so the
// shell rounds once, at draw time). radius == 0 marks a degenerate cell.
struct KnobGeometry {
double centerX = 0.0;
double centerY = 0.0;
double radius = 0.0;
};
// A pixel-space point (the needle endpoint the shell draws to).
struct KnobPoint {
double x = 0.0;
double y = 0.0;
};
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A
// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full
// control column) both when drawing and when hit-testing — `controlAtPoint` always uses
// `r.control` as the cell, so the draw cell and hit cell must be the same. If the shell
// wants to draw a smaller circle it must center it within `row.control` and accept that the
// hit area is the larger column-inscribed circle. Pure.
KnobGeometry computeKnob(const Rect& cell);
// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the
// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure.
bool knobHitTest(const KnobGeometry& knob, int x, int y);
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping
// +360 when the end is at-or-behind the start (default arc -> 300). Pure.
double knobSweepDeg(const KnobArc& arc);
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
// linear between, returned normalized to [0, 360). Pure.
double knobValueAngleDeg(const KnobArc& arc, double value);
// The needle endpoint for normalized `value`: the point on the knob circle at the value's
// angle, from the center. The shell draws the needle from (centerX, centerY) to this point
// (or lerps toward the center for a shorter needle). Pure.
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value);
// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped),
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1
// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value.
// Pure — the inverse map for the knob's drag interaction.
double knobDragValue(double startValue, int dyPixels,
int dragRangePixels = kKnobDragRangePixels);
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every
// row). The FIRST matching row wins (rows never overlap, so at most one matches). Pure — the
// shell's routing entry point: on a hit it reads the value (valueAtPoint /
// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits.
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::instrument::ui
+99
View File
@@ -0,0 +1,99 @@
// waveform_view.cpp — see waveform_view.h. Pure math; no host types.
#include "core/instrument/ui/waveform_view.h"
#include <algorithm>
#include <cstdlib> // std::abs (int overload)
namespace reasampler::instrument::ui {
namespace {
std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
if (f < 0) return 0;
if (f > frameCount) return frameCount;
return f;
}
} // namespace
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return area.x;
const std::int64_t f = clampFrame(frame, frameCount);
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line
// visually centered on its frame; the divide is exact rational (multiply first).
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
return area.x + static_cast<int>(num / frameCount);
}
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return 0;
if (x <= area.x) return 0;
if (x >= area.right()) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
// agree at bin granularity.
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
}
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y) {
if (count <= 0 || frames == nullptr) return -1;
if (!contains(area, x, y)) return -1;
for (int i = 0; i < count; ++i) {
const int mx = frameToX(area, frameCount, frames[i]);
if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i;
}
return -1;
}
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels) {
const std::int64_t start = clampFrame(startFrame, frameCount);
if (dxPixels == 0) return start;
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return start; // no room to move
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
const std::int64_t magnitude =
(static_cast<std::int64_t>(std::abs(dxPixels)) * frameCount +
static_cast<std::int64_t>(w) / 2) /
static_cast<std::int64_t>(w);
const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude;
return clampFrame(start + shift, frameCount);
}
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target) {
if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0);
// Clamp target into a valid sample index [0, frames).
std::int64_t t = target;
if (t < 0) t = 0;
if (t > frames - 1) t = frames - 1;
// A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR
// pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we
// probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic).
auto isCrossing = [&](std::int64_t i) -> bool {
if (i < 1 || i >= frames) return false;
const AudioSample a = pcm[i - 1];
const AudioSample b = pcm[i];
if (b == 0.0f) return true; // a sample on zero is its own crossing
return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i
};
if (isCrossing(t)) return t;
for (std::int64_t d = 1; d < frames; ++d) {
const std::int64_t lo = t - d;
if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie
const std::int64_t hi = t + d;
if (hi < frames && isCrossing(hi)) return hi;
// Stop once both probes have run off both ends — no crossing anywhere.
if (lo < 1 && hi >= frames) break;
}
return t; // no sign change in the whole buffer -> keep the raw (clamped) target
}
} // namespace reasampler::instrument::ui
+85
View File
@@ -0,0 +1,85 @@
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap
// arithmetic lives here, unit-tested outside the DAW, while the editor shell
// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it.
//
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start,
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately
// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a
// different marker set (start + %-length end + fades) without reworking the machinery.
//
// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab
// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's
// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps),
// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so
// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
#pragma once
#include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans
namespace reasampler::instrument::ui {
using audio::AudioSample;
// The width (px) of a marker's grab region either side of its x line: a grab within this many
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
// distinguishable.
inline constexpr int kMarkerGrabWidth = 5;
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x,
// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a
// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure.
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to
// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount.
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
// to the same frame at bin granularity).
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at
// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker
// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring
// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or
// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like
// keyboard_strip::zoneBarAtPoint.
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y);
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth),
// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to
// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag;
// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this
// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame
// for dxPixels == 0.
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels);
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames)
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to
// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or
// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller
// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the
// decoded PCM the shell already holds; no host types, no file I/O.
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target);
} // namespace reasampler::instrument::ui
+1 -1
View File
@@ -138,7 +138,7 @@ public:
// Captures the raw source text of one value verbatim (string-aware brace
// matching), so a nested blob can be handed to its own parser — the
// bank_book -> BankIndex::deserialize seam.
// bank_book -> BankModel::deserialize seam.
bool captureValue(std::string& raw);
private:
+737
View File
@@ -0,0 +1,737 @@
#include "core/model/bank_book.h"
#include <algorithm>
#include <unordered_set>
#include "core/json/json.h"
// bank_book implementation.
//
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
// and view_mode_model. The book blob nests one bank object per bank, each carrying that
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize),
// so per-bank sample serialization stays owned by bank_model and is not duplicated
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
// raw "index" member whose value is the BankModel blob verbatim; the parser splits
// the book envelope, then hands each nested index blob straight to
// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped.
namespace reasampler {
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05).
// ---------------------------------------------------------------------------
// BankBook — construction + bank lookup
// ---------------------------------------------------------------------------
BankBook::BankBook() {
Bank pool;
pool.id = kPoolBankId;
pool.displayName = kPoolBankName;
pool.ordinal = 0;
banks_.push_back(std::move(pool));
activeBankId_ = kPoolBankId;
}
Bank* BankBook::bank(const std::string& id) {
for (auto& b : banks_)
if (b.id == id) return &b;
return nullptr;
}
const Bank* BankBook::bank(const std::string& id) const {
for (const auto& b : banks_)
if (b.id == id) return &b;
return nullptr;
}
BankModel* BankBook::index(const std::string& id) {
Bank* b = bank(id);
return b ? &b->index : nullptr;
}
const BankModel* BankBook::index(const std::string& id) const {
const Bank* b = bank(id);
return b ? &b->index : nullptr;
}
Bank& BankBook::pool() {
// The pool is seeded on construction and is un-deletable, so it always exists.
return *bank(kPoolBankId);
}
const Bank& BankBook::pool() const {
return *bank(kPoolBankId);
}
// ---------------------------------------------------------------------------
// Ordinal normalization
// ---------------------------------------------------------------------------
void BankBook::normalizeOrdinals() {
// Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a
// contiguous 0..N-1. Stability preserves the caller's relative order among banks
// that share (or, after a reorder shuffle, tie on) an ordinal.
std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) {
if (a.isPool() != b.isPool()) return a.isPool(); // pool always first
return a.ordinal < b.ordinal;
});
for (std::size_t i = 0; i < banks_.size(); ++i)
banks_[i].ordinal = static_cast<int>(i);
}
// ---------------------------------------------------------------------------
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
// ---------------------------------------------------------------------------
namespace {
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one
// key and cannot coexist. ASCII-only by design — the pure core carries no locale
// facility and must not grow one; bank names are short user labels, not full Unicode
// case-folding candidates.
std::string nameKey(const std::string& s) {
std::size_t b = 0, e = s.size();
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
while (b < e && isWs(s[b])) ++b;
while (e > b && isWs(s[e - 1])) --e;
std::string out;
out.reserve(e - b);
for (std::size_t i = b; i < e; ++i) {
char c = s[i];
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
out += c;
}
return out;
}
} // namespace
// True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The
// exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name.
bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const {
const std::string key = nameKey(name);
for (const auto& b : banks_)
if (b.id != exceptId && nameKey(b.displayName) == key) return true;
return false;
}
// ---------------------------------------------------------------------------
// Bank lifecycle
// ---------------------------------------------------------------------------
bool BankBook::createBank(const std::string& id, const std::string& displayName) {
if (id.empty()) return false; // ids key the registry
if (id == kPoolBankId) return false; // reserved pool id
if (bank(id) != nullptr) return false; // duplicate id
// Display names are unique (trimmed + case-insensitive); the pool's "Pool" is a
// reserved name and is caught here like any other collision.
if (displayNameTaken(displayName, /*exceptId=*/id)) return false;
Bank b;
b.id = id;
b.displayName = displayName;
b.ordinal = static_cast<int>(banks_.size()); // append; normalize compacts it
banks_.push_back(std::move(b));
normalizeOrdinals();
return true;
}
bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
if (id == kPoolBankId) return false; // pool is un-renamable
Bank* b = bank(id);
if (b == nullptr) return false;
// Reject a name already used by a DIFFERENT bank. Renaming a bank to its own
// current name (or a case/space variant of it) is a no-op success, not a
// rejection — exceptId=id excludes the bank itself from the collision scan.
if (displayNameTaken(displayName, /*exceptId=*/id)) return false;
b->displayName = displayName;
return true;
}
bool BankBook::deleteBank(const std::string& id) {
if (id == kPoolBankId) return false; // pool is un-deletable
auto it = std::find_if(banks_.begin(), banks_.end(),
[&](const Bank& b) { return b.id == id; });
if (it == banks_.end()) return false;
banks_.erase(it);
// If the active bank was the one deleted, fall back to the pool (invariant: the
// active id always names a live bank).
if (activeBankId_ == id) activeBankId_ = kPoolBankId;
normalizeOrdinals();
return true;
}
bool BankBook::reorderBank(const std::string& id, int newOrdinal) {
if (id == kPoolBankId) return false; // pool is pinned at ordinal 0
if (bank(id) == nullptr) return false;
// Work on the named banks as an ordered list (banks_ is already ordinal-sorted
// with the pool first, so named banks are banks_[1..]). Pull the target out and
// re-insert it at the requested position, clamped into the named-bank range
// [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct.
std::vector<Bank> named;
named.reserve(banks_.size());
for (auto& b : banks_)
if (!b.isPool()) named.push_back(std::move(b));
auto it = std::find_if(named.begin(), named.end(),
[&](const Bank& b) { return b.id == id; });
Bank moved = std::move(*it);
named.erase(it);
// Named ordinals are 1..N; convert to a 0-based insertion index into `named`.
const int hi = static_cast<int>(named.size()); // insert-at range is [0..size]
int insertAt = std::max(0, std::min(newOrdinal - 1, hi));
named.insert(named.begin() + insertAt, std::move(moved));
// Rebuild banks_: pool first, then the reordered named banks. Assign ordinals
// directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys
// on the (now stale) ordinals and would undo the reinsertion order.
std::vector<Bank> rebuilt;
rebuilt.reserve(named.size() + 1);
rebuilt.push_back(std::move(pool()));
for (auto& b : named) rebuilt.push_back(std::move(b));
banks_ = std::move(rebuilt);
for (std::size_t i = 0; i < banks_.size(); ++i)
banks_[i].ordinal = static_cast<int>(i);
return true;
}
bool BankBook::evacuate(const std::string& id) {
if (id == kPoolBankId) return false; // pool is un-evacuable (it is the target)
Bank* src = bank(id);
if (src == nullptr) return false;
// Move every member into the pool, index-only, observing destination collapse.
// Snapshot the members first, then clear the source — BankModel has no bulk move,
// and adding into the pool must not alias the vector we are draining.
BankModel& poolIndex = pool().index;
const std::vector<Sample> members = src->index.all(); // copy
for (const auto& s : members)
poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash
src->index = BankModel{}; // leave the evacuated bank empty
return true;
}
// ---------------------------------------------------------------------------
// Active bank
// ---------------------------------------------------------------------------
bool BankBook::setActiveBank(const std::string& id) {
if (bank(id) == nullptr) return false; // unknown id never corrupts state
activeBankId_ = id;
return true;
}
BankModel& BankBook::activeIndex() {
// activeBankId_ always names a live bank; it falls back to the pool on delete.
return bank(activeBankId_)->index;
}
const BankModel& BankBook::activeIndex() const {
return bank(activeBankId_)->index;
}
// ---------------------------------------------------------------------------
// Sample movement (index-only)
// ---------------------------------------------------------------------------
namespace {
// Adds `s` to `dest` and maps the BankModel outcome onto the transfer outcome for
// the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes
// (absolute path / empty id) cannot occur here: the sample already passed add() on
// the source side, so its path and id are already valid.
TransferResult applyDestAdd(BankModel& dest, const Sample& s, TransferResult gained) {
return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained;
}
} // namespace
TransferResult BankBook::moveSample(const std::string& sampleId,
const std::string& fromBankId,
const std::string& toBankId) {
Bank* from = bank(fromBankId);
Bank* to = bank(toBankId);
if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank;
if (fromBankId == toBankId) return TransferResult::RejectedSameBank;
const Sample* s = from->index.query(sampleId);
if (s == nullptr) return TransferResult::RejectedSampleAbsent;
// Copy the sample out before removing it: query returns a pointer into the
// source vector that remove() invalidates.
const Sample moved = *s;
from->index.remove(sampleId); // source loses the entry unconditionally on a move
return applyDestAdd(to->index, moved, TransferResult::Moved);
}
TransferResult BankBook::copySample(const std::string& sampleId,
const std::string& fromBankId,
const std::string& toBankId) {
Bank* from = bank(fromBankId);
Bank* to = bank(toBankId);
if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank;
if (fromBankId == toBankId) return TransferResult::RejectedSameBank;
const Sample* s = from->index.query(sampleId);
if (s == nullptr) return TransferResult::RejectedSampleAbsent;
const Sample copy = *s; // source entry is left intact
return applyDestAdd(to->index, copy, TransferResult::Copied);
}
// ---------------------------------------------------------------------------
// Sample removal (index-only) + the last-reference query
// ---------------------------------------------------------------------------
RemoveResult BankBook::removeSample(const std::string& sampleId,
const std::string& fromBankId,
RemoveScope scope) {
if (scope == RemoveScope::AllBanks) {
// Latent seam: purge the id from every bank that holds it. fromBankId is
// ignored (the id is dropped book-wide). Removed iff at least one drop landed.
bool any = false;
for (auto& b : banks_)
if (b.index.remove(sampleId)) {
b.slots.remove(sampleId); // keep SlotMap in sync: leave an empty gap
any = true;
}
return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent;
}
// ThisBank (default, the only surfaced verb): drop from the one named source bank.
Bank* from = bank(fromBankId);
if (from == nullptr) return RemoveResult::RejectedUnknownBank;
if (!from->index.remove(sampleId)) return RemoveResult::RejectedSampleAbsent;
from->slots.remove(sampleId); // keep SlotMap in sync: the removed sample's slot becomes a gap
return RemoveResult::Removed;
}
bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) {
for (auto& b : banks_)
if (b.index.query(sampleId) != nullptr)
return b.index.updateInPlace(sampleId, updated);
return false; // no bank holds the id
}
// ---------------------------------------------------------------------------
// Sample display order (L7) — SlotMap driven, index membership untouched
// ---------------------------------------------------------------------------
namespace {
// The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed.
std::vector<std::string> indexIds(const BankModel& idx) {
std::vector<std::string> ids;
for (const auto& s : idx.all()) ids.push_back(s.id);
return ids;
}
// Squares one bank's SlotMap with its index membership. A map with NO overlap with the
// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from
// insertion order; an existing map is reconciled (drop stale markers, append unmapped).
void reconcileBankSlots(Bank& b) {
const std::vector<std::string> live = indexIds(b.index);
if (b.slots.empty()) {
b.slots.resetDense(live); // migration / first-population default: dense, no gaps
return;
}
b.slots.reconcile(live); // partial map: keep positions, drop stale, append new
}
} // namespace
void BankBook::reconcileSlots() {
for (auto& b : banks_) reconcileBankSlots(b);
}
std::vector<std::string> BankBook::orderedSampleIds(const std::string& bankId) {
Bank* b = bank(bankId);
if (b == nullptr) return {};
reconcileBankSlots(*b); // ensure the map covers all live members
return b->slots.orderedIds();
}
bool BankBook::reorderSample(const std::string& id, const std::string& bankId,
int targetSlot) {
Bank* b = bank(bankId);
if (b == nullptr) return false;
if (b->index.query(id) == nullptr) return false; // bank does not hold the sample
reconcileBankSlots(*b); // complete the target space first
return b->slots.reorder(id, targetSlot); // gap-preserving; index untouched
}
bool BankBook::replaceSample(const std::string& newId, const std::string& oldId,
const std::string& bankId) {
if (newId == oldId) return false;
Bank* b = bank(bankId);
if (b == nullptr) return false;
// Both the dragged sample and the occupant must live in this bank.
if (b->index.query(newId) == nullptr) return false;
if (b->index.query(oldId) == nullptr) return false;
reconcileBankSlots(*b); // complete the map so oldId's slot is known
// Capture the target slot BEFORE any mutation so the position survives the removal.
const int targetSlot = b->slots.slotOf(oldId);
if (targetSlot < 0) return false; // occupant not positioned (shouldn't happen post-reconcile)
// POOL GUARD (settled): the occupant's index-removal must pass the SAME guard the
// remove verb applies. Commit the removal FIRST so a rejection is a true no-op (no
// slot mutation happened yet). removeSample(ThisBank) permits per-sample removal from
// any bank incl. the pool (per-sample remove is not a pool privilege), so it succeeds
// whenever the occupant exists — which we verified — but routing through it means a
// future pool-floor guard added to remove governs replace identically, one rule.
const RemoveResult r = removeSample(oldId, bankId, RemoveScope::ThisBank);
if (r != RemoveResult::Removed) return false; // guard rejected -> nothing changed
// Occupant gone from the index; now update the slot markers. Drop oldId's now-dangling
// marker to free the target slot, then move newId onto it. reorder onto an EMPTY slot
// places newId there exactly and empties newId's own (source) slot — the slot position
// is preserved and only its occupant changed, exactly the replace contract.
b->slots.remove(oldId);
b->slots.reorder(newId, targetSlot);
return true;
}
bool BankBook::hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const {
if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash)
for (const auto& b : banks_) {
if (b.id == exceptBankId) continue; // the removed-from bank is excluded
if (b.index.findByHash(hash) != nullptr) return true;
}
return false;
}
std::vector<std::string> BankBook::referencedPaths() const {
// Union across the whole book (pool first, then named banks in ordinal order —
// banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy
// put in two banks appears once. Skip empty paths (they reference no file).
std::vector<std::string> paths;
std::unordered_set<std::string> seen;
for (const auto& b : banks_) {
for (const auto& s : b.index.all()) {
if (s.relativePath.empty()) continue;
if (seen.insert(s.relativePath).second)
paths.push_back(s.relativePath);
}
}
return paths;
}
// ===========================================================================
// JSON — writer
// ===========================================================================
namespace {
// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the
// prior file-local writer carried, so the emitted blob is byte-identical.
std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
} // namespace
std::string BankBook::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", intToStr(1));
root.keyStr("activeBank", activeBankId_);
// banks: array of { id, displayName, ordinal, index: <BankModel blob> }.
// The pool rides in as bank-zero, persisted identically to any named bank.
root.keyBegin("banks");
out += '[';
for (std::size_t i = 0; i < banks_.size(); ++i) {
if (i) out += ',';
ObjWriter b(out);
b.keyStr("id", banks_[i].id);
b.keyStr("displayName", banks_[i].displayName);
b.keyRaw("ordinal", intToStr(banks_[i].ordinal));
// The nested index is bank_model's own JSON, emitted verbatim so the
// per-sample shape stays owned by BankModel::serialize (not duplicated).
b.keyRaw("index", banks_[i].index.serialize());
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the
// parser defaults such a bank's slots from insertion order on load.
b.keyRaw("slots", banks_[i].slots.serialize());
}
out += ']';
} // root closes here (see bank_model note on NRVO + deferred close)
return out;
}
// ===========================================================================
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
// ===========================================================================
namespace {
// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1).
// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root
// blob, distinguishing the legacy shape (a bare bank_index object: has
// "samples", no "banks") from the book shape (has "banks"): a legacy blob
// yields a single pool bank carrying the migrated index and an empty active id
// (⇒ pool). The member deserialize() adopts the result (ordinal normalize +
// active resolve).
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out);
bool parseBank(json::Reader& r, Bank& b) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // a bank object must at least carry an id
bool haveId = false;
bool haveIndex = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "id") {
if (!r.parseString(b.id)) return false;
haveId = true;
} else if (key == "displayName") {
if (!r.parseString(b.displayName)) return false;
} else if (key == "ordinal") {
if (!r.parseInt(b.ordinal)) return false;
} else if (key == "index") {
std::string raw;
if (!r.captureValue(raw)) return false;
auto idx = BankModel::deserialize(raw);
if (!idx) return false; // a malformed nested index fails the whole parse
b.index = std::move(*idx);
haveIndex = true;
} else if (key == "slots") {
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips
// nothing because the key never appears); when present it drives the
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
std::vector<std::pair<std::string, int>> pairs;
if (!parseSlots(r, pairs)) return false;
b.slots = SlotMap::fromEntries(pairs);
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || b.id.empty()) return false; // id keys the registry
if (!haveIndex) return false; // every bank persists its index
return true;
}
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out) {
out.clear();
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true; // empty slot array — a bank with no positions yet
do {
if (!r.consume('{')) return false;
std::string id;
int slot = 0;
bool haveId = false, haveSlot = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
else { if (!r.skipValue()) return false; } // forward-compat
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || !haveSlot) return false; // a slot entry needs both
out.emplace_back(std::move(id), slot);
} while (r.consume(','));
return r.consume(']');
}
bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks,
std::string& activeBank) {
banks.clear();
activeBank.clear();
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // an empty object is neither shape → malformed
// Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a
// "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
std::vector<Bank> parsedBanks;
bool sawBanks = false;
bool sawSamples = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "banks") {
sawBanks = true;
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
Bank b;
if (!parseBank(r, b)) return false;
parsedBanks.push_back(std::move(b));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "activeBank") {
if (!r.parseString(activeBank)) return false;
} else if (key == "samples") {
// Legacy marker. The legacy index is re-parsed from the whole input below
// (BankModel::deserialize owns that shape); here we only skip the value to
// keep the scan well-formed and note that we saw it.
sawSamples = true;
if (!r.skipValue()) return false;
} else {
if (!r.skipValue()) return false; // version, or unknown
}
} while (r.consume(','));
if (!r.consume('}')) return false;
r.skipWs();
if (!r.eof()) return false; // trailing garbage
// --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
if (!sawBanks) {
if (!sawSamples) return false; // neither shape's marker → malformed
auto legacy = BankModel::deserialize(raw);
if (!legacy) return false;
Bank pool;
pool.id = kPoolBankId;
pool.displayName = kPoolBankName;
pool.ordinal = 0;
pool.index = std::move(*legacy);
banks.push_back(std::move(pool)); // { pool } with zero named banks
activeBank.clear(); // ⇒ pool (default) after adoption
return true;
}
// --- Book shape: the parsed banks ARE the book (pool folded in). ---
// The pool must be present as bank-zero (serialize always emits it). Reject a
// book blob that omits it rather than silently re-seeding — a book without its
// pool is malformed, not a legacy blob.
bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(),
[](const Bank& b) { return b.isPool(); });
if (!hasPool) return false;
// Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup).
for (std::size_t i = 0; i < parsedBanks.size(); ++i)
for (std::size_t j = i + 1; j < parsedBanks.size(); ++j)
if (parsedBanks[i].id == parsedBanks[j].id) return false;
// Force the pool's fixed display name — it is not user-mutable, so we do not
// trust a persisted override for it (keeps kPoolBankName authoritative).
for (auto& b : parsedBanks)
if (b.isPool()) b.displayName = kPoolBankName;
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
// The in-model create/rename path enforces unique display names under nameKey,
// but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
// same key ("Drums" and " drums "). Rejecting the whole book over one collision
// would degrade the user's entire library to empty, so instead we AUTO-
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
// the first time a folded key repeats, suffix that bank's display name (" 2",
// " 3", …) until its folded key is unique among all names seen so far. The FIRST
// bank to carry a key keeps its name verbatim; only subsequent collisions are
// renamed. No bank or sample is lost, and ids are untouched. The pool is included
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
// is disambiguated away from it, never the reverse.
{
std::vector<std::string> seenKeys;
seenKeys.reserve(parsedBanks.size());
for (auto& b : parsedBanks) {
if (b.isPool()) { // pool's name is fixed; reserve its key
seenKeys.push_back(nameKey(b.displayName));
continue;
}
const auto taken = [&](const std::string& k) {
return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end();
};
std::string key = nameKey(b.displayName);
if (taken(key)) {
// Suffix with an ascending integer until the folded key is free. Guard
// against a pathological blob whose base name already ends in a number
// by folding the candidate each attempt (nameKey normalizes it).
const std::string base = b.displayName;
for (int n = 2;; ++n) {
const std::string candidate = base + " " + std::to_string(n);
const std::string candKey = nameKey(candidate);
if (!taken(candKey)) {
b.displayName = candidate;
key = candKey;
break;
}
}
}
seenKeys.push_back(key);
}
}
banks = std::move(parsedBanks);
return true;
}
} // namespace
void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank) {
banks_ = std::move(banks);
normalizeOrdinals();
// Resolve the active bank defensively: fall back to the pool if the persisted id
// names no bank, so a corrupt active id never leaves a dangling capture target.
activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId);
}
std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
std::vector<Bank> banks;
std::string activeBank;
json::Reader r(blob);
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
BankBook book;
book.adoptBanks(std::move(banks), activeBank);
return book;
}
// ---------------------------------------------------------------------------
// Active-bank cycle ordering (pure, free function — mirror of nextModeId)
// ---------------------------------------------------------------------------
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
const std::string& currentBankId) {
if (orderedBankIds.empty()) return {}; // nothing to cycle to
for (std::size_t i = 0; i < orderedBankIds.size(); ++i) {
if (orderedBankIds[i] == currentBankId)
return orderedBankIds[(i + 1) % orderedBankIds.size()]; // wrap past the last
}
// Active id not in the list (stale/unknown) — jump to the first id as a sane
// home rather than returning "" (matches nextModeId's fallback).
return orderedBankIds.front();
}
BankBook BankBook::loadFromPersisted(const std::string& banksJson,
const std::string& legacyJson) {
// Precedence 1: the authoritative `banks` blob. A present-but-malformed blob is
// an error, not an absence — degrade to an empty book rather than falling through
// to a stale legacy key (which would resurrect superseded single-bank state).
if (!banksJson.empty()) {
auto book = deserialize(banksJson);
return book ? std::move(*book) : BankBook{};
}
// Precedence 2: no `banks` yet, but a legacy `bank_index` — one-way pool migration
// (deserialize's parse-time legacy path promotes it into the pool). A malformed
// legacy blob likewise degrades to empty.
if (!legacyJson.empty()) {
auto book = deserialize(legacyJson);
return book ? std::move(*book) : BankBook{};
}
// Precedence 3: a brand-new / never-captured project — a fresh empty book.
return BankBook{};
}
} // namespace reasampler
+374
View File
@@ -0,0 +1,374 @@
#pragma once
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the
// third instance of the same "pure registry + JSON round-trip, unit-tested outside
// the DAW" pattern as bank_model and view_mode_model.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only.
//
// -- What it is --------------------------------------------------------------
//
// An ordered registry of banks. Each bank = { stable id, display name, ordinal,
// BankModel }. The book WRAPS N BankModel instances — bank_model / BankModel are
// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is
// index-only (remove from source's BankModel, add to destination's); files never
// relocate — banks are logical groupings over one shared file pool.
//
// -- The pool (privileged, not special-cased) --------------------------------
//
// Structurally the pool is bank-zero — one Bank among many, seeded on construction
// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0.
// Semantically it is privileged, and the privileges are enforced HERE in the pure
// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell):
// * always exists (seeded on construction; the book never reaches zero banks)
// * un-deletable (deleteBank rejects the pool)
// * un-renamable (renameBank rejects the pool)
// * un-evacuable (evacuate rejects the pool — the pool is evacuation's
// destination, not a source)
//
// -- Id minting is the CALLER'S job (design decision) ------------------------
//
// createBank takes a caller-supplied stable id, mirroring bank_model's "id
// assigned by the caller" and view_mode_model's mode ids. The pure core has no
// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source
// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2
// shell mint a genuine REAPER GUID while the model stays pure and deterministically
// testable. The model still enforces the invariants: non-empty, unique, not the
// reserved pool id.
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "core/model/bank_model.h"
#include "core/model/slot_map.h"
namespace reasampler {
// Q-W1 interim: this god module re-namespaces in its own split wave; until then the
// clean model types it wraps live in reasampler::model.
using namespace model;
// The pool's fixed identity. The id is reserved: createBank rejects it, and the
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
inline constexpr const char* kPoolBankId = "pool";
inline constexpr const char* kPoolBankName = "Pool";
// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h.
// Included above because Bank carries one per bank.
// One bank: a stable id, a display name, an ordinal (tab/display order), and its
// own BankModel. The pool is the bank whose id == kPoolBankId.
struct Bank {
std::string id; // stable, persisted; the pool's is kPoolBankId
std::string displayName; // mutable for named banks; fixed "Pool" for the pool
int ordinal = 0; // display order; pool is 0, named banks 1..N
BankModel index; // this bank's samples
SlotMap slots; // L7 display positions of this bank's samples (gap-preserving)
bool isPool() const { return id == kPoolBankId; }
bool operator==(const Bank& o) const {
return id == o.id && displayName == o.displayName &&
ordinal == o.ordinal && index == o.index && slots == o.slots;
}
};
// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op
// reports what happened rather than silently mutating on a bad request.
// - Moved / Copied: the sample was transferred to the destination as a new entry.
// - Collapsed: the destination already held the hash; it collapsed onto the
// existing entry (a no-op add on the destination side). For a
// MOVE the source entry is STILL removed; for a COPY the source
// entry is (as always) retained.
// - RejectedUnknownBank: a source or destination id named no bank.
// - RejectedSampleAbsent: the sample id was not in the source bank.
// - RejectedSameBank: source and destination were the same bank (no-op).
enum class TransferResult {
Moved,
Copied,
Collapsed,
RejectedUnknownBank,
RejectedSampleAbsent,
RejectedSameBank,
};
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam —
// live and tested at the model level, promotable later behind this parameter without
// a rewrite, but never wired to an affordance in B5.
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry
// in another bank survives (no cross-bank cascade — dedup is per-bank).
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
// ("purge from the library"). Latent; unsurfaced.
enum class RemoveScope {
ThisBank,
AllBanks,
};
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
// what happened rather than silently mutating on a bad request.
// - Removed: at least one index entry was dropped.
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
enum class RemoveResult {
Removed,
RejectedUnknownBank,
RejectedSampleAbsent,
};
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
// multi-bank phase — mirror of bank_model / view_mode_model.
class BankBook {
public:
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
// active bank = pool; zero named banks.
// -- Bank lifecycle ------------------------------------------------------
// Creates a named bank with the caller-supplied stable id and display name,
// assigning the next ordinal. Rejects (returns false, no mutation) an empty id,
// a duplicate id, the reserved pool id, or a display name that duplicates an
// existing bank's name (including the pool's "Pool"). Display-name uniqueness is
// trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
bool createBank(const std::string& id, const std::string& displayName);
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a
// target name already used by a DIFFERENT bank (trimmed + case-insensitive, as
// createBank). Renaming a bank to its own current name is a no-op success.
bool renameBank(const std::string& id, const std::string& displayName);
// Deletes a NAMED bank, removing it (and its member index entries) from the
// registry. Files are a shell/prune concern and are NOT touched here. Rejects
// (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are
// compacted so the pool stays 0 and named banks stay contiguous 1..N. If the
// deleted bank was active, the active bank falls back to the pool.
bool deleteBank(const std::string& id);
// Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range),
// shifting the others to keep ordinals contiguous. The pool is pinned at 0 and
// cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
bool reorderBank(const std::string& id, int newOrdinal);
// Moves EVERY member of a named bank into the pool (index-only, observing the
// same destination-collapse-by-hash as a move), leaving the bank empty. Rejects
// (false, no mutation) an unknown id or the pool (the pool is the destination,
// never a source). Returns true on success even if the bank was already empty.
bool evacuate(const std::string& id);
// -- Active bank ---------------------------------------------------------
// The active bank's id (the capture target). Defaults to the pool.
const std::string& activeBankId() const { return activeBankId_; }
// Sets the active bank. Rejects (returns false, no change) an id that names no
// bank — an invalid set never corrupts state.
bool setActiveBank(const std::string& id);
// The active bank's BankModel — the index the capture layer adds to. Always
// valid (the active id always names a live bank; it falls back to the pool).
BankModel& activeIndex();
const BankModel& activeIndex() const;
// -- Sample movement (index-only; files never relocate) ------------------
// Moves a sample by id from `fromBankId` to `toBankId`: removes it from the
// source index and adds it to the destination (observing destination
// collapse-by-hash). See TransferResult for the full outcome set.
TransferResult moveSample(const std::string& sampleId,
const std::string& fromBankId,
const std::string& toBankId);
// Copies a sample by id from `fromBankId` to `toBankId`: the source entry is
// retained, the destination gains it (observing destination collapse-by-hash).
// Same hash may then live in both banks — cross-bank dedup is NOT enforced.
TransferResult copySample(const std::string& sampleId,
const std::string& fromBankId,
const std::string& toBankId);
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
// Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
// Index-only and non-destructive to the file: a last-reference remove leaves the
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes.
//
// Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
// `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
// that holds it. See RemoveResult for the outcome set.
// * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
// if that bank does not hold the id; Removed on a drop.
// * AllBanks: `fromBankId` is ignored (the id is purged book-wide);
// RejectedSampleAbsent if NO bank held the id; Removed otherwise.
// No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
RemoveResult removeSample(const std::string& sampleId,
const std::string& fromBankId,
RemoveScope scope = RemoveScope::ThisBank);
// -- Sample display order (L7; index membership untouched) ---------------
// The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid
// iterates, sourced from the bank's SlotMap. Reconciles the map against live index
// membership first (drops stale markers, appends unmapped samples densely), so a
// freshly-migrated or out-of-band-mutated bank always yields a complete order. An
// unknown bank id yields an empty vector. Const-logical but reconciles lazily, so
// it is a non-const member.
std::vector<std::string> orderedSampleIds(const std::string& bankId);
// Ensures every bank's SlotMap is consistent with its index membership: seeds a
// map that has NO overlap with its index from insertion order (the pre-L7 migration
// default — dense, no gaps), and reconciles a partially-populated map (drop stale,
// append unmapped). Idempotent. Called after deserialize and after any capture/
// transfer that added samples out-of-band of the L7 reorder path.
void reconcileSlots();
// Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see
// SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and
// metadata are untouched (capture != placement holds). Reconciles the bank's slots
// first so the target space is complete. Returns false (no mutation) on an unknown
// bank or an id the bank does not hold.
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot);
// Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`)
// takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s
// index (index-only, same semantics as removeSample ThisBank — the file stays on
// disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
// last-reference case). Position of the slot is preserved; only its occupant changes.
//
// POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the
// remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed.
// For the pool this is permitted whenever the occupant exists (per-sample removal
// is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate,
// never per-sample remove). If the removal would be rejected (occupant absent), the
// whole replace is rejected: false, NO mutation (neither the index nor the slots
// change), so the shell can fall back to the default insert-shift or a no-op.
// Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not
// hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority.
bool replaceSample(const std::string& newId, const std::string& oldId,
const std::string& bankId);
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture):
// finds the bank holding `sampleId` and replaces its entry with `updated`
// (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in
// ordinal order and updates the FIRST holder (a sample id is unique within a
// bank; the same id living in two banks via copy would update the earliest, which
// is acceptable — re-capture operates on the panel's focused single selection).
// Returns false (no mutation) if no bank holds the id or the replacement's path
// is absolute. Index-only and non-destructive to the timeline.
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
// Reference-count query backing the confirm-on-last-reference guardrail: does any
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
//
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
// the whole model already reasons in (findByHash / collapse-by-hash), and two
// entries that share content share one file — so "some other bank still references
// this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
// never matched (it does not participate in dedup, mirroring findByHash), so an
// empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
// direction (we cannot prove another bank shares an unhashed file).
bool hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const;
// Every project-relative file path referenced by ANY bank in the book, pool
// included — the union across the whole book (Phase R, prune). This is the
// safety-critical referenced-set the prune core subtracts: a file referenced by
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never
// reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings —
// no normalization), first-seen order across banks in ordinal order then sample
// insertion order, and DE-DUPLICATED (one file referenced by N banks appears
// once). An empty relativePath is skipped (it references no file). Additive
// read-only query; adds no mutation and no coupling to Phase R.
std::vector<std::string> referencedPaths() const;
// -- Query ---------------------------------------------------------------
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
Bank* bank(const std::string& id);
const Bank* bank(const std::string& id) const;
// The bank's BankModel by id, or nullptr. Convenience over bank()->index.
BankModel* index(const std::string& id);
const BankModel* index(const std::string& id) const;
// The pool (always present). Never null.
Bank& pool();
const Bank& pool() const;
// All banks in ordinal order (pool first). The pool is always banks()[0].
const std::vector<Bank>& banks() const { return banks_; }
std::size_t size() const { return banks_.size(); } // >= 1 (the pool)
bool operator==(const BankBook& o) const {
return banks_ == o.banks_ && activeBankId_ == o.activeBankId_;
}
// -- Persistence ---------------------------------------------------------
// Serializes the whole book to a JSON string (lossless round-trip): the pool
// folded in as bank-zero + named banks + per-bank indices + ordinals + active
// id. deserialize(serialize(x)) == x.
std::string serialize() const;
// Parses a book JSON produced by serialize(). std::nullopt on malformed input.
//
// LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an
// object with a "samples" array and no "banks" key) is promoted into the pool's
// index, yielding a book of { pool } with zero named banks — one-way, lossless.
// After migration the book blob is authoritative (the caller persists the book
// shape going forward; the legacy key is retired by the B2 shell).
static std::optional<BankBook> deserialize(const std::string& json);
// Resolve a BankBook from the two persisted ext-state values a project may carry:
// the authoritative `banks` blob and the retired-but-possibly-present legacy
// `bank_index` blob. The persist shell (B2) hands both raw strings straight here so
// the load-source decision stays REAPER-free and unit-tested. Precedence:
// 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is
// MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks`
// blob is an error, not an absence; return an empty book so a stale legacy key
// can never resurrect a superseded single-bank state over a broken book.
// 2. else non-empty `legacyJson` -> deserialize it (one-way pool migration).
// 3. else (both absent/empty) -> a fresh empty book (pool only).
// Never returns nullopt: an unloadable input degrades to the empty book (matching
// the shell's existing "malformed -> ignore, start empty" behaviour), so the caller
// has one branchless install path.
static BankBook loadFromPersisted(const std::string& banksJson,
const std::string& legacyJson);
private:
std::vector<Bank> banks_; // ordinal order; banks_[0] is always the pool
std::string activeBankId_; // always names a live bank; defaults to pool
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check;
// pass exceptId=id to let a bank keep (or re-case/-space) its own name.
bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a
// contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any
// structural change (create / delete / reorder).
void normalizeOrdinals();
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves
// the active bank (falling back to the pool if the id names no bank). Used only
// by deserialize; kept private so the public surface stays create/rename/etc.
void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
};
// The next bank id to activate when cycling the active bank forward, in ordinal
// order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id
// after the last returns the first (pool → named → … → pool). This is the pure
// decision behind the "cycle active bank" action — the shell reads the book's
// ordered bank ids + current active id, asks for the next, and activates it.
// * empty list -> "" (nothing to cycle to)
// * single id (pool-only) -> that id (a one-bank book stays put)
// * currentBankId not present -> the first id (a sane home to jump to)
// Exposed as a free function (not a BankBook member) so it is unit-testable against
// a bare id vector without a full book. Mirror of view_mode_model's nextModeId.
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
const std::string& currentBankId);
} // namespace reasampler
+461
View File
@@ -0,0 +1,461 @@
#include "core/model/bank_model.h"
#include <cctype>
#include "core/json/json.h"
// bank_model implementation.
//
// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer,
// no per-module Parser copy). The field set is a flat struct of primitives,
// strings, one enum, a small string array, and a few optionals, so a compact
// writer + recursive-descent DOMAIN parser over json::Reader is the simplest
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the
// shortest form that round-trips every IEEE-754 double exactly, so the
// deserialize(serialize(x)) == x invariant holds bit-for-bit.
namespace reasampler::model {
// ---------------------------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
bool SourceRange::operator==(const SourceRange& o) const {
return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
startPpq == o.startPpq && endPpq == o.endPpq;
}
bool Provenance::operator==(const Provenance& o) const {
return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot;
}
bool Levels::operator==(const Levels& o) const {
return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs;
}
bool LoopPoints::operator==(const LoopPoints& o) const {
return start == o.start && end == o.end;
}
bool Sample::operator==(const Sample& o) const {
return id == o.id && displayName == o.displayName && relativePath == o.relativePath &&
sourceMode == o.sourceMode && sourceRange == o.sourceRange &&
trackGuids == o.trackGuids && wetDry == o.wetDry &&
channelCount == o.channelCount && sampleRate == o.sampleRate &&
lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats &&
captureTempo == o.captureTempo &&
captureTimeSigNum == o.captureTimeSigNum &&
captureTimeSigDenom == o.captureTimeSigDenom && key == o.key &&
rootNote == o.rootNote && loop == o.loop && levels == o.levels &&
clipped == o.clipped && tier == o.tier && contentHash == o.contentHash &&
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
}
// ---------------------------------------------------------------------------
// path invariant
// ---------------------------------------------------------------------------
// DECISION: reject absolute paths rather than normalize them. The pure model has
// no knowledge of the project root, so it cannot correctly relativize an absolute
// path — any "normalization" would be a guess that could point at the wrong file.
// Rejecting at the boundary is honest and deterministic; the capture backend (M3)
// is responsible for handing us an already-relative path. Covers POSIX ("/x"),
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share")
// forms. Any leading <alpha>: is rejected regardless of the character that follows —
// drive-relative paths ("C:foo.wav") resolve against the drive's current directory,
// not the project root, so they violate the relative-paths-only invariant just as
// much as "C:\foo.wav" does.
static bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true; // Windows drive (C:\, C:/, C:foo, C:)
return false;
}
// ---------------------------------------------------------------------------
// BankModel
// ---------------------------------------------------------------------------
AddResult BankModel::add(const Sample& sample) {
if (sample.id.empty()) return AddResult::RejectedEmptyId;
if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath;
if (findByHash(sample.contentHash) != nullptr)
return AddResult::Collapsed;
samples_.push_back(sample);
return AddResult::Added;
}
bool BankModel::remove(const std::string& id) {
for (auto it = samples_.begin(); it != samples_.end(); ++it) {
if (it->id == id) {
samples_.erase(it);
return true;
}
}
return false;
}
bool BankModel::updateInPlace(const std::string& id, const Sample& updated) {
if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds
for (auto& s : samples_) {
if (s.id == id) {
s = updated; // replace in place — position (insertion order) preserved
return true;
}
}
return false;
}
const Sample* BankModel::query(const std::string& id) const {
for (const auto& s : samples_)
if (s.id == id) return &s;
return nullptr;
}
const Sample* BankModel::findByHash(const std::string& contentHash) const {
if (contentHash.empty()) return nullptr; // empty hashes never dedup
for (const auto& s : samples_)
if (s.contentHash == contentHash) return &s;
return nullptr;
}
bool BankModel::moveTier(const std::string& id, Tier tier) {
for (auto& s : samples_) {
if (s.id == id) {
s.tier = tier;
return true;
}
}
return false;
}
std::vector<Sample> BankModel::byTier(Tier tier) const {
std::vector<Sample> out;
for (const auto& s : samples_)
if (s.tier == tier) out.push_back(s);
return out;
}
// ---------------------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
namespace {
using json::numToStr;
using json::writeEscaped;
using json::writeStringArray;
using ObjWriter = json::Writer;
void writeSample(std::string& out, const Sample& s) {
ObjWriter w(out);
w.keyStr("id", s.id);
w.keyStr("displayName", s.displayName);
w.keyStr("relativePath", s.relativePath);
w.keyRaw("sourceMode", numToStr(static_cast<int>(s.sourceMode)));
w.keyBegin("sourceRange");
{
ObjWriter r(out);
r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds));
r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds));
r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq));
r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq));
}
w.keyBegin("trackGuids");
writeStringArray(out, s.trackGuids);
w.keyRaw("wetDry", numToStr(s.wetDry));
w.keyRaw("channelCount", numToStr(s.channelCount));
w.keyRaw("sampleRate", numToStr(s.sampleRate));
w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds));
w.keyRaw("lengthBeats", numToStr(s.lengthBeats));
w.keyRaw("captureTempo", numToStr(s.captureTempo));
w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum));
w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom));
// Optionals are emitted as null when absent so present/absent round-trips.
w.keyBegin("key");
if (s.key) writeEscaped(out, *s.key); else out += "null";
// Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely —
// parses to empty optionals and re-serializes without invention.
w.keyBegin("rootNote");
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
w.keyBegin("loop");
if (s.loop) {
ObjWriter lp(out);
lp.keyRaw("start", numToStr(s.loop->start));
lp.keyRaw("end", numToStr(s.loop->end));
} else {
out += "null";
}
w.keyBegin("levels");
{
ObjWriter l(out);
l.keyRaw("peakDb", numToStr(s.levels.peakDb));
l.keyRaw("rmsDb", numToStr(s.levels.rmsDb));
l.keyRaw("lufs", numToStr(s.levels.lufs));
}
w.keyRaw("clipped", s.clipped ? "true" : "false");
w.keyRaw("tier", numToStr(static_cast<int>(s.tier)));
w.keyStr("contentHash", s.contentHash);
w.keyBegin("provenance");
if (s.provenance) {
ObjWriter p(out);
p.keyStr("parentSampleId", s.provenance->parentSampleId);
p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot);
} else {
out += "null";
}
w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp));
}
} // namespace
std::string BankModel::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", numToStr(1));
root.keyBegin("samples");
out += '[';
for (std::size_t i = 0; i < samples_.size(); ++i) {
if (i) out += ',';
writeSample(out, samples_[i]);
}
out += ']';
} // root closes the object here — not deferred to function return (NRVO would
// otherwise let the caller observe `out` before the closing brace is appended)
return out;
}
// ---------------------------------------------------------------------------
// JSON parser (recursive descent over the shared json::Reader). Returns false
// on any malformed input; never reads out of bounds. Only supports the subset
// our writer emits. The lexical layer (strings, numbers, skip) lives in
// core/json; only the Sample/index DOMAIN grammar lives here.
// ---------------------------------------------------------------------------
namespace {
bool parseSample(json::Reader& r, Sample& s) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object (shouldn't happen, but valid)
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "id") {
if (!r.parseString(s.id)) return false;
} else if (key == "displayName") {
if (!r.parseString(s.displayName)) return false;
} else if (key == "relativePath") {
if (!r.parseString(s.relativePath)) return false;
} else if (key == "sourceMode") {
int v = 0;
if (!r.parseInt(v)) return false;
// Valid range: MasterMix(0) .. Realtime(5).
if (v < static_cast<int>(SourceMode::MasterMix) ||
v > static_cast<int>(SourceMode::Realtime))
return false;
s.sourceMode = static_cast<SourceMode>(v);
} else if (key == "sourceRange") {
if (!r.consume('{')) return false;
do {
std::string rk;
if (!r.parseKey(rk)) return false;
double dv = 0.0;
if (!r.parseDouble(dv)) return false;
if (rk == "startSeconds") s.sourceRange.startSeconds = dv;
else if (rk == "endSeconds") s.sourceRange.endSeconds = dv;
else if (rk == "startPpq") s.sourceRange.startPpq = dv;
else if (rk == "endPpq") s.sourceRange.endPpq = dv;
} while (r.consume(','));
if (!r.consume('}')) return false;
} else if (key == "trackGuids") {
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
std::string g;
if (!r.parseString(g)) return false;
s.trackGuids.push_back(g);
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "wetDry") {
if (!r.parseDouble(s.wetDry)) return false;
} else if (key == "channelCount") {
if (!r.parseInt(s.channelCount)) return false;
} else if (key == "sampleRate") {
if (!r.parseInt(s.sampleRate)) return false;
} else if (key == "lengthSeconds") {
if (!r.parseDouble(s.lengthSeconds)) return false;
} else if (key == "lengthBeats") {
if (!r.parseDouble(s.lengthBeats)) return false;
} else if (key == "captureTempo") {
if (!r.parseDouble(s.captureTempo)) return false;
} else if (key == "captureTimeSigNum") {
if (!r.parseInt(s.captureTimeSigNum)) return false;
} else if (key == "captureTimeSigDenom") {
if (!r.parseInt(s.captureTimeSigDenom)) return false;
} else if (key == "key") {
bool wasNull = false;
if (!r.expectNullOr(wasNull)) return false;
if (wasNull) {
s.key.reset();
} else {
std::string k;
if (!r.parseString(k)) return false;
s.key = k;
}
} else if (key == "rootNote") {
bool wasNull = false;
if (!r.expectNullOr(wasNull)) return false;
if (wasNull) {
s.rootNote.reset();
} else {
int v = 0;
if (!r.parseInt(v)) return false;
// Valid MIDI note range: 0..127 inclusive (boundaries valid).
if (v < 0 || v > 127) return false;
s.rootNote = v;
}
} else if (key == "loop") {
bool wasNull = false;
if (!r.expectNullOr(wasNull)) return false;
if (wasNull) {
s.loop.reset();
} else {
if (!r.consume('{')) return false;
LoopPoints lp;
do {
std::string lk;
if (!r.parseKey(lk)) return false;
std::int64_t lv = 0;
if (!r.parseInt64(lv)) return false;
if (lk == "start") lp.start = lv;
else if (lk == "end") lp.end = lv;
} while (r.consume(','));
if (!r.consume('}')) return false;
// Invariant: 0 <= start <= end. start == end is a valid zero-length
// marker; a negative index or start > end is malformed, not silently
// clamped (mirrors the enum-range rejection above).
if (lp.start < 0 || lp.end < lp.start) return false;
s.loop = lp;
}
} else if (key == "levels") {
if (!r.consume('{')) return false;
do {
std::string lk;
if (!r.parseKey(lk)) return false;
double dv = 0.0;
if (!r.parseDouble(dv)) return false;
if (lk == "peakDb") s.levels.peakDb = dv;
else if (lk == "rmsDb") s.levels.rmsDb = dv;
else if (lk == "lufs") s.levels.lufs = dv;
} while (r.consume(','));
if (!r.consume('}')) return false;
} else if (key == "clipped") {
if (!r.parseBool(s.clipped)) return false;
} else if (key == "tier") {
int v = 0;
if (!r.parseInt(v)) return false;
// Valid range: Scratch(0) .. Archive(1).
if (v < static_cast<int>(Tier::Scratch) ||
v > static_cast<int>(Tier::Archive))
return false;
s.tier = static_cast<Tier>(v);
} else if (key == "contentHash") {
if (!r.parseString(s.contentHash)) return false;
} else if (key == "provenance") {
bool wasNull = false;
if (!r.expectNullOr(wasNull)) return false;
if (wasNull) {
s.provenance.reset();
} else {
if (!r.consume('{')) return false;
Provenance p;
do {
std::string pk;
if (!r.parseKey(pk)) return false;
std::string pv;
if (!r.parseString(pv)) return false;
if (pk == "parentSampleId") p.parentSampleId = pv;
else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
} while (r.consume(','));
if (!r.consume('}')) return false;
s.provenance = p;
}
} else if (key == "createdTimestamp") {
if (!r.parseInt64(s.createdTimestamp)) return false;
} else {
if (!r.skipValue()) return false; // forward-compat: ignore unknown
}
} while (r.consume(','));
return r.consume('}');
}
bool parseIndex(json::Reader& r, BankModel& out) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object — vacuously an empty index
std::vector<Sample> parsed;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "samples") {
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
Sample s;
if (!parseSample(r, s)) return false;
parsed.push_back(std::move(s));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else {
if (!r.skipValue()) return false; // version, or unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
// Trailing garbage after the root object is malformed.
r.skipWs();
if (!r.eof()) return false;
// Rebuild via add() so the same invariants (relative-path, dedup) that guard
// live inserts also guard deserialized data. Rejected/collapsed entries are
// dropped silently — a well-formed serialized index never triggers them.
for (auto& s : parsed) out.add(s);
return true;
}
} // namespace
std::optional<BankModel> BankModel::deserialize(const std::string& blob) {
BankModel idx;
json::Reader r(blob);
if (!parseIndex(r, idx)) return std::nullopt;
return idx;
}
} // namespace reasampler::model
+217
View File
@@ -0,0 +1,217 @@
#pragma once
// bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so
// it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample
// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query /
// tier moves / dedup-by-hash + JSON round-trip to/from std::string).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace reasampler::model {
// How the source audio was obtained. Kept in the pure core (no REAPER coupling);
// the capture backends (M3/M8) map their own notion onto these.
enum class SourceMode {
MasterMix, // offline render of the master output
SelectedTracks, // offline render of selected tracks
SelectedItems, // offline render of selected media items
TimeSelection, // offline render bounded by the time selection
RazorArea, // offline render of a razor edit area
Realtime, // realtime record of wet output
};
// Retention tier. `Scratch` is auto-prunable working material; `Archive` is kept.
enum class Tier {
Scratch,
Archive,
};
// Sample-accurate source bounds, in both project seconds and PPQ (ticks). Both
// are stored because capture needs seconds and musical placement needs PPQ; we
// refuse to re-derive one from the other and risk rounding (precision invariant).
struct SourceRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
double startPpq = 0.0;
double endPpq = 0.0;
bool operator==(const SourceRange& o) const;
};
// Present only when a sample was resampled FROM another sample. Carries the
// parent's id and the FX-chain snapshot string (a thin drift fingerprint, NOT a
// restorable chunk) captured at resample time; the re-capture-from-source action
// (M10) uses it to detect chain drift and replay the original capture request.
struct Provenance {
std::string parentSampleId;
std::string fxChainSnapshot;
bool operator==(const Provenance& o) const;
};
// Loudness / level metrics measured from the captured file.
struct Levels {
double peakDb = 0.0;
double rmsDb = 0.0;
double lufs = 0.0;
bool operator==(const Levels& o) const;
};
// Sample-accurate sustain-loop bounds, as frame indices into the captured file
// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like
// sampleRate or length — consumed by the future MIDI-playback instrument to hold
// notes past the recorded length. Modeled as one optional struct (not two loose
// optionals) so "both points or neither" is a structural invariant, not a rule to
// re-check at every boundary. Frame indices, not seconds, because the loop is a
// per-sample-frame contract; the instrument reads the file's sample rate to relate
// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end.
// start == end is a valid zero-length loop marker.
struct LoopPoints {
std::int64_t start = 0;
std::int64_t end = 0;
bool operator==(const LoopPoints& o) const;
};
// The metadata record for one captured sample. The audio itself lives in a
// project-relative file; `relativePath` is ALWAYS relative (enforced at the
// BankModel::add boundary — see AddResult).
struct Sample {
std::string id; // stable unique id (assigned by the caller)
std::string displayName;
std::string relativePath; // project-relative; never absolute (invariant)
SourceMode sourceMode = SourceMode::MasterMix;
SourceRange sourceRange;
// Track GUID(s) the capture came from, when applicable (empty otherwise).
std::vector<std::string> trackGuids;
double wetDry = 1.0; // 1.0 = fully wet, 0.0 = fully dry
int channelCount = 0;
int sampleRate = 0;
double lengthSeconds = 0.0;
double lengthBeats = 0.0;
double captureTempo = 0.0; // project tempo (BPM) at capture time
// Time signature at capture time (L7 F1 — stamped alongside captureTempo so the
// bars.beats.subdivisions read-out is stable under later project meter changes).
// 0/0 means UNSTAMPED (pre-L7 sample, or a capture that could not read the meter);
// the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms.
int captureTimeSigNum = 0; // meter numerator (e.g. 4 in 4/4); 0 = unstamped
int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped
std::optional<std::string> key; // musical key, when known
// Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument,
// additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S
// samples deserialize without them and re-serialize without inventing values.
// - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument
// can repitch it across the keyboard. DISTINCT from the musical `key` above:
// `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch.
// Populated at/after capture only where derivable — left empty (never guessed)
// when the source is not a single played note.
// - loop: sustain-loop bounds, populated only where explicitly set.
std::optional<int> rootNote;
std::optional<LoopPoints> loop;
Levels levels;
bool clipped = false;
Tier tier = Tier::Scratch;
std::string contentHash; // dedup key (see BankModel)
std::optional<Provenance> provenance; // set only when resampled
std::int64_t createdTimestamp = 0; // unix epoch seconds
bool operator==(const Sample& o) const;
bool operator!=(const Sample& o) const { return !(*this == o); }
// A scratch-tier sample is auto-prunable; archive is kept.
bool isAutoPrunable() const { return tier == Tier::Scratch; }
};
// Outcome of BankModel::add. `add` rejects rather than silently mutating:
// - RejectedAbsolutePath: relativePath was absolute (precision invariant).
// - RejectedEmptyId: id was empty (the collection is keyed by id).
// - Collapsed: content hash matched an existing entry; the existing
// entry is kept and the add is a no-op (dedup).
// - Added: inserted as a new entry.
enum class AddResult {
Added,
Collapsed,
RejectedAbsolutePath,
RejectedEmptyId,
};
// An ordered, id-keyed collection of Samples with content-hash dedup, tier
// moves/filtering, and lossless JSON round-trip. Insertion order is preserved
// so a future panel (M5) can iterate in stable order.
class BankModel {
public:
// Adds a sample. Enforces the relative-paths-only invariant and dedups by
// content hash (an equal-hash add collapses onto the existing entry rather
// than duplicating). See AddResult for the full outcome set.
AddResult add(const Sample& sample);
// Removes the sample with `id`. Returns true if one was removed.
bool remove(const std::string& id);
// Replaces the sample carrying `id` IN PLACE (preserving its position in
// insertion order), with `updated`. Used by M10 re-capture-from-source: a
// provenanced sample's file is regenerated and its metadata (relativePath,
// contentHash, levels, timestamp, ...) refreshed while its identity (id) and
// slot are kept, so the bank panel shows the same tile updated rather than a
// reordered new entry. `updated.id` should equal `id` (the caller keeps the id
// stable); a differing id is written through as given (the caller's contract).
// Does NOT dedup — an in-place refresh of one entry is not a new insert, so the
// collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false
// (no mutation) if `id` is absent or `updated.relativePath` is absolute
// (the relative-paths-only invariant still holds for the replacement).
bool updateInPlace(const std::string& id, const Sample& updated);
// Returns the sample with `id`, or nullptr if absent. The pointer is
// invalidated by any mutating call.
const Sample* query(const std::string& id) const;
// Returns the sample whose contentHash matches, or nullptr. Empty hashes are
// never matched (they do not participate in dedup).
const Sample* findByHash(const std::string& contentHash) const;
// Moves the sample with `id` to `tier`. Returns true if the sample existed.
bool moveTier(const std::string& id, Tier tier);
// Returns copies of all samples in the given tier, in insertion order.
std::vector<Sample> byTier(Tier tier) const;
// All samples in insertion order.
const std::vector<Sample>& all() const { return samples_; }
std::size_t size() const { return samples_.size(); }
bool empty() const { return samples_.empty(); }
bool operator==(const BankModel& o) const { return samples_ == o.samples_; }
// Serializes the whole index to a JSON string (lossless round-trip).
std::string serialize() const;
// Parses a JSON string produced by serialize(). Returns std::nullopt on
// malformed / truncated input (error signaled, never UB). On success the
// returned index satisfies deserialize(serialize(x)) == x.
static std::optional<BankModel> deserialize(const std::string& json);
private:
std::vector<Sample> samples_; // insertion order preserved
};
} // namespace reasampler::model
+113
View File
@@ -0,0 +1,113 @@
#include "core/model/owned_manifest.h"
#include <cctype>
#include "core/json/json.h"
// owned_manifest implementation.
//
// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model /
// bank_book / tail_control). The shape is a single object with one string array:
//
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
//
// so a compact writer + a focused string-array domain parse is all it needs.
namespace reasampler::model {
// ---------------------------------------------------------------------------
// path invariant (mirror of bank_model's isAbsolutePath)
// ---------------------------------------------------------------------------
namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to
// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant
// must match exactly (a path the index accepts must be recordable, and vice versa).
bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true;
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true;
return false;
}
} // namespace
// ---------------------------------------------------------------------------
// mutation / query
// ---------------------------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath;
if (contains(relativePath)) return ManifestAddResult::AlreadyPresent;
paths_.push_back(relativePath);
return ManifestAddResult::Added;
}
bool OwnedFileManifest::contains(const std::string& relativePath) const {
for (const auto& p : paths_)
if (p == relativePath) return true;
return false;
}
// ---------------------------------------------------------------------------
// JSON writer (shared core/json escape — byte-identical to the prior local one)
// ---------------------------------------------------------------------------
std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":[";
for (std::size_t i = 0; i < paths_.size(); ++i) {
if (i) out += ',';
json::writeEscaped(out, paths_[i]);
}
out += "]}";
return out;
}
// ---------------------------------------------------------------------------
// JSON parser (string-array-only DOMAIN grammar over the shared core/json
// lexical layer). Tolerates unknown keys (forward-compat) and requires the
// "owned" value to be an array of strings.
// ---------------------------------------------------------------------------
namespace {
bool parseManifest(json::Reader& r, OwnedFileManifest& out) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object -> empty manifest
for (;;) {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "owned") {
std::vector<std::string> paths;
if (!r.parseStringArray(paths)) return false;
for (auto& p : paths) {
// Feed through add() so the persisted invariants (dedup, reject
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
// blob cannot smuggle an absolute or duplicate path into the manifest.
out.add(p);
}
} else {
if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys
}
r.skipWs();
if (r.consume(',')) continue;
if (r.consume('}')) return true;
return false;
}
}
} // namespace
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& blob) {
OwnedFileManifest m;
json::Reader r(blob);
if (!parseManifest(r, m)) return std::nullopt;
return m;
}
} // namespace reasampler::model
+91
View File
@@ -0,0 +1,91 @@
#pragma once
// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
// "small pure type + JSON round-trip" pattern as wav_trim / tab_strip.
//
// -- What it is --------------------------------------------------------------
//
// The set of files the bank system ITSELF created — every file the capture path
// writes gets recorded here. Phase R prune consumes it to tell the system's own
// orphans (owned ∩ present referenced) apart from hand-dropped files. B-cap only
// WRITES and PERSISTS the manifest; no prune logic lives here (fork R-D, settled
// 2026-07-24: "defer the feature, design the seam").
//
// -- What it is NOT ----------------------------------------------------------
//
// It is NOT a mirror of the bank index. Removing or moving an index entry does NOT
// remove the file's manifest record: the manifest tracks files *created*, and prune
// (Phase R) reconciles manifest-vs-index later. The ONLY thing that adds to it is
// the capture add-path. There is deliberately no remove verb here.
//
// -- The relative-paths-only invariant ---------------------------------------
//
// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath
// and the persisted BankModel). add() rejects an absolute path rather than guess a
// relativization — the pure model has no project root, so a "normalization" would be
// a guess that could point at the wrong file (mirror of BankModel::add's rejection).
#include <optional>
#include <string>
#include <vector>
namespace reasampler::model {
// Outcome of an add(). Mirrors BankModel::AddResult's honesty — the op reports what
// happened rather than silently mutating on a bad request.
// - Added: the path was new and recorded.
// - AlreadyPresent: the path was already in the manifest (dedup no-op).
// - RejectedEmptyPath: the path was empty.
// - RejectedAbsolutePath: the path was absolute (relative-paths-only invariant).
enum class ManifestAddResult {
Added,
AlreadyPresent,
RejectedEmptyPath,
RejectedAbsolutePath,
};
// The owned-file manifest: an insertion-ordered, deduplicated set of project-relative
// paths the capture path has created. Insertion order is preserved so serialize()
// round-trips byte-identically (deterministic ext-state, mirror of the index).
class OwnedFileManifest {
public:
OwnedFileManifest() = default;
// Record a project-relative path as owned. Rejects an empty or absolute path (no
// mutation). A path already present is a dedup no-op (AlreadyPresent), so a repeat
// capture of an identical request does not double-record.
ManifestAddResult add(const std::string& relativePath);
// True iff the exact path string is recorded. Phase R uses this to attribute a
// present file to the bank system. Exact string match — path normalization (if any)
// is the caller's concern, consistent across add and query.
bool contains(const std::string& relativePath) const;
// The owned paths in insertion order. Phase R unions this with the on-disk file
// set; here it is the round-trip + query surface.
const std::vector<std::string>& paths() const { return paths_; }
std::size_t size() const { return paths_.size(); }
bool empty() const { return paths_.empty(); }
bool operator==(const OwnedFileManifest& o) const { return paths_ == o.paths_; }
// -- Persistence ---------------------------------------------------------
// Serialize to a JSON string (lossless round-trip): deserialize(serialize(x)) == x.
// An empty manifest serializes to a well-formed empty shape (round-trips to empty).
std::string serialize() const;
// Parse a manifest JSON produced by serialize(). std::nullopt on malformed input
// (the persist shell warns + falls back to an empty manifest, mirroring the bank /
// view malformed handling). An empty/absent stored value is the caller's concern
// (an empty string is not valid JSON) — the shell maps absence to a fresh manifest.
static std::optional<OwnedFileManifest> deserialize(const std::string& json);
private:
std::vector<std::string> paths_; // insertion order; deduplicated
};
} // namespace reasampler::model
+162
View File
@@ -0,0 +1,162 @@
#include "core/model/provenance.h"
#include <cstdio>
#include "core/wire/wire.h"
// provenance implementation — pure, self-contained (no third-party lib, mirror of
// bank_model's hand-rolled encoding discipline).
//
// ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it
// is unambiguous and forge-proof (a value containing the separator cannot shift
// the parse). Grammar:
//
// "rsprov1" -- magic + version tag
// then, in fixed order, each field as <len>':'<bytes>
//
// Every field — including numbers — is emitted as its decimal / %.17g text then
// length-prefixed, so the parser never has to guess a field boundary. A trailing
// field is the track-GUID count followed by that many length-prefixed GUIDs, then
// the folded fxChainIdentity. Numbers use the SAME %.17g the bank model uses so a
// double round-trips bit-for-bit. Any deviation (wrong magic, short read, bad
// number) -> parseFingerprint returns nullopt.
//
// The fxChainIdentity fold is itself length-prefixed per entry field, so it is
// injection-proof on its own and can be embedded whole as one more length-prefixed
// field of the fingerprint.
namespace reasampler::model {
bool CaptureRecipe::operator==(const CaptureRecipe& o) const {
return scope == o.scope && sourceMode == o.sourceMode &&
startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
tailMode == o.tailMode && tailMs == o.tailMs &&
sampleRate == o.sampleRate && channelCount == o.channelCount &&
trackGuids == o.trackGuids && fxChainIdentity == o.fxChainIdentity;
}
namespace {
constexpr const char* kMagic = "rsprov1";
// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full
// hardening (incl. the fixed fieldInt range check that closes the old strtol
// silent-narrowing TODO). Only the %.17g double rendering stays local — it is
// this writer's convention, shared with the bank model's JSON doubles.
using wire::putField;
using Cursor = wire::Cursor;
std::string dblToStr(double v) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.17g", v);
return buf;
}
} // namespace
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries) {
std::string out;
// Count first, then each entry's three fields length-prefixed. Order is part of
// identity (chain order matters), so we emit in the given vector order.
putField(out, std::to_string(entries.size()));
for (const FxIdentityEntry& e : entries) {
putField(out, e.name);
putField(out, e.guid);
putField(out, e.enabled ? "1" : "0");
}
return out;
}
std::string combineChainIdentities(const std::vector<std::string>& perTrack) {
std::string out;
putField(out, std::to_string(perTrack.size()));
for (const std::string& id : perTrack) putField(out, id);
return out;
}
std::string buildFingerprint(const CaptureRecipe& r) {
std::string out(kMagic);
putField(out, std::to_string(static_cast<int>(r.scope)));
putField(out, std::to_string(r.sourceMode));
putField(out, dblToStr(r.startSeconds));
putField(out, dblToStr(r.endSeconds));
putField(out, std::to_string(r.tailMode));
putField(out, dblToStr(r.tailMs));
putField(out, std::to_string(r.sampleRate));
putField(out, std::to_string(r.channelCount));
putField(out, std::to_string(r.trackGuids.size()));
for (const std::string& g : r.trackGuids) putField(out, g);
putField(out, r.fxChainIdentity);
return out;
}
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
Cursor c(fingerprint);
if (!c.literal(kMagic)) return std::nullopt;
CaptureRecipe r;
int scopeInt = 0;
if (!c.fieldInt(scopeInt)) return std::nullopt;
if (scopeInt != static_cast<int>(ProvenanceScope::Item) &&
scopeInt != static_cast<int>(ProvenanceScope::Track))
return std::nullopt;
r.scope = static_cast<ProvenanceScope>(scopeInt);
if (!c.fieldInt(r.sourceMode)) return std::nullopt;
if (!c.fieldDouble(r.startSeconds)) return std::nullopt;
if (!c.fieldDouble(r.endSeconds)) return std::nullopt;
if (!c.fieldInt(r.tailMode)) return std::nullopt;
if (!c.fieldDouble(r.tailMs)) return std::nullopt;
if (!c.fieldInt(r.sampleRate)) return std::nullopt;
if (!c.fieldInt(r.channelCount)) return std::nullopt;
std::size_t guidCount = 0;
if (!c.fieldSizeT(guidCount)) return std::nullopt;
// Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least
// 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the
// reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge)
// into std::length_error / bad_alloc through the shell.
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
r.trackGuids.reserve(guidCount);
for (std::size_t i = 0; i < guidCount; ++i) {
std::string g;
if (!c.field(g)) return std::nullopt;
r.trackGuids.push_back(std::move(g));
}
if (!c.field(r.fxChainIdentity)) return std::nullopt;
// Trailing garbage means the string was not produced by our writer -> reject,
// so a corrupt/extended blob never silently drives a partial re-capture.
if (!c.ok() || !c.atEnd()) return std::nullopt;
return r;
}
std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles) {
if (sourceItemFiles.empty()) return std::nullopt;
std::optional<std::string> parent; // the single bank sample all sources point at
for (const std::string& src : sourceItemFiles) {
// Resolve this source file against the bank by exact normalized path.
const std::string* matchedId = nullptr;
for (const BankFileRef& ref : bankFiles) {
if (!ref.absolutePath.empty() && ref.absolutePath == src) {
matchedId = &ref.sampleId;
break;
}
}
if (matchedId == nullptr)
return std::nullopt; // a source item is NOT a bank file -> not a resample
if (!parent) {
parent = *matchedId;
} else if (*parent != *matchedId) {
return std::nullopt; // sources span >1 bank sample -> ambiguous, no parent
}
}
return parent;
}
} // namespace reasampler::model
+149
View File
@@ -0,0 +1,149 @@
#pragma once
// provenance — the REAPER-free core behind Milestone 10 (re-capture from source).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The shell (main.cpp / actions.cpp)
// gathers the raw inputs from REAPER — the source item media-file names, the
// source track FX-chain identity (names / GUIDs / enabled flags), the exact
// capture range, scope, tail — and hands plain strings/values here. This module
// owns:
//
// * CaptureRecipe — the recorded capture request PLUS the source FX-chain
// identity at capture time. Everything "re-capture from
// source" needs to re-run the SAME request against the
// source's CURRENT state, and to tell whether the source
// drifted since capture.
// * the ENCODING of a recipe into the single `Provenance.fxChainSnapshot`
// string (M1's field already JSON-round-trips one string,
// so the whole thin fingerprint rides in it — no schema
// change to Sample).
// * fxChainIdentity — folds the shell-gathered FX-chain rows into one identity
// string (the drift-detection component of the fingerprint).
// * detectParent — the pure parent-detection decision: given the resolved
// absolute media-file path(s) of the capture's source item(s)
// and the bank's path->sampleId map, decide whether this
// capture genuinely derives from a bank sample (P1: identity
// by resolved file path only — no fuzzy match, no false
// parentage).
//
// Fork picks (docs/product/provenance.md, settled 2026-07-23): P1 = a THIN
// reproducibility fingerprint (drift-detect + re-run the same request), NOT a
// serialized FX chunk to restore. P2 = bank-only re-capture. So nothing here
// stores a restorable chain, and nothing here reaches into view_mode_model.
#include <optional>
#include <string>
#include <vector>
namespace reasampler::model {
// Capture scope, mirrored from render_settings' CaptureScope but kept independent
// here so the pure provenance module does not pull the whole render_settings graph
// in. The shell maps its CaptureScope onto this two-value enum. Item = item/take FX
// only; Track = item FX + the track's own FX (CLAUDE.md §Capture FX scope).
enum class ProvenanceScope {
Item,
Track,
};
// One FX-chain entry as the shell reads it from REAPER. For Track scope the shell
// uses TrackFX_GetFXName/GetFXGUID/GetEnabled; for Item scope it uses the TakeFX_*
// equivalents over the active take's FX chain. Plain data — the shell fills it, the
// pure fold turns the vector into the identity string.
struct FxIdentityEntry {
std::string name; // TrackFX_GetFXName / TakeFX_GetFXName
std::string guid; // TrackFX_GetFXGUID / TakeFX_GetFXGUID -> guidToString
bool enabled; // TrackFX_GetEnabled / TakeFX_GetEnabled
};
// The recorded capture recipe + source FX-chain identity — the thin fingerprint.
// Re-capture replays the request fields verbatim against the source's CURRENT
// state; fxChainIdentity is compared post-hoc to report drift. Ordinary equality
// (via ==) is a full recipe match; fxChainIdentity difference alone is "the source
// drifted but the recipe is the same" (the re-run still succeeds, the user is told).
struct CaptureRecipe {
ProvenanceScope scope = ProvenanceScope::Track;
int sourceMode = 0; // reasampler::SourceMode as int (bank_model)
double startSeconds = 0.0; // exact bounds — no rounding (invariant)
double endSeconds = 0.0;
int tailMode = 0; // reasampler::TailMode as int (render_settings)
double tailMs = 0.0;
int sampleRate = 0; // 0 = follow project rate
int channelCount = 2;
// Canonical GUID strings of the source track(s) the capture came from
// (guidString form). Re-capture resolves these back to live tracks.
std::vector<std::string> trackGuids;
// The source FX-chain identity at capture time — the drift component. A folded
// string (fxChainIdentity) of the in-scope FX rows. Not a restorable chunk.
std::string fxChainIdentity;
bool operator==(const CaptureRecipe& o) const;
bool operator!=(const CaptureRecipe& o) const { return !(*this == o); }
};
// Folds the shell-gathered FX rows into ONE identity string. Order-sensitive
// (chain order is part of identity), delimited so a name containing the delimiter
// cannot forge a different chain (the fields are length-prefixed). Empty vector ->
// empty string (a no-FX source has an empty, stable identity). Pure + deterministic.
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries);
// Combines several per-track FX-chain identity strings (one per source track, in
// track order) into ONE identity, length-prefixing each so two different per-track
// partitions can never collide by concatenation (e.g. {"X",""} != {"","X"}). Used
// for a multi-track Track-scope capture. A single-track capture combines to a
// stable, unambiguous wrapping of its one identity. Pure + deterministic.
std::string combineChainIdentities(const std::vector<std::string>& perTrack);
// Encodes a CaptureRecipe into the single string stored in
// Provenance.fxChainSnapshot. Self-describing, versioned, and escape-safe so it
// round-trips losslessly through the Sample JSON (which treats the whole thing as
// one opaque string value). buildFingerprint(x) then parseFingerprint(...) == x.
std::string buildFingerprint(const CaptureRecipe& recipe);
// Parses a fingerprint produced by buildFingerprint. Returns nullopt on any
// malformed / unrecognized-version input (never throws, never UB) so a legacy or
// corrupt provenance string degrades to "no recipe" gracefully rather than
// mis-driving a re-capture.
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint);
// --- Parent detection (P1: identity by resolved file path) -------------------
// One bank sample as the detector sees it: its stable id and the ABSOLUTE,
// normalized path its file resolves to (the shell resolves relativePath against
// the current project dir via resolveBankFile + normalizeSlashes before handing
// it here). Plain data so the decision is pure and testable.
struct BankFileRef {
std::string sampleId;
std::string absolutePath; // normalized (forward-slash, no trailing slash)
};
// Decides whether a capture derives from a bank sample.
//
// RULE (stated for the handoff, honest — no false parentage): a capture derives
// from a bank sample iff EVERY source item whose media file could be resolved
// points at the SAME bank sample's file (by exact normalized absolute path). If
// the source items resolve to files not in the bank, or to MORE THAN ONE distinct
// bank sample (ambiguous parentage), no parent is recorded. An empty source-file
// set (nothing resolvable) yields no parent.
//
// sourceItemFiles : normalized absolute paths of the capture's source items'
// take media files (the shell gathers + normalizes them). A
// file that could not be resolved is simply omitted by the
// shell — it never becomes an empty string here.
// bankFiles : the active book's samples as BankFileRefs (path -> id).
//
// Returns the parent sample id, or nullopt when the capture is not a genuine
// resample-from-sample. Comparison is exact path identity; the caller normalizes
// both sides identically via normalizeSlashes (which lowercases on Windows) so a
// slash/case difference never spuriously matches or misses. On Windows both sides
// are lowercased before they reach here; on macOS/Linux they are case-exact.
std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles);
} // namespace reasampler::model
+145
View File
@@ -0,0 +1,145 @@
#include "core/model/slot_map.h"
#include <algorithm>
#include "core/json/json.h"
// slot_map implementation (extracted from bank_book, Q-W1 T4-05).
//
// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one
// slot per id. Every mutator restores it; queries assume it. serialize rides the
// shared core/json emit helpers — the emitted fragment is byte-identical to the
// pre-extraction bank_book writer.
namespace reasampler::model {
void SlotMap::sortBySlot() {
std::stable_sort(entries_.begin(), entries_.end(),
[](const Entry& a, const Entry& b) { return a.slot < b.slot; });
}
int SlotMap::slotOf(const std::string& id) const {
for (const auto& e : entries_)
if (e.id == id) return e.slot;
return -1;
}
std::string SlotMap::idAt(int slot) const {
for (const auto& e : entries_)
if (e.slot == slot) return e.id;
return {};
}
int SlotMap::maxSlot() const {
int m = -1;
for (const auto& e : entries_)
if (e.slot > m) m = e.slot;
return m;
}
std::vector<std::string> SlotMap::orderedIds() const {
// entries_ is sorted ascending by slot, so a straight walk is display order.
std::vector<std::string> out;
out.reserve(entries_.size());
for (const auto& e : entries_) out.push_back(e.id);
return out;
}
void SlotMap::append(const std::string& id) {
if (id.empty()) return;
remove(id); // an existing id is re-appended, not left in place
entries_.push_back(Entry{id, maxSlot() + 1}); // next free slot after the last occupied
sortBySlot();
}
bool SlotMap::remove(const std::string& id) {
for (auto it = entries_.begin(); it != entries_.end(); ++it) {
if (it->id == id) {
entries_.erase(it); // leaves the slot empty — no re-pack
return true;
}
}
return false;
}
bool SlotMap::reorder(const std::string& id, int targetSlot) {
if (slotOf(id) < 0) return false; // not mapped -> no mutation
if (targetSlot < 0) targetSlot = 0;
if (slotOf(id) == targetSlot) return false; // already there — true no-op
// Detach the moving id first so the occupancy test below sees the post-move world.
remove(id);
const bool occupied = !idAt(targetSlot).empty();
if (occupied) {
// Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one,
// preserving relative order and interior gaps above the target. The moving id then
// takes targetSlot cleanly.
for (auto& e : entries_)
if (e.slot >= targetSlot) ++e.slot;
}
entries_.push_back(Entry{id, targetSlot});
sortBySlot();
return true;
}
void SlotMap::resetDense(const std::vector<std::string>& ids) {
entries_.clear();
int slot = 0;
for (const auto& id : ids) {
if (id.empty()) continue;
if (slotOf(id) >= 0) continue; // skip a duplicate id (one slot per id)
entries_.push_back(Entry{id, slot++});
}
// Already ascending by construction; no sort needed.
}
void SlotMap::reconcile(const std::vector<std::string>& liveIds) {
// Drop markers whose sample left the index.
entries_.erase(
std::remove_if(entries_.begin(), entries_.end(),
[&](const Entry& e) {
return std::find(liveIds.begin(), liveIds.end(), e.id) ==
liveIds.end();
}),
entries_.end());
// Append live ids that have no mapping yet (out-of-band index growth), in liveIds
// order, each to the next free slot after the current frontier.
for (const auto& id : liveIds)
if (slotOf(id) < 0) append(id);
sortBySlot();
}
bool SlotMap::operator==(const SlotMap& o) const {
return entries_ == o.entries_;
}
SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pairs) {
SlotMap m;
for (const auto& [id, slot] : pairs) {
if (id.empty() || slot < 0) continue; // drop malformed pair
if (m.slotOf(id) >= 0) continue; // duplicate id: first wins
if (!m.idAt(slot).empty()) continue; // slot taken: never double-occupy
m.entries_.push_back(Entry{id, slot});
}
m.sortBySlot();
return m;
}
std::string SlotMap::serialize() const {
// Array of {id, slot} objects in ascending slot order (entries_ is kept sorted).
// json::Writer + numToStr are the same emit path the pre-extraction writer used,
// so the fragment is byte-identical.
std::string out;
out += '[';
for (std::size_t i = 0; i < entries_.size(); ++i) {
if (i) out += ',';
json::Writer e(out);
e.keyStr("id", entries_[i].id);
e.keyRaw("slot", json::numToStr(entries_[i].slot));
}
out += ']';
return out;
}
} // namespace reasampler::model
+106
View File
@@ -0,0 +1,106 @@
#pragma once
// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled:
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty
// first row above an occupied second row). At most one id per slot (a slot is never
// double-occupied) and at most one slot per id (an id sits in exactly one place).
//
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one
// sample into two banks may sit at different slots, so position is a per-bank display
// concern owned by the bank's membership. bank_model / Sample stay untouched.
//
// Extracted from bank_book (Q-W1, T4-05): a self-contained ordered-slot container
// with its own serialize, distinct from the multi-bank registry that carries it.
// Behavior covered by bank_book_tests (the round-trip + reorder/reconcile suites);
// a dedicated slot_map_tests target is a welcome follow-up, not a Q-W1 requirement.
//
// PURE: standard library + core/json (serialize) only.
#include <cstddef>
#include <string>
#include <utility>
#include <vector>
namespace reasampler::model {
class SlotMap {
public:
// The slot an id occupies, or -1 if the id is not mapped. O(N).
int slotOf(const std::string& id) const;
// The id occupying `slot`, or "" if the slot is empty. O(N).
std::string idAt(int slot) const;
// The highest occupied slot, or -1 when the map is empty. Defines the append
// frontier and (with trailing-empty trim) the content extent.
int maxSlot() const;
// Ids in ASCENDING slot order (the deterministic display order). Empty slots
// produce no entry — the caller iterates occupants; sparse layout is a draw
// concern that reads slotOf/idAt, not this list.
std::vector<std::string> orderedIds() const;
// Places `id` at the next free slot after the last occupied one (append). If the
// id is already mapped it is first removed (leaving its old slot empty), then
// appended — an append never fills an earlier gap. No-op guard: empty id ignored.
void append(const std::string& id);
// Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id
// keeps its position. Returns true if the id was mapped.
bool remove(const std::string& id);
// Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics):
// * target slot EMPTY -> `id` moves there; its old slot is left empty.
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one,
// preserving their relative order and never colliding. Matches file-manager
// reorder. Interior gaps between shifted occupants are preserved as-is
// (shift is +1 on each occupant, so the gap structure above the target is kept).
// * negative targetSlot is clamped to 0.
// Returns false (no mutation) if `id` is not mapped. Deterministic.
bool reorder(const std::string& id, int targetSlot);
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]),
// dropping any prior state. The migration path: a pre-L7 bank with no persisted
// slot data is seeded from its BankModel insertion order, densely packed (no gaps),
// so it is visually identical on first post-L7 load. Empty/duplicate ids skipped.
void resetDense(const std::vector<std::string>& ids);
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
// the index) and appends any live id that has NO mapping yet (a sample the index
// gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps
// the map consistent with the bank's membership without a re-pack. Deterministic:
// orphan appends follow `liveIds` order.
void reconcile(const std::vector<std::string>& liveIds);
bool empty() const { return entries_.empty(); }
std::size_t size() const { return entries_.size(); }
bool operator==(const SlotMap& o) const;
// JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the
// bank envelope's "slots" member by BankBook::serialize; parsed back by its parser.
// Round-trips losslessly with the rest of the bank.
std::string serialize() const;
// Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces
// the map invariants defensively against a hand-edited blob: a duplicate id keeps
// its FIRST occurrence; a slot already taken by a kept id drops the later pair
// (never double-occupies); an empty id or negative slot is dropped. The result is
// sorted ascending by slot. reconcile() against live membership runs afterward, so
// a lossy repair here degrades gracefully rather than corrupting lookup.
static SlotMap fromEntries(const std::vector<std::pair<std::string, int>>& pairs);
private:
struct Entry {
std::string id;
int slot = 0;
bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; }
};
std::vector<Entry> entries_; // kept sorted ascending by slot (invariant)
void sortBySlot();
};
} // namespace reasampler::model
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// core/namespaces.h — Q-W1 INTERIM flat-namespace shim for the not-yet-split
// god/shell TUs (bank_panel / actions / persist / ingest / main / view / capture
// shells / the VST editor+processor). The Q-W1 sub-namespaces move every clean pure
// module's symbols out of the flat `reasampler` namespace; the god modules keep their
// pre-split internals, which reference those symbols unqualified (or qualified as
// `reasampler::X`). Nominating every sub-namespace inside `reasampler` restores both
// forms ([namespace.qual]p2 routes qualified lookup through using-directives), so the
// god internals stay untouched until their own split waves.
//
// SCOPE CONTRACT: included ONLY by god/shell TUs pending their split wave
// (Q-W2/Q-W2v/Q-W3/Q-W4/Q-W5). Clean core modules must NOT include this — they
// reference cross-subsystem symbols by their real namespace homes. Each split wave
// drops this include from the TUs it rewrites; when the last split lands, delete
// this header.
namespace reasampler {
namespace model {}
namespace view {}
namespace capture {}
namespace audio {}
namespace ui {}
namespace reclaim {}
namespace version {}
namespace json {}
namespace util {}
namespace wire {}
namespace instrument {
namespace engine {}
namespace map {}
namespace ui {}
} // namespace instrument
using namespace model;
using namespace view;
using namespace capture;
using namespace audio;
using namespace ui;
using namespace reclaim;
using namespace version;
using namespace util;
using namespace wire;
using namespace instrument::engine;
using namespace instrument::map;
using namespace instrument::ui;
} // namespace reasampler
+86
View File
@@ -0,0 +1,86 @@
#include "core/reclaim/prune_reconcile.h"
#include <unordered_set>
// prune_reconcile implementation — the one set-algebra computation, kept trivially
// auditable: build the referenced and owned lookup sets, then walk `present` once,
// keeping a path iff it is owned AND not referenced. Walking `present` (not owned)
// gives the ∩-present clause for free and yields output in folder-enumeration order.
namespace reasampler::reclaim {
std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned) {
// Exact-string membership — the model's canonical relative-path comparison
// (Sample.relativePath / OwnedFileManifest::contains). std::string hashes/compares
// byte-for-byte, so no normalization creeps in.
const std::unordered_set<std::string> referencedSet(referenced.begin(),
referenced.end());
const std::unordered_set<std::string> ownedSet(owned.begin(), owned.end());
std::vector<std::string> orphans;
std::unordered_set<std::string> emitted; // de-dup repeated spellings in `present`
for (const std::string& path : present) {
// (owned ∩ present) referenced: present is the walk; owned and !referenced
// are the two membership tests; emitted guards against a duplicate `present`.
if (ownedSet.count(path) == 0) continue; // not our leaving — skip
if (referencedSet.count(path) != 0) continue; // some bank references it
if (!emitted.insert(path).second) continue; // already emitted
orphans.push_back(path);
}
return orphans;
}
std::vector<std::string> mergeReferenced(const std::vector<std::string>& primary,
const std::vector<std::string>& extra) {
std::vector<std::string> merged;
merged.reserve(primary.size() + extra.size());
std::unordered_set<std::string> seen;
for (const std::string& p : primary) {
if (seen.insert(p).second) merged.push_back(p);
}
for (const std::string& p : extra) {
if (seen.insert(p).second) merged.push_back(p);
}
return merged;
}
PruneReport buildPruneReport(
const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
std::size_t displayCap) {
PruneReport report;
report.count = orphans.size();
for (const std::string& o : orphans) {
// Sum EVERY orphan's bytes (the exact reclaimable total), not just the displayed
// ones. A path with no stat'd size contributes 0 — never dropped, never negative.
const auto it = sizeByPath.find(o);
report.totalBytes += (it != sizeByPath.end()) ? it->second : 0;
// Display list is capped (0 = uncapped). count stays exact above regardless.
if (displayCap == 0 || report.orphans.size() < displayCap)
report.orphans.push_back(o);
}
report.truncated = report.orphans.size() < report.count;
return report;
}
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
const std::vector<std::string>& freshOrphans) {
// fresh is a pure-core output (referenced/hand-dropped already excluded), so keeping a
// confirmed path iff it is still a fresh orphan can never re-admit an unsafe file. Walk
// `confirmed` to preserve the confirm's listing order; emitted de-dups repeats.
const std::unordered_set<std::string> freshSet(freshOrphans.begin(), freshOrphans.end());
std::vector<std::string> plan;
std::unordered_set<std::string> emitted;
for (const std::string& path : confirmed) {
if (freshSet.count(path) == 0) continue; // stale: vanished / now-referenced -> skip
if (!emitted.insert(path).second) continue; // already emitted
plan.push_back(path);
}
return plan;
}
} // namespace reasampler::reclaim
+178
View File
@@ -0,0 +1,178 @@
#pragma once
// prune_reconcile — the pure core of Phase R (Reclaim), Wave 1. The safety-critical
// "which files are orphans" decision, computed with NO filesystem I/O and NO REAPER
// types. The mirror of view_mode_model's reconcile(liveGuids), one level DOWN: it
// reconciles FILES ON DISK against REFERENCED FILES (the union across every bank),
// where reconcile reconciled membership entries against live tracks.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes, NO filesystem calls. Standard library only. Unit-tested outside
// the DAW — this is the safety-critical part (it decides which bytes get deleted in
// R2/R3), so it is hard-tested here before any I/O exists.
//
// -- The one computation ------------------------------------------------------
//
// orphans = (owned ∩ present) referenced
//
// * present — files enumerated in the resolved current bank folder (R2 shell).
// * referenced — every project-relative path referenced by ANY bank in the book,
// pool included (union across the whole book — see BankBook::
// referencedPaths). A file referenced by any bank — including via a
// COPY into a second bank — is NEVER an orphan (the prune null test).
// * owned — the owned-file manifest: the files the bank system itself created
// (OwnedFileManifest). A present-but-unowned (hand-dropped) file is
// NEVER reclaimed — prune reclaims only the system's own leavings.
//
// The three settled guardrails fall straight out of the set algebra:
// * ∩ present — never proposes deleting a file that is not on disk (an owned-
// but-absent manifest entry yields no orphan, no error).
// * ∩ owned — never a hand-dropped file (ownership attribution, fork R-D).
// * referenced — never a file any bank references (union safety, prune null test).
//
// -- Path representation: EXACT-STRING match (safety-critical) -----------------
//
// Every path in the model is a project-relative string compared VERBATIM: Sample.
// relativePath, OwnedFileManifest::contains (p == relativePath), and BankModel all
// use raw std::string equality — no separator normalization, no case-folding, no
// trailing-slash trimming. This core MATCHES that convention exactly: it compares
// the raw strings the shell supplies. Feeding a consistent spelling across the three
// inputs is the R2 shell's contract (it enumerates the folder, unions the book, and
// reads the manifest against the SAME resolved current folder). Diverging from exact
// match here (e.g. case-insensitive compare) would be the unsafe direction — it could
// let one spelling of a referenced file be treated as an orphan under another.
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace reasampler::reclaim {
// The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin
// prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands
// it to the report surface; R3 will act on the SAME set behind the confirm guardrail.
// REAPER-free / filesystem-free by design (the shell does the I/O; this is just the
// tallied outcome), so the count/size aggregation is unit-testable outside the DAW.
//
// * count — number of orphan files (== orphans.size(); the AUTHORITATIVE tally,
// exact even when `orphans` below is a truncated display list).
// * totalBytes — sum of the on-disk sizes of the orphan files, in bytes (reclaimable
// space). A file the stat could not size contributes 0 (never negative).
// * orphans — the orphan file list as project-relative index-spelled paths, in
// folder-enumeration order (deterministic). MAY be truncated for a large
// set (the shell's display cap); `count` stays exact regardless, and
// `truncated` says whether the list was clipped.
// * truncated — true iff `orphans` holds fewer than `count` entries (a large set was
// clipped for display); false when the list is complete.
// * abortedUnreadableUsage — true iff the scan found a present-but-unreadable
// rsusage_* instance-usage record (pS-usage fail-safe): the orphan
// computation was NOT performed (count 0, empty list) and the prune
// must HALT — deleting with degraded protection is the data-loss
// direction. Set by the session's scan shell, never by
// buildPruneReport (which stays a pure tally).
// * offendingUsageKeys — the exact "rsusage_<guid>" ext-state key names that
// triggered the abort (non-empty iff abortedUnreadableUsage). Named
// so the action can print them for operator recovery: a corrupt/
// oversized key whose owning instance no longer exists is never
// automatically rewritten, so the abort would be permanent without
// a way to clear it. The operator can clear each key via ReaScript:
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
struct PruneReport {
std::size_t count = 0;
std::uint64_t totalBytes = 0;
std::vector<std::string> orphans;
bool truncated = false;
bool abortedUnreadableUsage = false;
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
};
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills
// this as it deletes the confirmed orphan set, reporting what it ACTUALLY reclaimed (not
// what it intended to) so a locked/vanished file shows up as a skip, never a false claim.
// REAPER-free / filesystem-free by design (the shell does the deletion; this is the
// tallied outcome), so the count/byte aggregation is unit-testable outside the DAW.
//
// * reclaimedCount — number of files actually removed from disk BY THIS CALL (trash or
// unlink). Already-absent files are NOT counted here.
// * reclaimedBytes — sum of the on-disk sizes of the files actually removed, in bytes.
// * skippedCount — files that could not be or were not reclaimed: stale entries that
// dropped out of the fresh-orphan intersection, files that vanished
// between the plan and the delete call (already absent), and real
// delete failures (locked, conversion error). Never an error/crash.
// * usedTrash — true iff the deletions were routed to the OS trash/recycle bin
// (recoverable); false iff the platform fell back to hard unlink.
struct PruneDeletionResult {
std::size_t reclaimedCount = 0;
std::uint64_t reclaimedBytes = 0;
std::size_t skippedCount = 0;
bool usedTrash = false;
};
// Computes the prune orphan set: (owned ∩ present) referenced.
//
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER
// they appear in `present` (deterministic output — mirror of the insertion-order
// determinism the index / manifest keep; the R2 dry-run reports a stable file list).
// Duplicate spellings within `present` are de-duplicated in the result (a folder
// enumeration yields distinct names, but the core does not rely on that).
//
// Pure: no I/O, no REAPER, no hidden state. All three inputs are project-relative
// path strings, compared by exact std::string equality (see header note).
std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned);
// Union two referenced-path sets into one (pS-usage): the bank's own referencedPaths()
// PLUS the paths held by live ReaSampler 9000 instances (sample_usage::usageHeldPaths).
// Order-preserving (`primary` first, then the `extra` paths not already present),
// exact-string de-dup — the same comparison convention as everything above, so feeding
// the result to pruneOrphans keeps the ` referenced` guardrail byte-exact. A path held
// ONLY by an instance (e.g. its bank entry was deleted while the instance kept its v10
// ref) is protected exactly like a bank-referenced one.
//
// Pure: no I/O, no REAPER. Kept here (not in the shells) so the "instance usage makes a
// file un-prunable" property is provable at the prune layer itself.
std::vector<std::string> mergeReferenced(const std::vector<std::string>& primary,
const std::vector<std::string>& extra);
// Tallies a dry-run PruneReport from a computed orphan set and a per-path size lookup.
// PURE (no I/O): the shell does the folder stat and passes the sizes in `sizeByPath`;
// this owns the count / byte-sum / display-truncation decision so it is unit-testable.
//
// * count == orphans.size() (the authoritative tally, exact regardless of the cap).
// * totalBytes == the sum of sizeByPath[o] over EVERY orphan o (not just the displayed
// ones); a path missing from sizeByPath contributes 0 (an orphan whose
// size could not be stat'd — never negative, never dropped from the sum).
// * orphans == the first `displayCap` orphans in input order (the deterministic
// folder-enumeration order pruneOrphans preserved); the whole set when
// count <= displayCap. displayCap == 0 means "no display cap" (whole set).
// * truncated == count > orphans.size() (a large set was clipped for display).
//
// Kept separate from pruneOrphans so the safety-critical set algebra stays a pure function
// of three sets, while the presentation tally (which the R2 dry-run and R3 confirm both
// need) is its own small, testable step.
PruneReport buildPruneReport(const std::vector<std::string>& orphans,
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
std::size_t displayCap);
// Computes the confirm-time delete plan: the intersection of the set the user was SHOWN
// and confirmed (`confirmed`) with a FRESH pure-core orphan output (`freshOrphans`) taken
// at delete time. Returns exactly `confirmed ∩ freshOrphans`, in the order of `confirmed`
// (deterministic — the same order the confirm listed).
//
// This is the R3 staleness guard, and it protects in BOTH directions so that "what was
// shown is what is deleted" holds no matter what changed between confirm and delete:
// * A confirmed path that is NO LONGER a fresh orphan — a file that vanished (gone from
// `present`), or that some bank now references (gone from ` referenced`), or whose
// ownership changed — is DROPPED (a skip, never an error, never a wrongful delete of a
// now-referenced file). Because `freshOrphans` is itself a pure-core output, the plan
// can never contain a referenced or hand-dropped file: the guard survives recompute.
// * A path that became an orphan AFTER the confirm (in `freshOrphans` but not `confirmed`)
// is NOT deleted — it was never shown, so it is never swept without its own confirm.
//
// PURE: no I/O, no REAPER. Duplicate spellings in `confirmed` are de-duplicated in the
// result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct names).
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
const std::vector<std::string>& freshOrphans);
} // namespace reasampler::reclaim
+154
View File
@@ -0,0 +1,154 @@
// action_bar — pure implementation. See action_bar.h. NO REAPER / SWELL / LICE / vendor.
#include "core/ui/action_bar.h"
#include <cstddef>
namespace reasampler::ui {
namespace {
// The total button count across all clusters (empty clusters contribute nothing).
int totalButtons(const std::vector<ClusterSpec>& clusters) {
int n = 0;
for (const ClusterSpec& c : clusters)
if (c.count > 0) n += c.count;
return n;
}
// Fills a slot's label rect from its box. The label spans the full button height — a single-row
// short label (L6: keybinding sub-row removed from the face; binding is in the hover tooltip).
// Insets horizontally so text clears the button edge.
void fillTextRects(ActionBarSlot& s, const ActionBarSpec& /*spec*/) {
const int hpad = 4; // horizontal text inset inside the button
const int innerX = s.x + hpad;
const int innerW = s.width - 2 * hpad;
if (innerW <= 0) return; // too narrow for text; leave label rect empty
s.labelX = innerX; s.labelY = s.y; s.labelW = innerW; s.labelH = s.height;
}
// Tiles the first `visible` buttons into slots, cluster by cluster, left to right. This is the
// ONE placement routine; both computeBarSlots and hitTestActionBar drive it so draw and
// hit-test can never drift. `visible` is assumed already clamped to [0, total]. Returns the
// slots in ascending flat-index order.
std::vector<ActionBarSlot> tile(const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec, int visible) {
std::vector<ActionBarSlot> slots;
if (visible <= 0) return slots;
slots.reserve(static_cast<std::size_t>(visible));
const int top = bar.y + spec.verticalInset;
const int btnH = bar.height - 2 * spec.verticalInset;
if (btnH <= 0) return slots;
int cursorX = bar.x + spec.sidePad;
int flatIndex = 0; // running flat action index across all clusters
int placed = 0; // buttons placed so far (stops at `visible`)
bool firstClusterEmitted = false;
for (const ClusterSpec& c : clusters) {
if (c.count <= 0) continue; // skip empty clusters (no gap emitted)
if (placed >= visible) break;
// Gap BEFORE this cluster (except the first non-empty one).
if (firstClusterEmitted) cursorX += spec.clusterGap;
firstClusterEmitted = true;
for (int i = 0; i < c.count; ++i, ++flatIndex) {
if (placed >= visible) return slots; // overflow cut — stop cleanly
if (i > 0) cursorX += spec.buttonGap; // gap between buttons in the cluster
ActionBarSlot s;
s.index = flatIndex;
s.cluster = c.cluster;
s.x = cursorX;
s.y = top;
s.width = spec.buttonWidth;
s.height = btnH;
fillTextRects(s, spec);
slots.push_back(s);
cursorX += spec.buttonWidth;
++placed;
}
}
return slots;
}
// The rightmost pixel the first `visible` buttons would occupy (bar.x + sidePad based). Used by
// computeBarFit to test whether a candidate visible-count fits within the bar's usable width.
// Mirrors tile()'s advance math exactly (gaps included) so fit and layout agree.
int rightEdgeFor(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec, int visible) {
if (visible <= 0) return bar.x + spec.sidePad;
int cursorX = bar.x + spec.sidePad;
int placed = 0;
bool firstClusterEmitted = false;
for (const ClusterSpec& c : clusters) {
if (c.count <= 0) continue;
if (placed >= visible) break;
if (firstClusterEmitted) cursorX += spec.clusterGap;
firstClusterEmitted = true;
for (int i = 0; i < c.count; ++i) {
if (placed >= visible) return cursorX;
if (i > 0) cursorX += spec.buttonGap;
cursorX += spec.buttonWidth; // this button's right edge
++placed;
if (placed >= visible) return cursorX;
}
}
return cursorX;
}
} // namespace
BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec) {
BarFit fit;
const int total = totalButtons(clusters);
if (total <= 0 || bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) {
fit.hiddenCount = total > 0 ? total : 0;
return fit;
}
const int usableRight = bar.x + bar.width - spec.sidePad;
// Largest prefix of buttons whose right edge stays within the usable right bound. Buttons
// never shrink; trailing ones that do not fit are the overflow (dropped whole).
int visible = 0;
for (int cand = 1; cand <= total; ++cand) {
if (rightEdgeFor(bar, clusters, spec, cand) <= usableRight)
visible = cand;
else
break;
}
fit.visibleCount = visible;
fit.hiddenCount = total - visible;
return fit;
}
std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec) {
if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return {};
const BarFit fit = computeBarFit(bar, clusters, spec);
return tile(bar, clusters, spec, fit.visibleCount);
}
int hitTestActionBar(int px, int py, const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec) {
if (bar.height <= 0 || bar.width <= 0) return -1;
// Reject outside the bar band first (half-open bounds match the slots).
if (px < bar.x || px >= bar.x + bar.width ||
py < bar.y || py >= bar.y + bar.height)
return -1;
const std::vector<ActionBarSlot> slots = computeBarSlots(bar, clusters, spec);
for (const ActionBarSlot& s : slots) {
if (px >= s.x && px < s.x + s.width && py >= s.y && py < s.y + s.height)
return s.index;
}
return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss
}
} // namespace reasampler::ui
+151
View File
@@ -0,0 +1,151 @@
#pragma once
#include "core/ui/rect.h"
// action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's
// TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a
// thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact
// bar of clusters, each button carrying a label sub-rect spanning
// its full height — a single-row short label (L6: the keybinding sub-row was on the button face
// through L5; L6 moves it to the hover tooltip instead). The bar degrades gracefully on a narrow
// panel by dropping WHOLE trailing buttons (never clipping) so the frequent leading cluster
// survives.
//
// L4 re-homes the inventory across TWO toolbars, BOTH driven by this one module: a TOP toolbar
// (Capture + Placement — the two acts the tool exists for) and a BOTTOM toolbar (the Design-View
// verbs, Tagging then Switching). The tiling is cluster-agnostic — it walks the caller's
// ClusterSpec list in order — so the same computeBarSlots / hitTestActionBar serve both bars;
// only the cluster membership and the band rect differ per toolbar.
//
// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): the panel shell owns the SWELL
// window, the L1-kit draws, and the NamedCommandLookup/Main_OnCommand dispatch — all
// DAW-verified. What is NOT DAW-bound — how the clusters tile the bar, where each button and
// its label sub-rect sit, and which button a click hits — lives HERE, unit-tested outside the
// DAW. Mirror of mode_switch / prune_button.
//
// NAME NOTE (brief §name-collision): ButtonRect / ButtonStripRect / ActionButtonRect /
// SegmentRect / CellRect / FooterRect / KitButtonBox are already owned in this namespace, so
// this module's types are ActionBarRect / ActionBarSlot / ActionCluster — grep-checked free
// before minting. They are a distinct concept (a task-grouped multi-cluster bar with text
// sub-rects), so the separate names are correct, not merely non-colliding.
//
// SCOPE: the destructive PRUNE button is NOT in this bar — it stays set-apart in the footer,
// warn-marked, owned by prune_button (L2 keeps prune deliberately away from the frequent
// action cluster). This module lays out only the non-destructive capture/placement/maintenance
// actions.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include <vector>
namespace reasampler::ui {
// The task cluster a button belongs to (the L2 "group by task" mandate). The order here is
// NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum
// only names the groups so a slot can carry (and a test/shell can assert) its membership.
//
// L4 split the panel's buttons across TWO toolbars, each an action_bar instance:
// * the TOP toolbar draws Capture + Placement (the two acts the tool exists for);
// * the BOTTOM toolbar draws the Design-View verbs, grouped Tagging then Switching.
// Both toolbars share this ONE pure layout module (the tiling is cluster-agnostic — it walks
// the caller's ClusterSpec list in order), so a cluster value belongs to whichever toolbar
// the shell places it in; nothing here couples a cluster to a specific bar.
enum class ActionCluster {
Capture, // capture item / track / realtime / batch — top toolbar, primary gesture
Placement, // insert at cursor / insert-conform — top toolbar, placing a sample
Maintenance, // re-capture from source / cancel realtime — rarer upkeep actions
Tagging, // tag / untag selected tracks for the active mode — bottom toolbar (L4)
Switching, // activate Arrange / Design, toggle mode, show-both — bottom toolbar (L4)
};
// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the
// top-left corner; width/height are the bar extents. The panel reserves this as a fixed-height
// band (its own judgment where — above the tail footer, below the split body).
using ActionBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// One visible button's placement within the bar, top-left origin. `index` is the button's
// position in the caller's flat action list (the caller supplies actions in cluster order, so
// index also selects the action to fire on a hit). `cluster` is the task group it was laid out
// under (surfaced so a test can assert the grouping is structural, and the shell can tint a
// cluster). `box` is the whole button rect; `labelBox` is the text area inset horizontally so
// text clears the button edge. Only VISIBLE buttons get a slot — a button that does not fit is
// omitted, never returned clipped, so every slot is fully drawable.
struct ActionBarSlot {
int index = 0;
ActionCluster cluster = ActionCluster::Capture;
int x = 0;
int y = 0;
int width = 0;
int height = 0;
// Label rect (absolute, top-left origin), inside `box`. The label spans the full button
// height — a single-row short label only (L6: keybinding sub-row removed from the face;
// binding is surfaced in the hover tooltip instead).
int labelX = 0, labelY = 0, labelW = 0, labelH = 0;
bool operator==(const ActionBarSlot& o) const {
return index == o.index && cluster == o.cluster &&
x == o.x && y == o.y && width == o.width && height == o.height &&
labelX == o.labelX && labelY == o.labelY &&
labelW == o.labelW && labelH == o.labelH;
}
};
// One cluster's button count, in the caller's flat action-list order. The caller passes these
// in the left-to-right order it wants them drawn (top toolbar: Capture then Placement; bottom
// toolbar: Tagging then Switching); a cluster with count 0 is skipped (no gap emitted for it).
// The flat action index a slot carries is the running sum across clusters (cluster 0's buttons
// are indices [0, counts[0]), etc.), so the shell's flat action table lines up with the slots
// by index.
struct ClusterSpec {
ActionCluster cluster = ActionCluster::Capture;
int count = 0;
};
// Layout inputs for the bar, in pixels. Defaults are the bank_panel action-bar metrics; the
// shell passes its own so draw and hit-test share ONE source of truth.
// * buttonWidth — each button's fixed width (buttons never render narrower; overflow drops
// whole trailing buttons instead of shrinking below this).
// * buttonGap — horizontal gap between buttons WITHIN a cluster.
// * clusterGap — horizontal gap between adjacent clusters (wider than buttonGap so the
// task grouping reads visually; the 8px-grid density decision).
// * sidePad — left/right inset from the bar edges to the first/last button.
// * verticalInset — top/bottom gap inside the bar (buttons read as raised, not full-bleed).
struct ActionBarSpec {
int buttonWidth = 108;
int buttonGap = 4;
int clusterGap = 16;
int sidePad = 8;
int verticalInset = 3;
};
// How many buttons (from the front, cluster by cluster) fit the bar at `spec.buttonWidth`.
// Split from slot tiling so the shell can size an overflow affordance / count without
// re-deriving it. Trailing buttons that do not fit are the overflow (dropped whole). A
// non-positive bar width, or a bar too narrow for even one button, yields 0. Clamps to
// [0, total-button-count].
struct BarFit {
int visibleCount = 0; // buttons that fit (laid out), counted from the front
int hiddenCount = 0; // total - visibleCount (the overflow, dropped whole)
};
BarFit computeBarFit(const ActionBarRect& bar, const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec);
// Lays out the VISIBLE buttons (per computeBarFit) left-to-right in cluster order: buttons
// pack at buttonWidth with buttonGap inside a cluster and clusterGap between clusters, starting
// at bar.x + sidePad. Each slot carries its flat action index, its cluster, its box, and the
// label sub-rect (full-height single row). Empty clusters emit no gap. Returns exactly
// visibleCount slots in ascending index order. A degenerate bar (width/height <= 0), an empty
// cluster list, or a non-positive buttonWidth yields empty.
std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters,
const ActionBarSpec& spec);
// The flat action index the point (px, py) (SWELL/LICE top-left client coords) lands on, or -1
// for a miss: outside the bar band, in an inter-button / inter-cluster gap, or past the last
// visible button (the narrow-panel overflow dead-zone — a harmless no-op the shell ignores).
// Half-open bounds [x, x+width) x [y, y+height) match computeBarSlots so no pixel is double-
// claimed and the hit maps to the button drawn there. Unlike an equal-tiled strip, the bar has
// real gaps, so a gap point is a clean miss (not the nearest button).
int hitTestActionBar(int px, int py, const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec);
} // namespace reasampler::ui
+227
View File
@@ -0,0 +1,227 @@
// bank_grid — pure implementation. See bank_grid.h. NO REAPER / SWELL / vendor.
#include "core/ui/bank_grid.h"
#include <algorithm>
#include <cmath>
namespace reasampler::ui {
namespace {
// Builds a sorted, unique ascending index vector for the inclusive range [a, b]
// (order-agnostic in a/b). Both ends assumed already in-range by the caller.
std::vector<int> rangeIndices(int a, int b) {
if (a > b) std::swap(a, b);
std::vector<int> out;
out.reserve(static_cast<std::size_t>(b - a + 1));
for (int i = a; i <= b; ++i) out.push_back(i);
return out;
}
// Clamps `index` to a valid cell (single-selection) result: sole member, focus and
// anchor both at index. Used by plain click and plain arrow.
Selection singleSelection(int index) {
Selection s;
s.indices = {index};
s.focus = index;
s.anchor = index;
return s;
}
} // namespace
int columnsForWidth(int panelWidth, const GridSpec& spec) {
// Layout: [gap][cell][gap][cell]...[cell][gap]. n cells occupy
// gap + n*(cellWidth + gap). Solve for the largest n that fits panelWidth,
// clamped to at least 1 so a too-narrow panel still shows a (clipped) column.
const int cell = spec.cellWidth + spec.gap;
if (cell <= 0) return 1; // degenerate spec — one column, avoid divide-by-zero
const int usable = panelWidth - spec.gap;
if (usable < spec.cellWidth) return 1;
const int cols = usable / cell;
return cols < 1 ? 1 : cols;
}
std::vector<CellRect> computeCellRects(int itemCount,
int panelWidth,
const GridSpec& spec) {
std::vector<CellRect> rects;
if (itemCount <= 0) return rects;
const int cols = columnsForWidth(panelWidth, spec);
rects.reserve(static_cast<std::size_t>(itemCount));
for (int i = 0; i < itemCount; ++i) {
const int col = i % cols;
const int row = i / cols;
CellRect r;
r.x = spec.gap + col * (spec.cellWidth + spec.gap);
r.y = spec.gap + row * (spec.cellHeight + spec.gap);
r.width = spec.cellWidth;
r.height = spec.cellHeight;
rects.push_back(r);
}
return rects;
}
int contentHeight(int itemCount, int panelWidth, const GridSpec& spec) {
if (itemCount <= 0) return 0;
const int cols = columnsForWidth(panelWidth, spec);
// Ceil-divide item count by columns to get the row count (partial last row
// still occupies a full row of height).
const int rows = (itemCount + cols - 1) / cols;
return spec.gap + rows * (spec.cellHeight + spec.gap);
}
std::string thumbnailKeyString(const ThumbnailKey& key) {
// Length-prefix the sampleId so a delimiter byte inside an id cannot forge a
// collision with a different (id, width, generation) triple.
std::string s;
s.reserve(key.sampleId.size() + 32);
s += std::to_string(key.sampleId.size());
s += ':';
s += key.sampleId;
s += '|';
s += std::to_string(key.width);
s += '|';
s += std::to_string(key.generation);
return s;
}
// --- Interaction --------------------------------------------------------------
int hitTestCell(int px, int py, const std::vector<CellRect>& rects) {
for (std::size_t i = 0; i < rects.size(); ++i) {
const CellRect& r = rects[i];
// Half-open bounds so adjacent (gapless) rects never both claim a pixel.
if (px >= r.x && px < r.x + r.width &&
py >= r.y && py < r.y + r.height)
return static_cast<int>(i);
}
return -1;
}
bool Selection::contains(int index) const {
return std::binary_search(indices.begin(), indices.end(), index);
}
Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
int itemCount) {
if (itemCount <= 0 || index < 0 || index >= itemCount) return current;
// Shift takes precedence over ctrl (documented): range-select from the anchor.
if (shift) {
const int anchor = current.anchor >= 0 && current.anchor < itemCount
? current.anchor
: index; // no valid anchor -> seed at the click
Selection s;
s.indices = rangeIndices(anchor, index);
s.focus = index;
s.anchor = anchor; // anchor unchanged across a shift-range
return s;
}
if (ctrl) {
Selection s = current;
auto it = std::lower_bound(s.indices.begin(), s.indices.end(), index);
if (it != s.indices.end() && *it == index)
s.indices.erase(it); // toggle OUT
else
s.indices.insert(it, index); // toggle IN (keeps sorted order)
s.focus = index;
s.anchor = index; // ctrl-click reseeds the range origin
return s;
}
// Plain click: sole selection.
return singleSelection(index);
}
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
bool shift) {
if (itemCount <= 0) return current;
if (cols < 1) cols = 1;
// A fresh panel (no focus): the first key press focuses cell 0 without moving,
// so the user sees the caret appear before it steps.
if (current.focus < 0 || current.focus >= itemCount) {
if (shift) {
Selection s;
s.indices = {0};
s.focus = 0;
s.anchor = 0;
return s;
}
return singleSelection(0);
}
const int from = current.focus;
int to = from;
switch (key) {
case NavKey::Left:
// Move one; clamp at cell 0 (stay put on the first cell).
if (from > 0) to = from - 1;
break;
case NavKey::Right:
// Move one; clamp at the last cell (stay put on the last cell).
if (from < itemCount - 1) to = from + 1;
break;
case NavKey::Up:
// Move up a row; if that leaves the grid (top row) stay put.
if (from - cols >= 0) to = from - cols;
break;
case NavKey::Down: {
// Move down a row. If the cell directly below exists, go there. If it
// does not (we're above a MISSING partial-last-row cell) but there ARE
// more cells, clamp to the last cell so the partial row is reachable.
// If we're already in the last populated row, stay put.
const int below = from + cols;
if (below < itemCount)
to = below;
else if (from + 1 < itemCount) // partial last row below us
to = itemCount - 1;
break;
}
case NavKey::Home: to = 0; break;
case NavKey::End: to = itemCount - 1; break;
}
if (!shift) return singleSelection(to);
// Shift-extend: keep the anchor (seed it at the origin cell on first extend).
const int anchor = current.anchor >= 0 && current.anchor < itemCount
? current.anchor
: from;
Selection s;
s.indices = rangeIndices(anchor, to);
s.focus = to;
s.anchor = anchor;
return s;
}
float compressAmplitudeForDisplay(float linear) {
const float mag = linear < 0.0f ? -linear : linear;
// The linear magnitude at the floor threshold: 10^(kDisplayFloorDb/20).
// Any magnitude at or below this maps to display fraction 0.
// Computed once as a constant expression; std::pow is constexpr in C++20 but
// not C++17, so derive it via the floor definition directly at runtime — it is
// only called once per bin, and the branch-free math is cheap.
const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f);
if (mag <= floorMag) return 0.0f; // below floor (and guards log10(0))
// dB in [kDisplayFloorDb, 0] for magnitude in [floorMag, 1].
const float db = 20.0f * std::log10(mag);
// Normalize to [0, 1]: 0 at kDisplayFloorDb, 1 at 0 dB.
const float fraction = (db - kDisplayFloorDb) / (0.0f - kDisplayFloorDb);
// Clamp to [0, 1] so floating-point overshoot on |linear| > 1.0 stays bounded,
// then re-apply the original sign.
const float clamped = fraction < 0.0f ? 0.0f : (fraction > 1.0f ? 1.0f : fraction);
return linear < 0.0f ? -clamped : clamped;
}
} // namespace reasampler::ui
+178
View File
@@ -0,0 +1,178 @@
#pragma once
#include "core/ui/rect.h"
// bank_grid — the REAPER-free layout math and cache-key logic behind the docked
// bank_panel (M5, Wave A). The panel shell (bank_panel.cpp) owns the SWELL window,
// LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What
// is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and
// the key that identifies a cached thumbnail — lives here so it is unit-tested
// outside the DAW (CLAUDE.md §load-bearing split).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER.
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace reasampler::ui {
// A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE
// convention). (x, y) is the top-left corner; width/height are the cell extents.
// These are the draw bounds for one sample's thumbnail; the panel draws its
// waveform envelope inside this rect (minus any internal padding it applies).
using CellRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the
// TARGET cell size; the layout fits as many whole columns as the panel width
// allows (>= 1) and wraps to as many rows as N requires. gap is the pixel spacing
// between adjacent cells (and the outer margin), so cells never touch.
struct GridSpec {
int cellWidth = 120;
int cellHeight = 72;
int gap = 8;
};
// Computes the number of columns that fit in a panel of the given pixel width for
// the spec. Always >= 1 (a panel narrower than one cell still shows one column,
// clipped by the window). Pure arithmetic — the panel passes its live client
// width here and to computeCellRects.
int columnsForWidth(int panelWidth, const GridSpec& spec);
// Tiles `itemCount` cells left-to-right, top-to-bottom into a panel of the given
// pixel width, honoring the spec's cell size and gap. Returns exactly itemCount
// rects in item order (rect i is sample i). A partial last row is left-aligned
// and simply shorter — no centering, no stretching. itemCount == 0 -> empty.
// panelWidth is used only to derive the column count; the returned rects may
// extend below any fixed viewport height (the panel scrolls/clips in Wave B).
std::vector<CellRect> computeCellRects(int itemCount,
int panelWidth,
const GridSpec& spec);
// The total pixel height the grid occupies for itemCount cells at the given panel
// width and spec (top margin + rows*cellHeight + inter-row gaps + bottom margin).
// 0 when itemCount == 0. The panel uses this to know its full content height
// (scroll extent in Wave B; for Wave A it sizes the empty-vs-populated decision).
int contentHeight(int itemCount, int panelWidth, const GridSpec& spec);
// Identifies one cached thumbnail. A cached envelope is valid only while the
// sample's identity, the draw width it was computed at, and the bank generation
// it was computed under all match. Width is part of the key because the envelope
// has exactly `width` bins per channel (peaks::computeEnvelope is width-driven);
// a resized panel needs a fresh envelope. Generation lets the panel invalidate
// every entry when the bank changes (capture / project load) without diffing.
struct ThumbnailKey {
std::string sampleId;
int width = 0;
std::uint64_t generation = 0;
bool operator==(const ThumbnailKey& o) const {
return sampleId == o.sampleId && width == o.width &&
generation == o.generation;
}
};
// A stable string form of the key, suitable as a map key. Deterministic: the same
// key always yields the same string, distinct keys always differ (the sampleId is
// length-prefixed so an id containing the delimiter cannot collide with another).
std::string thumbnailKeyString(const ThumbnailKey& key);
// --- Interaction (M5 Wave B): hit-test, selection, keyboard nav --------------
//
// All REAPER-free so the panel's interaction LOGIC is unit-tested outside the DAW,
// exactly as the layout math is. The panel shell (bank_panel.cpp) reads live mouse
// coordinates / key codes / modifier state via SWELL and calls into these; it owns
// no selection arithmetic of its own.
// Hit-tests a point (SWELL/LICE top-left client coords) against a cell-rect list.
// Returns the index of the FIRST rect that contains the point, or -1 for a miss
// (a click in the inter-cell gap, the margin, or below the last row). Half-open
// bounds [x, x+width) x [y, y+height) so adjacent rects never both claim a pixel.
int hitTestCell(int px, int py, const std::vector<CellRect>& rects);
// The panel's selection state. `indices` is the selected set as a SORTED, unique
// ascending vector (deterministic for tests and for highlight iteration). `focus`
// is the cell the caret sits on — the audition/extend target — or -1 when nothing
// is focused. `anchor` is the fixed end of a shift-range (the cell a range extends
// FROM); -1 when there is no active range origin. An empty selection has focus and
// anchor both -1.
//
// Invariants (upheld by the pure mutators below, asserted in tests):
// * indices is sorted ascending with no duplicates;
// * every index (and focus/anchor when >= 0) is in [0, itemCount);
// * focus, when >= 0, is a member of indices.
struct Selection {
std::vector<int> indices;
int focus = -1;
int anchor = -1;
bool operator==(const Selection& o) const {
return indices == o.indices && focus == o.focus && anchor == o.anchor;
}
bool contains(int index) const;
bool empty() const { return indices.empty(); }
};
// Applies a mouse click on cell `index` to `current`, returning the new selection.
// Modifier semantics (standard multi-select, matching file-manager conventions):
// * plain (no modifier): select ONLY `index`; focus = anchor = index.
// * ctrl: TOGGLE `index` in/out of the set; focus = index. Anchor moves to
// index on add, and to index on remove too (a ctrl-click reseeds the
// range origin at the clicked cell). If the toggle empties the set,
// focus stays at index (the caret) but the set is empty.
// * shift: select the inclusive RANGE from `anchor` to `index` (replacing the
// set); focus = index, anchor unchanged. With no prior anchor (anchor
// == -1) shift behaves like a plain click (anchor seeds at index).
// `index` out of [0, itemCount) or itemCount <= 0 returns `current` unchanged.
// ctrl and shift together: shift takes precedence (range select), matching common
// UI; documented so the panel need not special-case it.
Selection applyClick(const Selection& current, int index, bool ctrl, bool shift,
int itemCount);
// A directional key for keyboard navigation. REAPER-free (the shell maps VK_* to
// these) so nav math is testable without SWELL. Enter/Space/Esc are NOT here: they
// drive audition, which is a shell concern (no selection math), so the shell reads
// those key codes directly.
enum class NavKey { Left, Right, Up, Down, Home, End };
// Moves the focus by one step for `key` in a grid of `cols` columns holding
// `itemCount` cells, returning the new selection. `cols` >= 1.
// * Left/Right move by one cell in linear (row-major) order; Up/Down move by
// `cols`. Movement CLAMPS at the grid ends (no wrap): Right on the last cell,
// Left on the first, Up on the top row, Down past the last cell all stay put.
// (Clamp, not wrap: wrap on a partial last row is surprising and error-prone;
// clamp is the predictable choice — flagged as the deliberate decision.)
// * Down from the second-to-last row into a column with no cell in the last row
// clamps to the last cell rather than overshooting past itemCount.
// * Without shift: the moved-to cell becomes the sole selection; focus = anchor
// = newIndex (a plain arrow reseeds the range origin).
// * With shift: focus moves to newIndex and the selection becomes the inclusive
// range from anchor to newIndex (anchor unchanged); a first shift-arrow with no
// anchor seeds the anchor at the ORIGIN cell before moving.
// * Empty selection (focus == -1): the first arrow focuses cell 0 (Home-like),
// so an arrow press on a fresh panel starts navigation predictably.
// itemCount <= 0 returns `current` unchanged.
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
bool shift);
// --- Waveform display compression --------------------------------------------
//
// Maps a raw linear amplitude magnitude to a perceptual display fraction so
// quiet and medium content remains visible in the thumbnail.
//
// The floor below which amplitude is treated as silence (display fraction 0).
// At -60 dB, 0.001 linear magnitude maps to ~0. Tune this constant in-DAW to
// taste — it is the only knob for the compression curve.
constexpr float kDisplayFloorDb = -60.0f;
// Maps a signed linear amplitude value in [-1, 1] (a raw envelope extreme such
// as PeakBin::max or PeakBin::min) to a signed display fraction in [-1, 1].
//
// The magnitude |linear| is converted to dB, clamped to [kDisplayFloorDb, 0],
// then normalized so kDisplayFloorDb -> 0 and 0 dB -> 1. The original sign is
// re-applied so positive max values still map positive (draw up) and negative
// min values still map negative (draw down). Exact-zero input returns 0.0f
// (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f.
float compressAmplitudeForDisplay(float linear);
} // namespace reasampler::ui
+96
View File
@@ -0,0 +1,96 @@
// card_drag — pure implementation. See card_drag.h. NO REAPER / SWELL / LICE / OS / vendor.
#include "core/ui/card_drag.h"
namespace reasampler::ui {
namespace {
// Half-open point-in-rect (matches drag_out / bank_grid: [x, x+w) x [y, y+h)).
bool insideClient(int px, int py, const PanelClientRect& c) {
return px >= c.x && px < c.x + c.width &&
py >= c.y && py < c.y + c.height;
}
} // namespace
CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
const DragState& state, const DragModifiers& mods) {
// No drag / empty payload: nothing to do.
if (!state.dragging || !state.hasArmedSamples) return CardGesture::None;
// Precedence 1: pointer left the client rect -> OS drag-out (wins first).
if (!insideClient(px, py, client)) return CardGesture::OsDragOut;
// Precedence 2: over a tab / the other bank -> move (or copy on Ctrl).
if (mods.region == DropRegion::OtherBankOrTab)
return mods.ctrl ? CardGesture::Copy : CardGesture::Move;
// Precedence 3: within the same bank's own grid -> reorder / replace.
if (mods.region == DropRegion::SameBankGrid) {
// Alt over an OCCUPIED slot replaces; otherwise reorder (empty = place,
// occupied+no-Alt = insert-before-and-shift).
if (mods.alt && mods.slotOccupied) return CardGesture::Replace;
return CardGesture::Reorder;
}
// Dead space inside the client: a drop here is a no-op.
return CardGesture::None;
}
CursorCue cursorForGesture(CardGesture g) {
switch (g) {
case CardGesture::OsDragOut: return CursorCue::OsDragOut;
case CardGesture::Move: return CursorCue::Move;
case CardGesture::Copy: return CursorCue::Copy;
case CardGesture::Reorder: return CursorCue::Reorder;
case CardGesture::Replace: return CursorCue::Replace;
case CardGesture::None: return CursorCue::Default;
}
return CursorCue::Default;
}
std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
const GridSpec& spec) {
std::vector<SlotCellRect> rects;
if (maxSlot < 0) return rects;
const int cols = columnsForWidth(panelWidth, spec);
const int count = maxSlot + 1; // slots 0..maxSlot inclusive (empties included)
rects.reserve(static_cast<std::size_t>(count));
for (int slot = 0; slot < count; ++slot) {
const int col = slot % cols;
const int row = slot / cols;
SlotCellRect r;
r.slot = slot;
r.x = spec.gap + col * (spec.cellWidth + spec.gap);
r.y = spec.gap + row * (spec.cellHeight + spec.gap);
r.width = spec.cellWidth;
r.height = spec.cellHeight;
rects.push_back(r);
}
return rects;
}
std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth,
const GridSpec& spec) {
const int cols = columnsForWidth(panelWidth, spec);
// One trailing row of slots past the last occupied slot — the drop-target extension.
// When maxSlot < 0 (empty bank) the trailing row begins at slot 0.
const int firstTrailing = maxSlot + 1;
const int newMax = firstTrailing + cols - 1; // fills one full trailing row
return computeSlotRects(newMax, panelWidth, spec);
}
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects) {
for (const SlotCellRect& r : rects) {
// Half-open bounds so adjacent rects never both claim a pixel.
if (px >= r.x && px < r.x + r.width &&
py >= r.y && py < r.y + r.height)
return r.slot;
}
return -1;
}
} // namespace reasampler::ui
+147
View File
@@ -0,0 +1,147 @@
#pragma once
// card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three
// pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing
// split); the SWELL wiring, SetCursor call, cursor resources, and drop-target draw stay in
// the shell (bank_panel.cpp). Mirror of drag_out::decideGesture.
//
// 1. GESTURE PRECEDENCE (F3 settled). A live drag resolves to exactly one gesture, in a
// strict precedence the shell evaluates on every mouse-move / at drop:
// (1) pointer LEFT the client rect -> OsDragOut (hand off to the OS)
// (2) else drop over a tab / the OTHER bank -> Move | Copy (Ctrl = Copy)
// (3) else drop within the SAME bank's grid -> Reorder | Replace
// - empty slot -> Reorder (place there)
// - occupied slot, no modifier -> Reorder (insert-before-and-shift)
// - occupied slot, Alt held -> Replace (Alt-replace-over-occupied)
// So leave-client wins first, then other-bank, then same-bank-grid = reorder/replace.
// This keeps the reorder gesture from ever stealing a bank-move or OS-drag.
//
// 2. SLOT HIT-TEST. Which grid SLOT a pointer sits over, sparse-aware: the grid tiles
// slots 0..maxSlot including empty ones, so hit-testing maps a point to a slot index
// (empty or occupied) or -1 for a miss. The pixel<->slot rect math extends bank_grid's
// dense tiling to the gap-preserving slot layout.
//
// 3. DROP-RESULT -> CURSOR CUE. The resolved gesture maps to a cursor cue enum the shell
// turns into a SetCursor call. The cue DECISION is pure (here); the shell owns only
// the SetCursor call and the cursor resources. The Replace cue appears ONLY when Alt
// is actually held over an occupied slot (precedence rule 3's Alt branch).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO OS, NO vendor/ includes. Standard
// library only. Reuses drag_out's PanelClientRect / DragState and bank_grid's CellRect.
#include <vector>
#include "core/ui/bank_grid.h" // CellRect
#include "core/ui/drag_out.h" // PanelClientRect, DragState
namespace reasampler::ui {
// Which drop region the pointer currently sits over WITHIN the client rect. The shell
// classifies the live pointer against its own region geometry (tab strip / other bank
// region / this bank's own grid) and passes the verdict; card_drag does not know panel
// layout, only the precedence over these verdicts. (When the pointer has left the client
// rect the shell need not compute this — OsDragOut wins first regardless.)
enum class DropRegion {
SameBankGrid, // over the dragged samples' OWN bank grid — a reorder/replace target
OtherBankOrTab, // over a tab or the other region's bank — a move/copy target
DeadSpace, // inside the client but over no drop target (header, footer, gap)
};
// The resolved gesture — one clean outcome the shell acts on and maps to a cursor.
enum class CardGesture {
None, // no drag under way, or an empty payload — do nothing
OsDragOut, // pointer left the client rect — hand off to the native OS drag (drag_out)
Move, // drop over another bank/tab, no Ctrl — move the samples there
Copy, // drop over another bank/tab, Ctrl held — copy the samples there
Reorder, // drop within the same bank grid — reorder to the target slot
Replace, // drop within the same bank grid, Alt over an OCCUPIED slot — replace
};
// The live drag inputs the precedence decision needs beyond position + client rect:
// region — the shell's verdict on what the pointer sits over (see DropRegion).
// targetSlot — the slot the pointer sits over in the same-bank grid, or -1 (used only
// when region == SameBankGrid to decide empty-vs-occupied).
// slotOccupied — whether targetSlot currently holds a sample (drives Reorder vs Replace).
// ctrl — Ctrl held (Copy vs Move over another bank).
// alt — Alt held (Replace vs Reorder over an occupied same-bank slot).
struct DragModifiers {
DropRegion region = DropRegion::DeadSpace;
int targetSlot = -1;
bool slotOccupied = false;
bool ctrl = false;
bool alt = false;
};
// Resolves the gesture for a drag at pointer (px, py) over `client`, given the drag
// `state` and the live `mods`. Precedence exactly as documented above.
// * Not dragging / no armed samples: None.
// * Pointer OUTSIDE the client rect: OsDragOut (wins first — invariant #4 boundary).
// * OtherBankOrTab: Copy if ctrl else Move.
// * SameBankGrid: Replace iff (alt AND the target slot is occupied); else Reorder
// (whether the slot is empty — place — or occupied without Alt — insert-shift).
// * DeadSpace inside the client: None (a drop here is a no-op).
CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
const DragState& state, const DragModifiers& mods);
// The cursor cue the shell should show for a resolved gesture. 1:1 with CardGesture but
// named as a cursor concern so the shell maps it to a SetCursor resource. None -> the
// default arrow. The Replace cue is produced ONLY for CardGesture::Replace (which itself
// requires Alt-over-occupied), satisfying "the replace cursor appears only while Alt is
// held over an occupied slot."
enum class CursorCue {
Default, // arrow — no drag, or dead space
Reorder, // within-bank reorder
Move, // move to another bank/tab
Copy, // copy to another bank/tab
OsDragOut, // pointer left the client (the OS drag loop owns the cursor once handed off)
Replace, // Alt-replace over an occupied slot
};
// Maps a resolved gesture to its cursor cue (pure — the shell owns SetCursor only).
CursorCue cursorForGesture(CardGesture g);
// --- Sparse-aware slot layout + hit-test --------------------------------------
// The pixel rect of one grid SLOT (empty or occupied). Distinct from bank_grid's CellRect
// only in intent — a SlotCellRect carries the slot index it draws, so the shell can map a
// drawn/hit rect back to the model slot without a parallel array. width/height match the
// grid spec; (x, y) is the top-left in the region's grid-viewport coordinates (the shell
// translates by the grid origin exactly as regionCellRects does today).
struct SlotCellRect {
int slot = 0; // the model slot this rect represents (0..maxSlot)
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool operator==(const SlotCellRect& o) const {
return slot == o.slot && x == o.x && y == o.y &&
width == o.width && height == o.height;
}
};
// Tiles slots 0..maxSlot (INCLUSIVE) into a panel of the given pixel width, honoring the
// grid spec — the sparse-aware sibling of bank_grid::computeCellRects. Every slot in
// [0, maxSlot] gets a rect (empty slots included) so a gap draws as an empty cell and a
// drop targets it precisely. `maxSlot` < 0 -> empty (no occupied slots). The rects use the
// SAME column/row math as computeCellRects (slot index in place of item index), so an
// all-dense map (slots 0..N-1) lays out identically to today's grid.
std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
const GridSpec& spec);
// Like computeSlotRects but extends one full trailing row of slots beyond maxSlot so a
// drop pointer past the last occupied card still resolves to a valid target slot. The
// trailing slots (maxSlot+1 .. maxSlot+cols) are empty — a drop on any of them calls
// reorderSample with that slot index, which places the card there directly (no shift,
// because the slot is empty). Used ONLY for drop hit-testing; the draw path uses
// computeSlotRects (no trailing ghost row in the visual).
// When maxSlot < 0 the trailing row starts at slot 0 (same as a fresh bank with no cards).
std::vector<SlotCellRect> computeSlotRectsForDrop(int maxSlot, int panelWidth,
const GridSpec& spec);
// Hit-tests a point against slot rects (half-open bounds, matching hitTestCell). Returns
// the SLOT index (rect.slot) of the first rect containing the point, or -1 on a miss (gap,
// margin, below the last row). NOTE the return is the slot index, NOT the vector index —
// callers reason in model slots.
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects);
} // namespace reasampler::ui
+64
View File
@@ -0,0 +1,64 @@
// card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor.
#include "core/ui/card_meta.h"
#include <cmath>
#include <cstdio>
namespace reasampler::ui {
std::string formatBarsBeats(const MusicalLength& m) {
// No derivable musical read-out without a positive tempo AND a stamped meter.
if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
const double len = m.lengthSeconds > 0.0 ? m.lengthSeconds : 0.0;
// Total beats in THIS meter. A quarter-note is 60/tempo s; a beat is (4/denom)
// quarter-notes, so a beat lasts (60/tempo) * (4/denom) seconds. beats = len / that.
const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom);
double totalBeats = len / secondsPerBeat;
// Snap to an exact beat when we are within a hundredth-of-a-beat epsilon of one, so a
// bar-aligned capture reads "2.1.00" rather than "1.4.99" from FP error just under the
// boundary. The epsilon is well below the .01 display quantum, so it never mis-rounds a
// genuinely fractional length.
const double snapped = std::floor(totalBeats + 0.5);
if (std::fabs(totalBeats - snapped) < 1e-6) totalBeats = snapped;
// Split into whole beats + a fractional remainder (0..1 of a beat).
double wholeBeats = std::floor(totalBeats);
double frac = totalBeats - wholeBeats;
// Bars/beats are 1-based; beat cycles 1..timeSigNum within a bar.
const long wb = static_cast<long>(wholeBeats);
const long bar = wb / m.timeSigNum + 1; // 1-based bar
const long beat = wb % m.timeSigNum + 1; // 1-based beat within the bar
// Subdivision: hundredths of a beat, floored (0..99). A decorative display quantum.
int sub = static_cast<int>(std::floor(frac * 100.0));
if (sub < 0) sub = 0;
if (sub > 99) sub = 99;
char buf[48];
std::snprintf(buf, sizeof(buf), "%ld.%ld.%02d", bar, beat, sub);
return buf;
}
std::string formatSecondsMs(double lengthSeconds) {
double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0;
long secs = static_cast<long>(std::floor(len));
// Round to the nearest millisecond (not floor): FP error means 62.037 s stores as
// 62.0369999... and a raw floor would render "62.036". +0.5 before truncation rounds
// to the closest ms, which is what a wall-clock read-out should show.
int ms = static_cast<int>((len - static_cast<double>(secs)) * 1000.0 + 0.5);
// Rounding can push ms to 1000 at a whole-second boundary; carry into seconds.
if (ms >= 1000) { ms -= 1000; ++secs; }
if (ms < 0) ms = 0;
char buf[48];
std::snprintf(buf, sizeof(buf), "%ld.%03d", secs, ms);
return buf;
}
} // namespace reasampler::ui
+55
View File
@@ -0,0 +1,55 @@
#pragma once
// card_meta — pure formatting for the L7 decorative card metadata overlay. Each bank
// card overlays capture length as bars.beats.subdivisions (bottom-LEFT, musical) and
// seconds.milliseconds (bottom-RIGHT, wall-clock). Both read-outs are DECORATIVE and
// non-interactive; the bank_panel draws them via the L1 kit. The formatting itself is
// pure string work over the sample's stamped tempo + meter + length, so it is
// unit-tested outside the DAW (CLAUDE.md §load-bearing split).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard
// library only. Mirror of tooltip's prefix-strip helper.
#include <string>
namespace reasampler::ui {
// The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp):
// lengthSeconds — captured length in wall-clock seconds (>= 0).
// tempoBpm — project tempo (BPM) at capture (Sample.captureTempo); 0 = unknown.
// timeSigNum — meter numerator at capture (Sample.captureTimeSigNum); 0 = unstamped.
// timeSigDenom — meter denominator at capture (Sample.captureTimeSigDenom); 0 = unstamped.
struct MusicalLength {
double lengthSeconds = 0.0;
double tempoBpm = 0.0;
int timeSigNum = 0;
int timeSigDenom = 0;
};
// bars.beats.subdivisions from a capture-time tempo + meter stamp (musical read-out).
//
// Derivation: one quarter-note lasts 60 / tempo seconds; a beat in this meter lasts
// (4 / timeSigDenom) quarter-notes; a bar holds timeSigNum beats. From lengthSeconds we
// get total beats, split into whole bars (÷ timeSigNum) + whole leftover beats + a
// subdivision remainder scaled to 1..N of the next beat. The output is 1-BASED and
// zero-padded to two subdivision digits: "1.1.00" is exactly one bar-start (a
// zero-length or bar-aligned capture), "2.3.50" is 1 bar + 2 beats + half a beat.
//
// Contract / edge cases (all tested):
// * UNSTAMPED meter (timeSigNum <= 0 || timeSigDenom <= 0) OR unknown tempo
// (tempoBpm <= 0): returns "" — no musical read-out is derivable (the caller keeps
// the s.ms read-out). This is the pre-L7-sample fallback (blank musical read-out).
// * zero length: "1.1.00" (bar 1, beat 1, no subdivision) — the musical origin.
// * exact bar boundary: the beat rolls to 1 and the bar increments (never "1.5.00"
// in 4/4 — that reads as "2.1.00").
// * long captures: bars grow without cap ("129.1.00" is fine).
// The subdivision is 0..99 (hundredths of a beat), floored — a display quantum, not a
// tick-accurate PPQ (the model refuses to invent PPQ; this is a decorative read-out).
std::string formatBarsBeats(const MusicalLength& m);
// seconds.milliseconds from a wall-clock length (always derivable, meter-independent).
// * "S.mmm" — integer seconds, a dot, zero-padded 3-digit milliseconds (rounded to nearest ms).
// e.g. 0.0 -> "0.000", 1.5 -> "1.500", 62.037 -> "62.037".
// * negative length is clamped to "0.000" (a length is never negative; defensive).
std::string formatSecondsMs(double lengthSeconds);
} // namespace reasampler::ui
+108
View File
@@ -0,0 +1,108 @@
// component_geometry — pure implementation. See component_geometry.h. NO REAPER / SWELL /
// LICE / vendor. Standard library only.
#include "core/ui/component_geometry.h"
namespace reasampler::ui {
bool hitTestBox(int px, int py, const KitBox& box) {
if (box.empty()) return false;
return px >= box.x && px < box.x + box.width &&
py >= box.y && py < box.y + box.height;
}
KitButtonBox computeButtonBox(const KitBox& cell, int padding) {
if (cell.empty()) return {};
if (padding < 0) padding = 0;
KitBox b;
b.x = cell.x + padding;
b.y = cell.y + padding;
b.width = cell.width - 2 * padding;
b.height = cell.height - 2 * padding;
if (b.empty()) return {}; // padding collapsed the cell -> suppress
return KitButtonBox{b};
}
SliderGeometry computeSlider(const KitBox& control, double value,
int handleSize, int trackThickness) {
if (control.empty() || handleSize <= 0 || trackThickness <= 0) return {};
// The handle must fit in both axes; too small -> nothing sensible to draw.
if (control.width < handleSize || control.height < handleSize) return {};
if (value < 0.0) value = 0.0;
if (value > 1.0) value = 1.0;
const int half = handleSize / 2;
// Track: horizontally inset by half the handle at each end so the handle's centre
// travels only within the control; vertically centred at trackThickness.
KitBox track;
track.x = control.x + half;
track.width = control.width - handleSize; // travel span for the handle centre
if (track.width < 0) track.width = 0;
track.height = trackThickness;
track.y = control.y + (control.height - trackThickness) / 2;
// Handle centre travels [track.x, track.x + track.width]; its box is centred on that.
const int centre = track.x + static_cast<int>(value * track.width + 0.5);
KitBox handle;
handle.x = centre - half;
handle.y = control.y + (control.height - handleSize) / 2;
handle.width = handleSize;
handle.height = handleSize;
// Filled portion: from the track's left up to the handle centre.
KitBox filled;
filled.x = track.x;
filled.y = track.y;
filled.width = centre - track.x;
if (filled.width < 0) filled.width = 0;
filled.height = track.height;
return SliderGeometry{track, filled, handle};
}
double sliderValueAt(int px, const KitBox& control, int handleSize) {
if (control.empty() || handleSize <= 0) return 0.0;
if (control.width < handleSize) return 0.0;
const int half = handleSize / 2;
const int trackStart = control.x + half;
const int trackSpan = control.width - handleSize; // matches computeSlider's travel
if (trackSpan <= 0) return 0.0;
if (px <= trackStart) return 0.0;
if (px >= trackStart + trackSpan) return 1.0;
return static_cast<double>(px - trackStart) / static_cast<double>(trackSpan);
}
ListRowBox computeListRow(const KitBox& list, int index, int rowHeight) {
if (list.empty() || rowHeight <= 0 || index < 0) return {};
const int top = list.y + index * rowHeight;
// Fully below the list bottom -> clipped away entirely -> no box.
if (top >= list.y + list.height) return {};
KitBox b;
b.x = list.x;
b.y = top;
b.width = list.width;
b.height = rowHeight; // a partially-visible last row keeps full height; caller clips
return ListRowBox{index, b};
}
int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount) {
if (list.empty() || rowHeight <= 0 || rowCount <= 0) return -1;
// Outside the list band entirely.
if (px < list.x || px >= list.x + list.width ||
py < list.y || py >= list.y + list.height)
return -1;
const int row = (py - list.y) / rowHeight;
if (row < 0 || row >= rowCount) return -1; // in the empty tail past the last row
return row;
}
int waveformColumnCount(const KitBox& box) {
const int w = box.width - 4; // fixed 2px inset each side (matches drawWaveform)
return w > 0 ? w : 0;
}
} // namespace reasampler::ui
+129
View File
@@ -0,0 +1,129 @@
#pragma once
#include "core/ui/rect.h"
// component_geometry — the REAPER-free, LICE-free geometry + hit-test math for the shared
// drawing kit's generic components (Phase L, L1): a button box, a slider's track/handle,
// and a list row. These are the kit-level primitives that DON'T already have a pure owner:
// bank_grid / mode_switch / tab_strip / prune_button stay the source of truth for the
// surfaces THEY own; this module carries only the new, reusable component
// shapes the kit's drawButton / drawSlider / drawListRow draw against.
//
// Why pure (CLAUDE.md §load-bearing split, DS-1 caution): even where the draw shell reuses
// a WDL/vwnd drawing idiom, the hit-test geometry stays HERE, unit-tested outside the DAW —
// vwnd's retained-mode controls own their hit-test internally, which this deliberately does
// NOT import. The shell asks this module where a handle is and whether a point hit a row.
//
// NAME NOTE (brief §name-collision): the surrounding modules already own ButtonRect /
// SegmentRect / CellRect / FooterRect etc. in this namespace, so this module's types are
// named KitButtonBox / SliderGeometry / ListRowBox to avoid collision — checked with grep
// before minting. They are distinct concepts (kit-generic component boxes vs. a specific
// surface's hit rects), so the separate names are correct, not merely non-colliding.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER. Mirror of mode_switch / prune_button.
namespace reasampler::ui {
// A generic pixel box, top-left origin (SWELL/LICE convention). Shared shape for the kit
// component rects below. A zero-area box (empty()) means "nothing to draw / hit" — the
// same graceful-suppression convention prune_button uses.
using KitBox = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// True iff (px, py) falls inside `box`, half-open bounds [x, x+width) x [y, y+height) —
// the same discipline as every sibling hit-test so draw and hit-test never double-claim a
// pixel. An empty box claims no point (always false).
bool hitTestBox(int px, int py, const KitBox& box);
// --- Button ------------------------------------------------------------------
//
// A button drawn inside a host cell, inset by a uniform padding so it reads as a raised
// control rather than a full-bleed fill (the kit's drawButton draws the micro-gradient
// surface inside this box). Distinct from prune_button, which owns its OWN placement
// within its strip — this is the generic "given a cell, where's the
// button" helper for new kit consumers.
struct KitButtonBox {
KitBox box;
bool operator==(const KitButtonBox& o) const { return box == o.box; }
};
// The button box inside `cell`, inset uniformly by `padding` on all four sides. Returns an
// empty box (suppressed) when the cell is degenerate or the padding would collapse it to
// zero-or-negative area — the caller then draws nothing (graceful, mirrors prune_button).
// padding < 0 is treated as 0.
KitButtonBox computeButtonBox(const KitBox& cell, int padding);
// --- Slider (horizontal) -----------------------------------------------------
//
// A horizontal slider: a track spanning the control width (inset at both ends by the
// handle's half-width so the handle never clips past the track), and a square handle
// centered on the track and positioned by the normalized value. drawSlider draws the
// track, the filled portion up to the handle, and the handle. Hit-test is against the
// handle (grab) and the track (jump); both are pure here.
struct SliderGeometry {
KitBox track; // the full track rect (the groove)
KitBox filled; // the filled portion from the track's left up to the handle center
KitBox handle; // the draggable handle rect
bool operator==(const SliderGeometry& o) const {
return track == o.track && filled == o.filled && handle == o.handle;
}
};
// Lays out a horizontal slider inside `control` for a normalized `value` in [0, 1] with a
// square handle of side `handleSize`. The track is vertically centered at a fixed
// `trackThickness`, inset horizontally by handleSize/2 at each end so the handle's travel
// stays within `control`. value is clamped to [0, 1]; a value of 0 puts the handle flush
// left, 1 flush right. Returns all-empty boxes when the control is degenerate or too
// small to host the handle (control width < handleSize or height < handleSize) — the
// caller draws nothing. handleSize <= 0 or trackThickness <= 0 also yields empty.
SliderGeometry computeSlider(const KitBox& control, double value,
int handleSize, int trackThickness);
// The normalized value [0, 1] a click at px maps to, for a slider laid out in `control`
// with `handleSize` (the inverse of computeSlider's handle placement — a track jump).
// px left of / at the track start yields 0.0, at/right of the track end yields 1.0,
// linear in between. Returns 0.0 for a degenerate/too-small control (no travel). py is
// unused (a horizontal slider maps X only); the caller gates the whole slider region
// with hitTestBox(control) before calling this.
double sliderValueAt(int px, const KitBox& control, int handleSize);
// --- List row ----------------------------------------------------------------
//
// A single selectable row in a vertical list: full-width, fixed height, stacked from the
// list's top by index (no scroll — the caller offsets the list origin for scroll). The
// kit's drawListRow draws the row surface (rest/hover/selected/focus) and an optional
// leading thumbnail; the panel's waveform cell is a specialization drawn the same way.
struct ListRowBox {
int index = 0; // the row's index in the caller's list (0-based, top-first)
KitBox box;
bool operator==(const ListRowBox& o) const {
return index == o.index && box == o.box;
}
};
// The row box for `index` in a list laid out inside `list` at `rowHeight` per row. Rows
// stack from list.y; row i spans [list.y + i*rowHeight, +rowHeight). Returns an empty box
// when the list is degenerate, rowHeight <= 0, index < 0, or the row would fall entirely
// below the list's bottom (fully clipped) — a partially-visible last row IS returned (the
// caller clips the draw). This is layout only; the caller decides how many rows exist.
ListRowBox computeListRow(const KitBox& list, int index, int rowHeight);
// The index of the row a point (px, py) lands on, for a list laid out inside `list` at
// `rowHeight`. Returns -1 for a miss: outside the list bounds, in the list band but below
// the last row of `rowCount` rows (the empty tail), or a degenerate list/rowHeight/count.
// rowCount bounds the hit so a click in blank space past the last row is a clean miss, not
// a phantom row. Half-open bounds match computeListRow so the hit maps to the drawn row.
int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount);
// --- Waveform column count ---------------------------------------------------
//
// The number of pixel columns drawWaveform renders inside `box` (its fixed 2px side
// insets), never negative. Callers pass this count directly as the `binCount` argument to
// peaks::computeEnvelope — one bin per column is the correct resolution, and
// peaks::columnMinMax's exact partition makes the render gap-free at any bins-to-pixels
// ratio. Overbinning does NOT improve render quality (columnMinMax's frame union is
// identical whether bins == columns or bins == k*columns) and wastes memory and CPU.
int waveformColumnCount(const KitBox& box);
} // namespace reasampler::ui
+54
View File
@@ -0,0 +1,54 @@
// drag_out — pure implementation. See drag_out.h. NO REAPER / SWELL / OS / vendor.
#include "core/ui/drag_out.h"
#include <unordered_set>
namespace reasampler::ui {
namespace {
// Half-open point-in-rect (matches the panel's other hit-tests: [x, x+w) x [y, y+h)).
bool insideClient(int px, int py, const PanelClientRect& c) {
return px >= c.x && px < c.x + c.width &&
py >= c.y && py < c.y + c.height;
}
} // namespace
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state) {
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
if (insideClient(px, py, client)) return DragGesture::Internal;
// Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is
// still over REAPER's own UI is an instrument drop (heading for a track's FX button);
// anything else (a multi-capture payload, or the pointer off REAPER entirely) is the
// unchanged M11 OS drag-out.
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
return DragGesture::OsDrag;
}
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
PathList out;
std::unordered_set<std::string> seen;
seen.reserve(resolved.size());
for (const ResolvedSample& s : resolved) {
if (s.absolutePath.empty()) { // shell could not resolve it
++out.skippedUnresolved;
continue;
}
if (!s.fileExists) { // stale index entry, file gone
++out.skippedMissing;
continue;
}
if (!seen.insert(s.absolutePath).second) { // already emitted this path
++out.skippedDuplicate;
continue;
}
out.paths.push_back(s.absolutePath);
}
return out;
}
} // namespace reasampler::ui
+132
View File
@@ -0,0 +1,132 @@
#pragma once
#include "core/ui/rect.h"
// drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS
// drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are
// unit-tested outside the DAW (CLAUDE.md §load-bearing split); the OLE / SWELL initiation
// and the bank_panel gesture hook stay in the shell (drag_out_win.* + bank_panel.cpp).
//
// 1. GESTURE BOUNDARY (invariant #4 — do not regress the internal drag). The panel
// already runs an INTERNAL drag: press a selected cell, cross a threshold, drop onto
// a pool/banks region or a tab to move/copy the samples between banks. That drag lives
// entirely INSIDE the panel client rect. The OS drag is a DISTINCT gesture with a
// distinct, discoverable boundary: while a drag is armed with samples in the payload,
// the moment the pointer LEAVES the panel client area the gesture becomes OS-bound —
// the payload is being dragged out to another window / Explorer / another DAW. Inside
// the client area it stays internal; with no armed samples there is no drag at all.
// This function is that decision, pure over (drag state + pointer + panel rect).
//
// 2. PATH-LIST ASSEMBLY. The OS drop carries absolute file paths (Windows CF_HDROP /
// macOS file-list pasteboard). Turning the armed sample ids into that path list —
// resolving each id to its already-on-disk bank file, de-duping, and applying an
// explicit skip-missing-file policy — is pure string work over a resolver the shell
// supplies (the shell owns the REAPER project-dir read + resolveBankFile; this module
// owns the set algebra and the result contract). NO temp files: the bank files already
// exist; the list points straight at them (COPY-ONLY is enforced at the OS layer — see
// drag_out_win — never by relocating or copying bytes here).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO OS/OLE, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER. Mirror of mode_switch.
#include <string>
#include <vector>
namespace reasampler::ui {
// --- Gesture boundary ---------------------------------------------------------
// The panel's client rectangle in its own client coordinates (top-left origin, the SWELL/
// LICE convention). width/height are the extents; a point (px, py) is INSIDE when
// x <= px < x + width and y <= py < y + height (half-open, matching the panel's other
// hit-tests so the edge is claimed consistently).
using PanelClientRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// The live drag state the shell tracks, reduced to what the boundary decision needs:
// whether a drag is currently active (threshold crossed) and whether the armed payload
// carries at least one sample. (Pre-threshold "armed but not yet dragging" is NOT a drag
// for this decision — the shell only asks once a drag is under way.)
//
// S17 (drop-and-load) adds two inputs that refine the OUTSIDE-the-panel decision without
// touching the INSIDE decision (the internal bank-to-bank drag stays byte-identical):
// * singleCapture — the payload holds EXACTLY ONE sample id. Only a single-capture drag
// arms the InstrumentDrop gesture (per the S17 open-question lean: a multi-capture drag
// over an FX button is NOT an instrument drop — it falls through to OsDrag, the natural
// multi-file drag-out to Explorer/another DAW). REJECT, not load-first: the whole gesture
// is "make ONE capture a playable instrument", so a multi payload is out of contract here.
// * overReaperUi — a SHELL-SUPPLIED predicate: true when the pointer, though outside the
// panel client rect, is still over REAPER's OWN window/UI (the shell owns the REAPER
// hit query, e.g. GetThingFromPoint; the pure layer owns only the set/boundary algebra).
// Both default false, so an M11-era caller that fills only {dragging, hasArmedSamples} gets
// EXACTLY the M11 behavior: outside the client rect with overReaperUi=false -> OsDrag.
struct DragState {
bool dragging = false; // threshold crossed; a drag is in progress
bool hasArmedSamples = false; // the drag payload holds >= 1 sample id
bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop)
bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied)
};
// What the shell should do with the drag given the current pointer position.
enum class DragGesture {
None, // no drag under way, or an empty payload — do nothing
Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag
InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI —
// the shell hover-tracks the TCP FX button and, on release, adds a
// ReaSampler 9000 instance preloaded with the dragged capture.
OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS
};
// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`.
// * Not dragging (or no armed samples): None — the shell ignores the move.
// * Dragging with samples, pointer INSIDE the client rect: Internal — unchanged
// bank-to-bank behavior (invariant #4: the internal drag stays byte-identical).
// * Dragging OUTSIDE the client rect, SINGLE capture, over REAPER's UI: InstrumentDrop —
// the drag is heading for a track's FX button (S17); the shell hover-tracks + highlights.
// * Dragging OUTSIDE the client rect otherwise (multi-capture, OR the pointer has left
// REAPER entirely): OsDrag — the samples are leaving to the OS; the shell initiates the
// native OS drag with the resolved paths.
// The INSIDE decision is untouched (M11 internal drag is byte-identical). The M11 boundary
// (left the client rect -> OsDrag) is REFINED, not replaced: leaving the rect now asks
// "single-capture and over REAPER's UI -> InstrumentDrop, else -> OsDrag" — so the M11
// OS-drag-out (multi payload, or pointer off REAPER) keeps its exact behavior. Position-only
// + state-only (no hidden state), so re-entry back inside returns Internal.
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state);
// --- Path-list assembly -------------------------------------------------------
// One armed sample reduced to what path assembly needs: the resolved ABSOLUTE file path
// the shell computed for it (empty when the shell could not resolve it — e.g. no project
// dir / empty relative path). The shell resolves each via the SAME machinery the panel
// already uses for audition/insert (resolveBankFile over the current project dir), so the
// drag points at the real bank file — no temp copy.
struct ResolvedSample {
std::string absolutePath; // resolved absolute path, or "" when unresolvable
bool fileExists = false; // shell stat() result — drives the skip-missing policy
};
// The outcome of assembling the drag's path list: the de-duped, existing-only absolute
// paths to hand to the OS, plus explicit tallies so the shell can decide whether to
// initiate at all (an empty `paths` means nothing draggable — do NOT start a drag).
struct PathList {
std::vector<std::string> paths; // de-duped, existing files, in first-seen order
int skippedMissing = 0; // resolved but file did not exist (skip policy)
int skippedUnresolved = 0; // shell could not resolve a path at all
int skippedDuplicate = 0; // same absolute path seen more than once
};
// Assembles the drag path list from the resolved samples (in selection order).
// Policy (all explicit, all tested):
// * SKIP-MISSING: a sample whose file does not exist on disk is skipped (counted in
// skippedMissing) — a stale index entry must never put a dangling path on the OS
// clipboard. This is the deliberate skip policy the brief asks be made explicit.
// * SKIP-UNRESOLVED: an empty absolutePath (shell could not resolve) is skipped
// (skippedUnresolved) — same reasoning, no empty entry reaches the OS.
// * DEDUPE: the same absolute path appearing twice (two index entries, one file — the
// cross-bank copy case) yields ONE CF_HDROP entry (skippedDuplicate counts the extras),
// so the OS never sees a duplicate drop path. First occurrence wins; order preserved.
// * EMPTY SELECTION: an empty input yields an empty PathList (all tallies zero) — the
// shell reads paths.empty() and does not start a drag.
// Comparison is exact-string (the shell normalizes slashes/case upstream if it wants
// case-insensitive dedup on Windows — the pure layer does not guess a platform rule).
PathList assemblePathList(const std::vector<ResolvedSample>& resolved);
} // namespace reasampler::ui
+69
View File
@@ -0,0 +1,69 @@
// footer_bar — pure implementation. See footer_bar.h. NO REAPER / SWELL / LICE / vendor.
#include "core/ui/footer_bar.h"
namespace reasampler::ui {
namespace {
// True iff a box [x, x+width) fits entirely left of `rightBound` (its right edge does not
// cross the reserved right region). A non-positive width never "fits" (nothing to place).
bool fitsLeftOf(int x, int width, int rightBound) {
return width > 0 && x + width <= rightBound;
}
bool pointIn(int px, int py, const FooterBarRect& r) {
return !r.empty() && px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height;
}
} // namespace
FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec) {
FooterBarLayout out;
if (footer.width <= 0 || footer.height <= 0) return out;
const int top = footer.y + spec.verticalInset;
const int boxH = footer.height - 2 * spec.verticalInset;
if (boxH <= 0) return out;
// The right bound the LEFT group must stay clear of (prune + version region). Clamp so a
// pathologically large rightReserve never yields a negative bound.
int rightBound = footer.x + footer.width - spec.rightReserve;
if (rightBound < footer.x) rightBound = footer.x;
int cursorX = footer.x + spec.leftPad;
// Toggle (most important — placed first, drops last).
if (fitsLeftOf(cursorX, spec.toggleWidth, rightBound)) {
out.toggle = FooterBarRect{cursorX, top, spec.toggleWidth, boxH};
cursorX += spec.toggleWidth + spec.gap;
} else {
return out; // no room for even the toggle — nothing else can fit either
}
// Count label (passive readout). Suppressed by countWidth <= 0 (no gap consumed then).
if (spec.countWidth > 0) {
if (fitsLeftOf(cursorX, spec.countWidth, rightBound)) {
out.count = FooterBarRect{cursorX, top, spec.countWidth, boxH};
cursorX += spec.countWidth + spec.gap;
}
// If the count does not fit, do NOT advance the cursor past it — the Tail button then
// gets its chance at the same slot (a passive label yields to the interactive button).
}
// Tail button.
if (fitsLeftOf(cursorX, spec.tailWidth, rightBound))
out.tail = FooterBarRect{cursorX, top, spec.tailWidth, boxH};
return out;
}
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) {
// Toggle first (matches the shell's segment sub-hit precedence), then the Tail button. The
// count label is a passive readout — never a hit target.
if (pointIn(px, py, layout.toggle)) return FooterHit::Toggle;
if (pointIn(px, py, layout.tail)) return FooterHit::Tail;
return FooterHit::None;
}
} // namespace reasampler::ui
+106
View File
@@ -0,0 +1,106 @@
#pragma once
#include "core/ui/rect.h"
// footer_bar — the REAPER-free, LICE-free layout + hit-test math for the bank_panel's L4
// footer LEFT group: the narrowed [Arrange|Design] mode toggle, its compact per-mode count
// label, and the Tail button, laid out left-to-right at the footer's left. The panel shell
// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the click dispatch (cycle tail /
// activate a mode); what is NOT DAW-bound — WHERE the toggle box, the count label, and the
// Tail button sit, and which one a click lands on — lives here so it is unit-tested outside
// the DAW (CLAUDE.md §load-bearing split). Mirror of action_bar / mode_switch / prune_button.
//
// -- Footer affordance order (L4, left -> right) -------------------------------
//
// [Arrange|Design] toggle . count label . Tail button . ... . Prune (rightmost, warn)
//
// The two view/session controls (mode toggle, tail) group at the LEFT as the "how this
// panel/capture behaves" cluster; Prune stays isolated at the far RIGHT, warn-colored and
// set apart (it is the only byte-deleting affordance). This module lays out the LEFT group
// ONLY — the rightmost Prune button remains owned by prune_button (computePruneButton), so
// the two never fight over the same pixels. footer_bar reserves a right margin (rightReserve)
// so its own affordances never run under the prune button's region.
//
// The mode toggle is drawn as an N-segment control (2 segments for Arrange|Design; N general).
// footer_bar returns only the toggle's BOX (fit to its text width); the shell hands that box's
// width to the pure mode_switch (computeSegmentRects / hitTestSegment) for the per-segment
// tiling and hit-test, so mode_switch stays the ONE owner of segment geometry. footer_bar
// decides the toggle's placement + overall width; mode_switch subdivides it.
//
// Naming: the rect-role family (ButtonRect / FooterRect / FooterBarRect / ...) is unified on
// the ONE concrete ui::Rect (core/ui/rect.h, Q-W1 T2-05) — the per-role names are aliases, so
// the former hand-collision bookkeeping is retired. FooterRect (prune_button) remains the
// shared input-strip spelling; this module's output/spec/hit types carry the FooterBar* prefix.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include "core/ui/prune_button.h" // FooterRect — the footer strip input type (shared, not re-minted)
namespace reasampler::ui {
// One placed affordance's pixel rectangle within the footer, top-left origin. A zero-area rect
// (empty()) means "not placed" (the footer was too narrow to host it after the ones before it),
// so the shell draws/hit-tests nothing for it — graceful degradation, mirroring prune_button.
using FooterBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// The laid-out footer LEFT group: the mode toggle box, the count label box, and the Tail
// button box, in left-to-right order. Any box may be empty (suppressed) when the footer is
// too narrow to fit it left of the reserved right margin — placement is greedy left-to-right,
// so an earlier affordance survives while a later one drops (the toggle is most important,
// the Tail button drops first on a very narrow footer).
struct FooterBarLayout {
FooterBarRect toggle; // the [Arrange|Design] segmented control's overall box
FooterBarRect count; // the compact per-mode count label (right of the toggle)
FooterBarRect tail; // the Tail button (right of the count label)
bool operator==(const FooterBarLayout& o) const {
return toggle == o.toggle && count == o.count && tail == o.tail;
}
};
// Which footer LEFT-group affordance a point landed on (or None for a miss / a suppressed
// affordance). Prune is NOT here — the shell hit-tests it separately via hitTestPruneButton.
enum class FooterHit { None, Toggle, Tail };
// Layout inputs for the footer LEFT group, in pixels. Defaults are the bank_panel footer
// metrics; the shell passes its own so draw and hit-test share ONE source of truth.
// * toggleWidth — the [Arrange|Design] toggle's overall width. Sized to fit its two
// segment labels comfortably (a NARROW control, per L4 §3 — no longer the
// full-width top header). The shell picks this to fit its text; the pure
// module treats it as a fixed input.
// * countWidth — the compact per-mode count label's width (e.g. "2 tracks"). 0 hides it.
// * tailWidth — the Tail button's width (fits "Tail: Manual 8.0s" comfortably).
// * gap — horizontal gap between adjacent affordances.
// * leftPad — inset from the footer left edge to the toggle's left edge.
// * verticalInset — top/bottom gap inside the footer so the controls read as raised, not
// full-height fills (matches prune_button's verticalInset).
// * rightReserve — pixels reserved at the footer's RIGHT for the prune button + version
// readout region; footer_bar never places an affordance whose right edge
// would cross into (footer.right - rightReserve). Keeps the LEFT group
// clear of the RIGHT prune/version region without those modules coupling.
struct FooterBarSpec {
int toggleWidth = 132;
int countWidth = 64;
int tailWidth = 132;
int gap = 6;
int leftPad = 8;
int verticalInset = 4;
int rightReserve = 168; // clears prune_button (rightInset 84 + width 72) + margin
};
// Lays out the footer LEFT group inside `footer` per `spec`, left-to-right: toggle, then the
// count label, then the Tail button, each `gap` px apart, starting at footer.left + leftPad,
// vertically centred by verticalInset. Greedy: an affordance is placed only if its whole box
// fits left of (footer.right - rightReserve); otherwise it (and, since placement is ordered,
// it alone or the ones after it) is suppressed (empty box). A degenerate footer (width/height
// <= 0) yields an all-empty layout. countWidth <= 0 suppresses the count label (and the gap
// that would precede the Tail button collapses so the Tail sits right after the toggle).
FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec& spec);
// The footer LEFT-group affordance the point (px, py) (SWELL/LICE top-left client coords) lands
// on, or FooterHit::None for a miss (outside every placed box, or on the count label — which is
// a passive readout, not a control). Half-open bounds [x, x+width) x [y, y+height) match
// computeFooterBar so draw and hit-test agree on the same pixels. An empty (suppressed) box
// never claims a point. The shell checks the toggle hit FIRST for a segment sub-hit (via
// mode_switch over the toggle box), then the Tail hit; this returns which region was struck.
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout);
} // namespace reasampler::ui
+21
View File
@@ -0,0 +1,21 @@
// mode_enable — pure implementation. See mode_enable.h. NO REAPER / SWELL / LICE / vendor.
#include "core/ui/mode_enable.h"
#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId — the ONE home for the mode ids
namespace reasampler::ui {
bool tagButtonEnabled(const std::string& activeModeId, TagTarget target) {
// The target's own mode id, so the rule is a single "target != active" compare.
const char* targetId =
(target == TagTarget::Arrange) ? kArrangeModeId : kDesignModeId;
// Fail-open on an unrecognized active id (neither seed mode): every button live, so a
// future added mode never dead-locks the bar and the user can always reach the action.
if (activeModeId != kArrangeModeId && activeModeId != kDesignModeId) return true;
return activeModeId != targetId;
}
} // namespace reasampler::ui
+39
View File
@@ -0,0 +1,39 @@
#pragma once
// mode_enable — the REAPER-free opposite-mode enablement predicate behind the bank_panel BOTTOM
// toolbar's four Item/Track × Arrange/Design tag buttons (Phase L, L5, refinement 3). Each tag
// button sends the selection to a TARGET mode; a button is meaningful ONLY when its target is
// the OPPOSITE of the currently active mode. When Design is active the two "…: Arrange" buttons
// are live and the two "…: Design" buttons are dead (already there); when Arrange is active the
// reverse. This module owns that one decision — (active mode, button target) -> live/disabled —
// as a pure predicate, unit-tested for both active modes; the shell reads the active mode from
// view().activeModeId() (the SAME source the footer toggle reads — one source of truth for
// "which mode is active") and draws the disabled buttons in the kit Disabled state.
//
// Why pure: which button is live is a decision, not a draw or a DAW behaviour. Keeping it here
// means the shell cannot drift the enablement from the rule, and both active modes are covered
// by CTest, not only whichever one a manual DAW pass happened to sit in.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include <string>
namespace reasampler::ui {
// A tag button's TARGET mode — the mode it sends the selection to when fired. Arrange = the
// untagged default (returning the selection to Arrange), Design = tagged into the Design mode.
// The Item/Track axis is orthogonal to enablement (both Item and Track buttons for a target
// enable/disable together), so it is NOT modelled here — the shell carries it per button.
enum class TagTarget {
Arrange,
Design,
};
// True iff a tag button whose target is `target` should be LIVE (clickable), given the active
// mode id `activeModeId` (as returned by ViewModeModel::activeModeId() — the mode ids are the
// pure `kArrangeModeId` / `kDesignModeId` constants). The rule: a button is live iff its target
// differs from the active mode — you tag INTO the mode you are not currently in. An unrecognized
// active id (neither arrange nor design) leaves every button live (fail-open: never silently
// disable an action the user can still reach), so a future added mode never dead-locks the bar.
bool tagButtonEnabled(const std::string& activeModeId, TagTarget target);
} // namespace reasampler::ui
+42
View File
@@ -0,0 +1,42 @@
// overflow_menu — pure implementation. See overflow_menu.h. NO REAPER / SWELL / LICE / vendor.
#include "core/ui/overflow_menu.h"
namespace reasampler::ui {
int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec) {
if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return 0;
// The reserve is the button width plus a right gap (rightInset) and a matching left gap
// (also rightInset) so the frequent buttons have breathing room before the menu button.
return spec.buttonWidth + 2 * spec.rightInset;
}
MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec) {
MenuButtonRect btn;
if (bar.width <= 0 || bar.height <= 0 || spec.buttonWidth <= 0) return btn;
const int right = bar.x + bar.width - spec.rightInset;
const int left = right - spec.buttonWidth;
if (left < bar.x + spec.minLeftInset) return btn; // too narrow — suppress
int top = bar.y + spec.verticalInset;
int height = bar.height - 2 * spec.verticalInset;
if (height <= 0) { // thin band: clamp to the band's own extents rather than go negative
top = bar.y;
height = bar.height;
}
btn.x = left;
btn.y = top;
btn.width = spec.buttonWidth;
btn.height = height;
return btn;
}
bool hitTestMenuButton(int px, int py, const MenuButtonRect& button) {
if (button.empty()) return false;
return px >= button.x && px < button.x + button.width &&
py >= button.y && py < button.y + button.height;
}
} // namespace reasampler::ui
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include "core/ui/rect.h"
// overflow_menu — the REAPER-free layout math behind the bank_panel TOP toolbar's "⋯ / More"
// overflow-menu button (Phase L, L5, refinement 1). The rare capture variants (Batch Items /
// Batch Razor / Capture RT) move OFF the always-visible top bar into a popup opened by a small
// square button pinned to the FAR RIGHT of the top toolbar band. This module owns two things,
// both unit-tested outside the DAW:
// * WHERE the More button sits in the top toolbar band (right-anchored, vertically inset);
// * the horizontal RESERVE the action_bar must leave for it, so the frequent buttons never
// run under the menu button (the shell shrinks the action_bar's usable width by this).
// The popup itself (TrackPopupMenu) + the command dispatch is shell — a transient OS menu, not
// panel chrome (brief §1: "a REAPER/host popup menu is acceptable"). Only the button
// geometry + hit-test live here.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
// Mirror of prune_button / mode_switch. The bar rect type it consumes mirrors action_bar's
// ActionBarRect shape but is named distinctly to avoid coupling the two modules.
namespace reasampler::ui {
// The toolbar band the button is drawn into, top-left origin (SWELL/LICE convention). The
// shell derives this from topToolbarRect(). A distinct type from action_bar::ActionBarRect so
// this module stands alone (same shape; deliberate — the two modules are not coupled).
using MenuBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// The More button's pixel rectangle within the band, top-left origin. A zero-area rect
// (width <= 0 or height <= 0) means "no button" — the band is degenerate or too narrow to
// place the button clear of its left inset; the caller must not draw or hit-test it. The
// three variants stay reachable via their bindable commands, so a suppressed button is
// graceful, not a lost affordance.
using MenuButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Layout inputs for the More button, in pixels. Defaults match the bank_panel top-toolbar
// metrics; the shell passes its own so draw and hit-test share one source of truth.
// * buttonWidth — the button's fixed width (a compact square-ish glyph button).
// * rightInset — gap from the band's right edge to the button's right edge.
// * verticalInset — top/bottom gap inside the band (shorter than the band so it reads as a
// raised control, matching the action_bar buttons' verticalInset).
// * minLeftInset — the button's left edge must stay at least this far from the band left
// edge; if it would encroach past this, computeMenuButton yields an empty
// rect (button suppressed).
struct MenuButtonSpec {
int buttonWidth = 28;
int rightInset = 6;
int verticalInset = 3;
int minLeftInset = 40;
};
// The horizontal reserve (px) the action_bar must leave at the band's right so its buttons
// never run under the More button: the button width + both insets (right gap + a matching
// left breathing gap equal to rightInset). The shell subtracts this from the action_bar rect's
// width before laying out slots. Returns 0 for a degenerate band (nothing to reserve).
int menuButtonReserve(const MenuBarRect& bar, const MenuButtonSpec& spec);
// Computes the More button's rect within `bar` per `spec`. Right-anchored: the button's right
// edge is bar.x + bar.width - rightInset, its width is buttonWidth, vertically centred by
// verticalInset. Returns an EMPTY rect when: the band is degenerate (width/height <= 0), the
// buttonWidth is non-positive, OR the resulting left edge would fall closer to the band left
// than minLeftInset. A thin band clamps the button height to the band's own rather than going
// negative (mirror of computePruneButton).
MenuButtonRect computeMenuButton(const MenuBarRect& bar, const MenuButtonSpec& spec);
// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`.
// Half-open bounds [x, x+width) x [y, y+height) — matches computeMenuButton so draw and
// hit-test agree on the same pixels. An empty button never claims a point (always false).
bool hitTestMenuButton(int px, int py, const MenuButtonRect& button);
} // namespace reasampler::ui
+39
View File
@@ -0,0 +1,39 @@
#include "core/ui/prune_button.h"
// prune_button implementation — right-anchored button placement in the footer strip,
// with a left-collision suppression rule. Trivially auditable arithmetic; the safety
// property (a suppressed/empty button never claims a click) is a pure predicate tested
// outside the DAW.
namespace reasampler::ui {
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec) {
if (footer.width <= 0 || footer.height <= 0) return ButtonRect{}; // degenerate footer
if (spec.buttonWidth <= 0) return ButtonRect{}; // nothing to place
// Right-anchored: right edge inset from the footer's right; width fixed.
const int right = footer.x + footer.width - spec.rightInset;
const int left = right - spec.buttonWidth;
// Suppress if the button would encroach past the reserved left inset (tail label room)
// or spill off the left of the footer entirely.
if (left < footer.x + spec.minLeftInset) return ButtonRect{};
// Vertically centred by the inset; clamp so a thin footer never yields a negative height.
int top = footer.y + spec.verticalInset;
int height = footer.height - 2 * spec.verticalInset;
if (height <= 0) {
top = footer.y;
height = footer.height;
}
return ButtonRect{left, top, spec.buttonWidth, height};
}
bool hitTestPruneButton(int px, int py, const ButtonRect& button) {
if (button.empty()) return false; // suppressed button claims nothing
return px >= button.x && px < button.x + button.width &&
py >= button.y && py < button.y + button.height;
}
} // namespace reasampler::ui
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include "core/ui/rect.h"
// prune_button — the REAPER-free layout math behind the bank_panel's Prune button
// (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's
// tail-footer strip that fires the "Prune bank folder" command. The panel shell
// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the Main_OnCommand
// dispatch of the registered command id — all REAPER-bound, DAW-verified. What is
// NOT DAW-bound — WHERE the button sits in the footer and whether a click lands on
// it — lives here so it is unit-tested outside the DAW (CLAUDE.md §load-bearing
// split). Mirror of mode_switch / tab_strip.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER.
//
// -- Placement contract --------------------------------------------------------
//
// The footer hosts (L4) a LEFT group — the [Arrange|Design] mode toggle, a per-mode
// count, and the Tail button (bank_panel footer_bar) — and a RIGHT-aligned version
// readout (bank_panel drawFooter). The prune button is a fixed-width button anchored
// to the RIGHT of the footer, inset from the right edge, sitting just LEFT of the
// version readout's inset region and set APART from the benign left group. It never
// overlaps the left group (footer_bar reserves rightReserve px at the right to match).
// When the footer is too narrow to fit the button without colliding with the left
// inset, the button is suppressed (empty rect) rather than drawn on top — the action
// is always reachable via its bindable command, so a hidden button is a graceful
// degradation, not a lost affordance.
namespace reasampler::ui {
// The footer strip the button is drawn into, top-left origin (SWELL/LICE
// convention). (x, y) is the top-left corner; width/height are the strip extents.
// bank_panel derives this from panelFooter() and passes it here.
using FooterRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// A button's pixel rectangle within the footer, top-left origin. A zero-area rect
// (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place
// it, or the footer itself is degenerate; the caller must not draw or hit-test it.
using ButtonRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer
// metrics; the shell passes its own so draw and hit-test share one source of truth.
// * buttonWidth — the button's fixed width.
// * rightInset — gap from the footer's right edge to the button's right edge (the
// button sits left of this inset, clearing the right-aligned version
// readout). COUPLED TO drawFooter (bank_panel.cpp): the version readout
// uses an 8 px right margin. The button's right edge lands at
// footer.right - 84, i.e. 76 px left of the readout's right margin —
// enough clearance for the ~10-char label. ALSO COUPLED to
// FooterBarSpec::rightReserve (footer_bar.h): the L4 footer-left group
// (mode toggle + count + Tail) reserves that many px at the right so it
// never runs under this button; rightReserve must exceed rightInset +
// buttonWidth. If the version readout's inset changes in drawFooter,
// update this value to maintain clearance.
// * verticalInset — top/bottom gap inside the footer (the button is shorter than the
// strip so it reads as a raised control, not a full-height fill).
// * minLeftInset — the button's left edge must stay at least this far from the footer
// left edge (reserving room for the L4 footer-left group). If the button
// would encroach past this, computePruneButton yields an empty rect
// (button suppressed — see header placement contract).
struct PruneButtonSpec {
int buttonWidth = 72;
int rightInset = 84; // COUPLED: version readout in drawFooter uses an 8 px right margin
int verticalInset = 4;
int minLeftInset = 120;
};
// Computes the prune button's rect within `footer` per `spec`. Right-anchored: the
// button's right edge is footer.x + footer.width - rightInset, its width is buttonWidth,
// and it is vertically centred by verticalInset. Returns an EMPTY rect (button
// suppressed) when: the footer is degenerate (width/height <= 0), OR the resulting left
// edge would fall closer to the footer left than minLeftInset (too narrow to place
// without colliding with the tail label). The action stays reachable via its command in
// that case — a suppressed button is graceful, not a lost feature.
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec);
// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`.
// Half-open bounds [x, x+width) x [y, y+height) — matches computePruneButton so draw and
// hit-test agree on the same pixels. An empty button never claims a point (always false),
// so a suppressed button cannot be accidentally clicked.
bool hitTestPruneButton(int px, int py, const ButtonRect& button);
} // namespace reasampler::ui
+56
View File
@@ -0,0 +1,56 @@
#pragma once
// rect.h — the ONE concrete pixel rectangle (Q-W1, T2-05 ≡ T4-21).
//
// Before Q-W1 the codebase carried 12+ byte-identical {x, y, width, height} structs
// (ButtonRect / FooterRect / CellRect / KitBox / ...) plus a second LTRB grammar on
// the VST side (editor_geometry's left/top/right/bottom Rect). This is the single
// owner: one CONCRETE type (deliberately NOT a template — the role types differed in
// name only, so a template would model nothing), with per-role aliases at the old
// definition sites so call sites keep their semantic names
// (`using ButtonRect = ui::Rect;`).
//
// Grammar: XYWH storage (the majority grammar — every extension role struct), with
// right()/bottom() accessors and an ltrb() factory so the former LTRB call sites
// convert mechanically. Half-open on both axes: a rect covers
// [x, x+width) × [y, y+height) — the same convention LICE/SWELL RECTs use, and the
// one every hitTest* in the codebase already implements.
//
// PURE MODULE: standard library only. Header-only; behavior is covered by the role
// modules' own test executables (prune_button / footer_bar / bank_grid / ... and the
// instrument-ui suites), which exercise every alias against these semantics.
namespace reasampler::ui {
struct Rect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
// Exclusive edges (half-open convention).
int right() const { return x + width; }
int bottom() const { return y + height; }
// A zero-or-negative-area rect means "not placed / suppressed": the caller must
// not draw or hit-test it (the shared graceful-degradation contract).
bool empty() const { return width <= 0 || height <= 0; }
// The former LTRB grammar's constructor (editor_geometry and friends): edges in,
// extents stored. right/bottom exclusive, matching right()/bottom().
static Rect ltrb(int left, int top, int right, int bottom) {
return Rect{left, top, right - left, bottom - top};
}
bool operator==(const Rect& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
bool operator!=(const Rect& o) const { return !(*this == o); }
};
// True iff (px, py) falls inside r under the half-open convention. An empty rect
// contains nothing, so a suppressed affordance can never claim a click.
inline bool contains(const Rect& r, int px, int py) {
return px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height;
}
} // namespace reasampler::ui
+112
View File
@@ -0,0 +1,112 @@
// tab_strip — pure implementation. See tab_strip.h. NO REAPER / SWELL / vendor.
#include "core/ui/tab_strip.h"
#include <cstddef>
namespace reasampler::ui {
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset) {
(void)scrollOffset; // layout depends on geometry only, not the current offset
TabStripLayout out;
if (tabCount <= 0 || strip.width <= 0) {
out.trackX = strip.x;
out.trackWidth = strip.width > 0 ? strip.width : 0;
return out; // nothing to lay out: track == strip, no overflow, no chevrons
}
const int totalTabsWidth = tabCount * spec.tabWidth;
if (totalTabsWidth <= strip.width) {
// Everything fits: the whole strip is the track; no chevrons, no scroll.
out.overflow = false;
out.trackX = strip.x;
out.trackWidth = strip.width;
out.maxScroll = 0;
return out;
}
// Overflow: reserve a chevron band at each end; the tabs live between them.
out.overflow = true;
out.leftChevron = true;
out.rightChevron = true;
out.trackX = strip.x + spec.chevronWidth;
out.trackWidth = strip.width - 2 * spec.chevronWidth;
if (out.trackWidth < 0) out.trackWidth = 0;
// The tab run exceeds the track by this many pixels; the strip may scroll exactly
// that far so the last tab's right edge reaches the track's right edge, no more.
out.maxScroll = totalTabsWidth - out.trackWidth;
if (out.maxScroll < 0) out.maxScroll = 0;
return out;
}
int clampTabScroll(int desiredOffset, const TabStripLayout& layout) {
if (desiredOffset < 0) return 0;
if (desiredOffset > layout.maxScroll) return layout.maxScroll;
return desiredOffset;
}
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset) {
std::vector<TabRect> rects;
if (tabCount <= 0 || strip.width <= 0) return rects;
const TabStripLayout layout =
computeTabStripLayout(strip, tabCount, spec, scrollOffset);
const int offset = layout.overflow ? clampTabScroll(scrollOffset, layout) : 0;
const int trackLeft = layout.trackX;
const int trackRight = layout.trackX + layout.trackWidth;
rects.reserve(static_cast<std::size_t>(tabCount));
for (int i = 0; i < tabCount; ++i) {
const int rawLeft = trackLeft + i * spec.tabWidth - offset;
const int rawRight = rawLeft + spec.tabWidth;
// Clip to the track: a partially-scrolled tab must not draw under a chevron
// or spill past the track. A tab whose clipped extent is empty is omitted.
int left = rawLeft < trackLeft ? trackLeft : rawLeft;
int right = rawRight > trackRight ? trackRight : rawRight;
if (right <= left) continue; // fully scrolled out of view either side
TabRect r;
r.index = i;
r.x = left;
r.y = strip.y;
r.width = right - left;
r.height = strip.height;
rects.push_back(r);
}
return rects;
}
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset) {
TabHit miss; // {None, -1}
if (tabCount <= 0 || strip.width <= 0 || strip.height <= 0) return miss;
// Reject anything outside the strip band first (half-open bounds).
if (px < strip.x || px >= strip.x + strip.width ||
py < strip.y || py >= strip.y + strip.height)
return miss;
const TabStripLayout layout =
computeTabStripLayout(strip, tabCount, spec, scrollOffset);
// Chevrons take precedence at the strip ends: a click in a reserved chevron band
// is a scroll, never a tab (the tab track excludes those bands).
if (layout.overflow) {
if (px < strip.x + spec.chevronWidth)
return TabHit{TabHitKind::ScrollLeft, -1};
if (px >= strip.x + strip.width - spec.chevronWidth)
return TabHit{TabHitKind::ScrollRight, -1};
}
// Inside the track: find the visible tab whose clipped rect contains px. Reuse
// computeTabRects so the hit matches exactly what was drawn (clipping included).
const std::vector<TabRect> rects =
computeTabRects(strip, tabCount, spec, scrollOffset);
for (const TabRect& r : rects) {
if (px >= r.x && px < r.x + r.width) return TabHit{TabHitKind::Tab, r.index};
}
return miss; // track dead space (no tab under the point)
}
} // namespace reasampler::ui
+127
View File
@@ -0,0 +1,127 @@
#pragma once
#include "core/ui/rect.h"
// tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's
// named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the
// vertical-split bank window is a LICE-drawn tab strip (one tab per named bank,
// NOT a SWELL-native tab control), and — from the start — it must scroll when the
// tabs overflow the strip width (a naive fixed-width strip breaks down at ~812
// tabs). What is NOT DAW-bound — how N fixed-width tabs tile a strip of a given
// pixel width, where the overflow chevrons sit, which tab/chevron a click lands in,
// and how far the strip may scroll — lives here so it is unit-tested outside the
// DAW (CLAUDE.md §load-bearing split). The panel shell (bank_panel.cpp) owns the
// SWELL window, LICE drawing, and the live BankBook read; it calls into this seam
// for every rect and every hit. Mirror of mode_switch / bank_grid.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER.
#include <vector>
namespace reasampler::ui {
// The strip the tabs are drawn into, top-left origin (SWELL/LICE convention).
// (x, y) is the top-left corner; width/height are the strip extents. The panel
// reserves this as a fixed-height band at the top of the named-banks region.
using TabStripRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed
// so the strip reads as a uniform segmented control and overflow math stays simple —
// labels ellipsize within the tab, they do not resize it). chevronWidth is the width
// reserved at each end for the scroll affordance WHEN the tabs overflow; when they
// fit, no chevron is reserved and the tabs use the full strip width.
struct TabStripSpec {
int tabWidth = 96;
int chevronWidth = 20;
};
// One tab's pixel rectangle within the strip, top-left origin, ALREADY translated
// by the current scroll offset and clipped to the visible track. `index` is the
// tab's index in the caller's list (ordinal order) so the shell can label/light it
// without re-deriving. A tab scrolled fully out of view is omitted from the result
// (the shell only draws what computeTabRects returns), so every returned rect is at
// least partially visible.
struct TabRect {
int index = 0;
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool operator==(const TabRect& o) const {
return index == o.index && x == o.x && y == o.y &&
width == o.width && height == o.height;
}
};
// The scrollable track's geometry: where the tabs may be drawn (between the
// chevrons when overflowing, or the whole strip when they fit) and whether each
// chevron is present. Derived once and shared by layout + hit-testing so both agree.
struct TabStripLayout {
bool overflow = false; // true iff N tabs at tabWidth exceed the track width
int trackX = 0; // left edge of the tab track (past the left chevron)
int trackWidth = 0; // width available to tabs (strip minus both chevrons)
int maxScroll = 0; // largest valid scroll offset (0 when no overflow)
bool leftChevron = false; // a left-scroll affordance is reserved this frame
bool rightChevron = false;// a right-scroll affordance is reserved this frame
};
// Computes the strip layout for `tabCount` tabs of `spec.tabWidth` in `strip`,
// given the current `scrollOffset`. Pure geometry:
// * No overflow (all tabs fit the strip width): overflow=false, no chevrons, the
// track IS the strip, maxScroll=0.
// * Overflow: both chevrons are reserved (chevronWidth each), the track is the
// strip minus both chevrons, and maxScroll is the pixels by which the tab run
// exceeds the track (so the last tab's right edge can reach the track's right
// edge but not scroll past it). Chevrons are always both present under overflow
// (a fixed affordance is simpler and unambiguous than hiding one at an end;
// clicking a chevron at a scroll limit is a harmless no-op the shell clamps).
// tabCount <= 0 or a non-positive strip width returns a zeroed layout (no overflow,
// track == strip, maxScroll 0).
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset);
// Clamps a desired scroll offset into [0, maxScroll] for the given layout. The shell
// calls this after a chevron click / wheel so the strip never scrolls past either
// end. maxScroll is 0 when the tabs fit, so a fitting strip always clamps to 0.
int clampTabScroll(int desiredOffset, const TabStripLayout& layout);
// Tiles `tabCount` fixed-width tabs left-to-right into the layout's track, shifted
// left by `scrollOffset`, and returns the rects that are at least partially visible
// (in tab-index order). Each tab i sits at trackX + i*tabWidth - scrollOffset; a tab
// whose visible extent is empty (fully left of or right of the track) is omitted.
// Returned rects are CLIPPED to the track horizontally so a partially-scrolled tab
// does not draw under a chevron. The caller passes the SAME scrollOffset it passed
// to computeTabStripLayout (the shell clamps once, then uses the clamped value for
// both). tabCount <= 0 -> empty.
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset);
// What a point in the strip resolves to.
enum class TabHitKind {
None, // outside the strip, or in dead space between visible tabs
Tab, // a tab — `index` is the tab's index in the caller's list
ScrollLeft, // the left overflow chevron
ScrollRight, // the right overflow chevron
};
// The outcome of hit-testing a point against the strip. For Tab, `index` is the tab
// index; for the chevrons and None it is -1.
struct TabHit {
TabHitKind kind = TabHitKind::None;
int index = -1;
bool operator==(const TabHit& o) const {
return kind == o.kind && index == o.index;
}
};
// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out
// for `tabCount` tabs at `scrollOffset`. Chevrons take precedence over tabs at the
// strip ends (a click in the reserved chevron band is a scroll, never a tab), and a
// point outside the strip band, or in the track but not on any visible tab, is None.
// Half-open bounds match computeTabRects / the chevron bands so no pixel is claimed
// twice. The shell passes the SAME clamped scrollOffset it drew with.
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
const TabStripSpec& spec, int scrollOffset);
} // namespace reasampler::ui
+193
View File
@@ -0,0 +1,193 @@
// theme — pure implementation. See theme.h. NO REAPER / SWELL / LICE / vendor.
#include "core/ui/theme.h"
#include <algorithm>
#include <cmath>
namespace reasampler::ui {
namespace {
// ===========================================================================
// THE ONE DIRECTION CONSTANTS BLOCK (DS-2 revised: B "Neon Console" REAPER-grey
// neutrals + three-accent pastel system + C pastel spectral).
//
// This is the SINGLE POINT OF CHANGE. Every role color below is one of these
// constants; roleColor() is a pure switch over them. To re-pick the visual
// direction (§4: A Studio Rack / B Neon Console / C full spectral), edit THIS
// block — no shell, no other module, names a color. Values are locked against
// each WCAG floor (proven by test_theme.cpp): text/dim is lifted to the lightest
// grey that still clears AA 4.5:1 body on the greyest surface it draws on; each
// pastel accent is the softest tint that still clears the 3:1 indicator floor on
// bg/cell ("punch from the soft side" — DS-2 revised §2.1 grey re-read).
// ===========================================================================
// REAPER-theme mid-grey elevation stack (DS-2 revised — NOT near-black). Matches
// Daniel's REAPER theme so the dock reads as part of REAPER: base = window chrome
// grey, panel/cell one step lighter each. The elevation-ladder discipline is
// unchanged (base < panel < cell by a few %, micro-gradient + inner highlight/
// shadow carry elevation, not hard borders); only the VALUES moved up into grey.
constexpr KitColor kDirBgBase {43, 43, 43, 255}; // #2b2b2b — REAPER chrome grey
constexpr KitColor kDirBgPanel {51, 51, 51, 255}; // #333333 — one step lighter
constexpr KitColor kDirBgCell {58, 58, 58, 255}; // #3a3a3a — REAPER track bg
constexpr KitColor kDirHairline {74, 74, 74, 255}; // #4a4a4a — subtle step above cell
// Text: REAPER body light-grey primary (#dcdcdc, clears ~8:1 on bg/cell) + a dimmer
// grey secondary. The greyer surfaces shrank the dim cushion (mid-grey-on-mid-grey
// is the classic AA failure): the spec-start #a0a0a0 lands ~4.35:1 on bg/cell, UNDER
// the AA 4.5 body floor — lifted to #a8a8a8 (~4.78:1 on bg/cell), the lightest grey
// that still reads dim while clearing AA 4.5 body on the greyest surface it draws
// body text on. Locked by test_theme.cpp.
constexpr KitColor kDirTextPrimary{220, 220, 220, 255}; // #dcdcdc
constexpr KitColor kDirTextDim {168, 168, 168, 255}; // #a8a8a8 (lifted from #a0a0a0)
// The three-accent pastel system (DS-2 revised — replaces the single electric cyan).
// primary = pastel lime (the live/active/selected signal, the eye-magnet); secondary
// = pastel teal, tertiary = pastel purple (CATEGORICAL distinctions — a KIND, never
// intensity). accent/hot is a brighter tint OF the primary for hover/live/drag. On the
// greyer bg/cell the pastels clear the 3:1 indicator floor comfortably (primary ~7.6,
// secondary ~6.8, tertiary ~5.5) at the spec-start values, so no per-hue nudge was
// needed — the hues stay pastel lime/teal/purple. warn is a reserved red/amber for
// byte-deleting states only.
constexpr KitColor kDirAccentPrimary {176, 224, 152, 255}; // #B0E098 — pastel lime
constexpr KitColor kDirAccentSecondary{132, 214, 208, 255}; // #84D6D0 — pastel teal
constexpr KitColor kDirAccentTertiary {194, 170, 232, 255}; // #C2AAE8 — pastel purple
constexpr KitColor kDirAccentHot {200, 236, 178, 255}; // #C8ECB2 — lighter pastel lime
constexpr KitColor kDirWarn {235, 120, 90, 255}; // #eb785a — destructive only
// Direction C pastel spectral ramp (DS-2 revised): a three-stop sweep through the
// accents — pastel lime (low) -> pastel teal (mid) -> pastel purple (high) — so the
// signature keyboard strip reads as an extension of the accent system, not a neon
// flourish. Endpoints/midpoint ARE the three accent constants (single source).
constexpr KitColor kDirSpectralLo = kDirAccentPrimary; // low notes: pastel lime
constexpr KitColor kDirSpectralMid = kDirAccentSecondary; // mid notes: pastel teal
constexpr KitColor kDirSpectralHi = kDirAccentTertiary; // high notes: pastel purple
// --- state transform helpers -------------------------------------------------
std::uint8_t clamp8(int v) {
return static_cast<std::uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v));
}
// Linear blend from a toward b by t in [0, 1] (alpha carried from a — a state
// tint changes hue/brightness, not opacity; disabled handles alpha separately).
KitColor mix(const KitColor& a, const KitColor& b, double t) {
return KitColor{
clamp8(static_cast<int>(std::lround(a.r + (b.r - a.r) * t))),
clamp8(static_cast<int>(std::lround(a.g + (b.g - a.g) * t))),
clamp8(static_cast<int>(std::lround(a.b + (b.b - a.b) * t))),
a.a,
};
}
// Scale RGB by factor (brightness up/down), alpha untouched.
KitColor scale(const KitColor& c, double factor) {
return KitColor{
clamp8(static_cast<int>(std::lround(c.r * factor))),
clamp8(static_cast<int>(std::lround(c.g * factor))),
clamp8(static_cast<int>(std::lround(c.b * factor))),
c.a,
};
}
// Desaturate toward the color's own luminance-gray by amount in [0, 1].
KitColor desaturate(const KitColor& c, double amount) {
// 8-bit gray from the perceptual weights (same weighting family as luminance).
const int gray = clamp8(static_cast<int>(
std::lround(0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b)));
const KitColor g{static_cast<std::uint8_t>(gray),
static_cast<std::uint8_t>(gray),
static_cast<std::uint8_t>(gray), c.a};
return mix(c, g, amount);
}
double linearizeChannel(std::uint8_t v) {
const double s = v / 255.0;
return s <= 0.03928 ? s / 12.92 : std::pow((s + 0.055) / 1.055, 2.4);
}
} // namespace
KitColor roleColor(Role role) {
switch (role) {
case Role::BgBase: return kDirBgBase;
case Role::BgPanel: return kDirBgPanel;
case Role::BgCell: return kDirBgCell;
case Role::LineHairline: return kDirHairline;
case Role::TextPrimary: return kDirTextPrimary;
case Role::TextDim: return kDirTextDim;
case Role::AccentPrimary: return kDirAccentPrimary;
case Role::AccentSecondary: return kDirAccentSecondary;
case Role::AccentTertiary: return kDirAccentTertiary;
case Role::AccentHot: return kDirAccentHot;
case Role::Warn: return kDirWarn;
}
return kDirBgBase; // unreachable; keeps non-void control flow total
}
KitColor roleColorState(Role role, InteractionState state) {
const KitColor base = roleColor(role);
switch (state) {
case InteractionState::Rest:
return base;
case InteractionState::Hover:
// Lighten the surface toward the hot accent (~10%) — the "alive" cue.
return mix(base, roleColor(Role::AccentHot), 0.10);
case InteractionState::Active:
// The selected/active layer carries the PRIMARY accent — "this is live"
// is always the primary hue (DS-2 revised: primary leads state; secondary/
// tertiary are categorical, never intensity).
return roleColor(Role::AccentPrimary);
case InteractionState::Pressed:
// The surface "pushes in": darken.
return scale(base, 0.82);
case InteractionState::Dragging:
// A live-drag element reads as active-but-lighter (primary -> hot).
return mix(roleColor(Role::AccentPrimary), roleColor(Role::AccentHot), 0.30);
case InteractionState::Focus:
// Focus keeps the surface but is drawn with a text/primary ring by the
// shell; the fill nudges toward the primary accent so focus reads pre-ring.
return mix(base, roleColor(Role::AccentPrimary), 0.08);
case InteractionState::Disabled: {
// Desaturate and drop alpha to 40% (§3.3).
KitColor d = desaturate(base, 0.6);
d.a = static_cast<std::uint8_t>(std::lround(base.a * 0.4));
return d;
}
}
return base;
}
KitColor spectralColor(double t) {
if (t < 0.0) t = 0.0;
if (t > 1.0) t = 1.0;
// Three-stop pastel sweep anchored on the accent trio (DS-2 revised Direction C):
// lime (low) -> teal (mid, t=0.5) -> purple (high). A single Lo->Hi lerp would skip
// the teal midpoint and drift the ramp off the accent family; interpolate each half
// so the midpoint IS the secondary accent and every stop stays in the pastel band.
if (t <= 0.5) {
return mix(kDirSpectralLo, kDirSpectralMid, t / 0.5);
}
return mix(kDirSpectralMid, kDirSpectralHi, (t - 0.5) / 0.5);
}
double relativeLuminance(const KitColor& c) {
return 0.2126 * linearizeChannel(c.r) +
0.7152 * linearizeChannel(c.g) +
0.0722 * linearizeChannel(c.b);
}
double contrastRatio(const KitColor& a, const KitColor& b) {
const double la = relativeLuminance(a);
const double lb = relativeLuminance(b);
const double lighter = std::max(la, lb);
const double darker = std::min(la, lb);
return (lighter + 0.05) / (darker + 0.05);
}
double textFloor(TextClass cls) {
return cls == TextClass::Body ? 4.5 : 3.0;
}
} // namespace reasampler::ui
+118
View File
@@ -0,0 +1,118 @@
#pragma once
// theme — the REAPER-free, LICE-free palette + type-scale core of the shared drawing
// kit (Phase L, L1). This is the "one source of drawing" made testable at its root: a
// ROLE-based color model (bg/base, bg/panel, bg/cell, line/hairline, text/primary,
// text/dim, accent/primary, accent/secondary, accent/tertiary, accent/hot, warn), an
// INTERACTION-STATE model (rest/hover/active/pressed/dragging/focus/disabled), and the
// WCAG contrast math that lets a unit test prove every text-on-surface pair clears its
// floor ("punch to the floor, not past it").
//
// THE SINGLE POINT OF CHANGE (DS-2 revised): every role color is produced by roleColor()
// from ONE direction constants block (kDirection*, below) carrying the settled B (Neon
// Console) neutrals — now REAPER-theme mid-grey, not near-black — plus the three-accent
// pastel system (primary lime / secondary teal / tertiary purple) and the C pastel
// spectral ramp. Switching the visual direction is editing that block and nothing else —
// no shell hardcodes a color; the shell asks the theme by role. The spectral (Direction C)
// hue ramp lives here too (spectralColor) so the signature keyboard strip's L3 consumer
// derives its per-note hue from the same source (a pastel sweep anchored on the three
// accents: primary lime -> secondary teal -> tertiary purple).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER. Mirror of mode_switch / bank_grid — the
// shell (draw_kit) turns a KitColor into a LICE_pixel at the boundary; the theme never
// names a LICE type.
#include <cstdint>
namespace reasampler::ui {
// A straight 8-bit-per-channel RGBA color, LICE-free. The draw shell converts this to a
// LICE_pixel via LICE_RGBA at the boundary (draw_kit); nothing here depends on LICE's
// packing. Deliberately NOT named "Color"/"RGBA" (both are common collision surfaces);
// "KitColor" scopes it to the kit.
struct KitColor {
std::uint8_t r = 0;
std::uint8_t g = 0;
std::uint8_t b = 0;
std::uint8_t a = 255;
bool operator==(const KitColor& o) const {
return r == o.r && g == o.g && b == o.b && a == o.a;
}
};
// The structural palette roles (direction-independent — §2.1 of the design doc). The
// direction (B/C) sets the concrete hue behind each; the shell always asks by role.
enum class Role {
BgBase, // window canvas
BgPanel, // a raised region (list, waveform pane)
BgCell, // a control / row surface
LineHairline, // separators (used sparingly — elevation carries most separation)
TextPrimary, // labels, values
TextDim, // secondary / units
AccentPrimary, // the live / active / selected signal — where the punch lives (pastel lime)
AccentSecondary,// categorical role A (pastel teal) — a distinct KIND, never intensity
AccentTertiary,// categorical role B (pastel purple) — a distinct KIND, never intensity
AccentHot, // hover / live / drag feedback (a brighter tint OF the primary accent)
Warn, // clip / destructive (prune, delete) — reserved for byte-deleting states
};
// The interaction-state model every kit component honors (§3.3). A component draws its
// role surface transformed by its current state; stateShift() below is that transform.
enum class InteractionState {
Rest,
Hover,
Active, // selected / active
Pressed,
Dragging,
Focus,
Disabled,
};
// Text size classes for the WCAG floor. "Large" text (>= ~18.66px, or >= ~14px bold) and
// UI-state indicators clear at 3:1; body text clears at 4.5:1 (WCAG 2.1 AA). The kit's
// four cached fonts map onto these: title -> Large, label/value -> Body, micro -> Body.
enum class TextClass {
Body, // AA 4.5:1
Large, // AA-large 3:1 (also the floor for state indicators)
};
// The concrete color for a role, produced from the ONE direction constants block. This is
// the single choke point the "single point of change" guarantee rests on: the shell has
// no other way to obtain a palette color, so re-picking the direction is editing the
// kDirection* block this reads and nothing else.
KitColor roleColor(Role role);
// The color for a role under an interaction state — roleColor(role) transformed by the
// state (hover lightens toward accent/hot, pressed darkens, disabled desaturates + drops
// alpha, etc.). Surfaces use this so every component gets the whole state model for free.
// Rest returns roleColor(role) unchanged.
KitColor roleColorState(Role role, InteractionState state);
// Direction C's spectral hue ramp (DS-2 revised — a PASTEL sweep anchored on the three
// accents, not the old neon cool-blue -> hot-magenta): maps a normalized position t in
// [0, 1] (low note -> high note across the keyboard strip) to a color that runs
// accent/primary (pastel lime, low) -> accent/secondary (pastel teal, mid) ->
// accent/tertiary (pastel purple, high). The same three hues that mean "live / category A
// / category B" elsewhere are the endpoints and midpoint here, so the strip reads as an
// extension of the accent system, not a separate flourish. The signature keyboard-strip
// surface (an L3 consumer) derives each note/zone's hue from this ONE function so the
// spectrum is defined in the same place as the rest of the palette. t is clamped to [0, 1].
KitColor spectralColor(double t);
// --- WCAG contrast (the "punch" rule, made testable) --------------------------
//
// The relative luminance of a color per WCAG 2.1 (sRGB linearization + the 0.2126/
// 0.7152/0.0722 weighting). Alpha is ignored — contrast is a question about the opaque
// hues; a translucent overlay's effective color is the caller's to compose first.
double relativeLuminance(const KitColor& c);
// The WCAG contrast ratio between two colors, in [1, 21]. Symmetric; order-independent.
double contrastRatio(const KitColor& a, const KitColor& b);
// The contrast floor a text class must clear: 4.5 for Body, 3.0 for Large. The test that
// proves the palette asserts contrastRatio(text, surface) >= textFloor(class) for every
// pair the kit actually draws.
double textFloor(TextClass cls);
} // namespace reasampler::ui
+53
View File
@@ -0,0 +1,53 @@
// tooltip — pure implementation. See tooltip.h. NO REAPER / SWELL / LICE / vendor.
#include "core/ui/tooltip.h"
namespace reasampler::ui {
std::string stripActionPrefix(const std::string& fullName, const std::string& prefix) {
if (prefix.empty()) return fullName;
if (fullName.size() >= prefix.size() &&
fullName.compare(0, prefix.size(), prefix) == 0)
return fullName.substr(prefix.size());
return fullName;
}
TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH,
int textW, int textH, int clientW, int clientH,
const TooltipSpec& spec) {
TooltipBox box;
if (textW <= 0 || textH <= 0 || clientW <= 0 || clientH <= 0) return box;
// Clamp boxW so it never exceeds the available client span; then clamp x so the (possibly
// reduced) box always sits within [margin, clientW - margin].
const int maxBoxW = clientW - 2 * spec.margin;
const int boxW = (textW + 2 * spec.padX < maxBoxW) ? textW + 2 * spec.padX : maxBoxW;
const int boxH = textH + 2 * spec.padY;
// Horizontal: centre on the anchor, then clamp within [margin, clientW - margin - boxW].
int x = anchorX + (anchorW - boxW) / 2;
const int maxX = clientW - spec.margin - boxW;
if (x > maxX) x = maxX;
if (x < spec.margin) x = spec.margin;
// Vertical: prefer BELOW the anchor; flip ABOVE if it would clip the bottom edge.
int y = anchorY + anchorH + spec.gap;
if (y + boxH > clientH - spec.margin) {
const int above = anchorY - spec.gap - boxH;
if (above >= spec.margin) {
y = above; // fits above — flip
} else {
// Fits neither cleanly (tall tooltip / short client): clamp to the bottom margin.
const int maxY = clientH - spec.margin - boxH;
y = maxY < spec.margin ? spec.margin : maxY;
}
}
box.x = x;
box.y = y;
box.width = boxW;
box.height = boxH;
return box;
}
} // namespace reasampler::ui
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// tooltip — the REAPER-free layout math + text helper behind the bank_panel's custom hover-delay
// tooltip (Phase L, L5, refinement 2). Button FACES stay short (the terse shortLabel); hovering a
// button for a short delay pops a small tooltip carrying the FULL action name with the
// "ReaSampler:" display prefix stripped. The tooltip is a custom LICE-kit draw (NOT the native
// Win32 / SWELL tooltip control) — chosen so it is uniform across platforms and consistent with
// the L1 kit (brief §tooltip mechanism). The DAW-bound parts (the hover timer, the LICE overlay
// draw, the kbd/action-name query) live in the shell; what is NOT DAW-bound — WHERE the tooltip
// box sits relative to its anchor button within the panel client, and stripping the display
// prefix — lives here, unit-tested outside the DAW. Mirror of prune_button / component_geometry.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard library only.
#include <string>
namespace reasampler::ui {
// The tooltip's box (top-left origin, SWELL/LICE convention). A zero-area rect means "do not
// draw" (degenerate inputs); the caller checks empty() before drawing.
struct TooltipBox {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool empty() const { return width <= 0 || height <= 0; }
bool operator==(const TooltipBox& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
};
// Placement inputs, in pixels.
// * gap — vertical gap between the anchor button and the tooltip box.
// * padX/padY — horizontal / vertical text padding inside the box.
// * margin — minimum clearance kept from the client edges when clamping.
struct TooltipSpec {
int gap = 4;
int padX = 6;
int padY = 3;
int margin = 2;
};
// Strips the action DISPLAY PREFIX from a full action name for the tooltip face. The registered
// gaccel name is composed as `prefix + phrase` (prefix from actionDisplayPrefix(), e.g.
// "ReaSampler: "); the tooltip shows only the phrase. If `fullName` does not start with
// `prefix`, it is returned unchanged (defensive — a name from an unexpected source still shows).
// An empty prefix returns fullName unchanged.
std::string stripActionPrefix(const std::string& fullName, const std::string& prefix);
// Places a tooltip of pixel size (textW + 2*padX) x (textH + 2*padY) for the button rect
// (anchorX, anchorY, anchorW, anchorH), clamped inside the client rect (0,0,clientW,clientH).
// Preference: BELOW the anchor, horizontally centred on it. If it would clip the bottom edge,
// it flips ABOVE the anchor. It is then clamped horizontally (and vertically as a last resort)
// to stay within `margin` of the client edges. Returns an empty box when the text extent or the
// client is degenerate. `textW`/`textH` are the measured text extents (the shell measures with
// the kit font before calling).
TooltipBox computeTooltip(int anchorX, int anchorY, int anchorW, int anchorH,
int textW, int textH, int clientW, int clientH,
const TooltipSpec& spec);
} // namespace reasampler::ui
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// clamp01 — the ONE unit-interval clamp (Q-W1, T4-24). Replaces the per-module
// static copies (master_gain / param_slider / envelope_overlay / reasampler_editor).
// Deliberately the ternary form: comparisons with NaN are false, so a NaN input
// passes through unchanged rather than silently collapsing to a bound — the
// behavior of the majority of the retired copies.
//
// PURE: standard library only (not even that).
namespace reasampler::util {
constexpr double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace reasampler::util
+2 -2
View File
@@ -4,7 +4,7 @@
#include <fstream>
namespace reasampler {
namespace reasampler::util {
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
@@ -18,4 +18,4 @@ std::vector<std::uint8_t> readFileBytes(const std::string& path) {
return bytes;
}
} // namespace reasampler
} // namespace reasampler::util
+2 -2
View File
@@ -9,11 +9,11 @@
#include <string>
#include <vector>
namespace reasampler {
namespace reasampler::util {
// Reads the whole file at `path` into a byte buffer. Empty on ANY failure —
// unopenable, empty file, or short read — so the caller has exactly one
// "nothing to work with" branch.
std::vector<std::uint8_t> readFileBytes(const std::string& path);
} // namespace reasampler
} // namespace reasampler::util

Some files were not shown because too many files have changed in this diff Show More