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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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ₙ*(1−w) + ref*w = out*(1−1) + 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
|
||||
@@ -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ₙ*(1−w) + 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 {
|
||||
|
||||
// Fritsch–Carlson 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 (Fritsch–Carlson 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 (Fritsch–Carlson) 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
|
||||
@@ -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 (Fritsch–Carlson 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
|
||||
// Fritsch–Carlson 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 (Fritsch–Carlson 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 v1–v5 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 v1–v6 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ~8–12
|
||||
// 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user