loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone

This commit is contained in:
2026-07-31 17:36:36 -04:00
parent a90ccd9a00
commit 0fe4166d7d
25 changed files with 991 additions and 64 deletions
+5 -3
View File
@@ -115,7 +115,8 @@ start point, Gate has modifiable loop points too. In addition to amp env, there
pitch envelope/curve (AD?) which is off by default."*
- **Gate — classic held note.** Note-on enters the amp envelope; note-off enters
release; a sustain loop applies for held notes. Envelope is **AHDSR**: `0→1` over
release; a sustain loop applies for held notes, cycling indefinitely until note-off, with a
user-parameterized pre-seam crossfade at the reset (`engine/loop/`). Envelope is **AHDSR**: `0→1` over
attack, hold at 1 over `holdFrames`, `1→sustain` over decay, hold sustain until
note-off, `level→0` over release. `holdFrames == 0` is exactly the pre-Gate ADSR — a
back-compat degenerate.
@@ -257,6 +258,7 @@ anything for a trigger shape.
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
@@ -264,7 +266,7 @@ anything for a trigger shape.
### `map/`
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v10), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v11), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
@@ -276,7 +278,7 @@ anything for a trigger shape.
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath.
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
+5 -1
View File
@@ -16,6 +16,10 @@ reasampler_test(master_gain LINK master_gain)
# Declared before sampler_core because the voice now runs one per sounding note.
add_subdirectory(filter)
# The sustain loop's validity + crossfade geometry, shared by the voice and the editor's
# marker layer. After filter: it links play_params' dependency set, which includes it.
add_subdirectory(loop)
# The live-parameter block: the value layer plus its publication, deliberately linking no
# engine — the block is a plain value the voice observes, not a thing the engine owns.
reasampler_pure_library(live_params
@@ -28,7 +32,7 @@ reasampler_test(live_params LINK live_params)
# boundary costs the hot path nothing.
reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law)
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span)
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
# or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core)
+60
View File
@@ -0,0 +1,60 @@
# src/core/instrument/engine/loop — the sustain loop's span rule
## Scope
One pure module, `loop_span`: the ONE fold that turns a stored `SampleLoop` + crossfade
length into the `ResolvedLoop` the voice's read path wraps on, plus the editor's default
handle placement for a capture with no loop. No REAPER, no VST3, no allocation, no I/O.
Everything lives in `reasampler::instrument::engine::loop`.
`resolveLoop` is cold — called once per note-on and by the editor. `crossfadeWeight` is
header-inline because it is evaluated per voice per sample.
## Invariants
### The crossfade is PRE-SEAM, and that is what makes it one tap
The fade runs over the last `crossfade` frames before `end`, blending the material running
into `end` toward the material running into `start`. The incoming material is the same read
head one loop length earlier, so the second tap is `pos - length` — no second position to
advance, no second wrap rule, no state. At `end` the incoming tap has arrived at `start`,
which is exactly where the wrap puts the head, so the seam is continuous by construction
rather than by a fade that merely hides it.
The consequence is a hard clamp: **`crossfade <= start`**. A loop starting at frame 0 has no
material ahead of it and therefore gets no crossfade, whatever the user dialled — honest
rather than silently reading before the buffer.
### The seam does not close to zero, it closes as 1/crossfade
The weight reaches 1 only AT `end` — a position no rendered frame lands on — so the last
frame before the wrap carries `(xf-1)/xf` and a residual step of `(seam step)/xf` survives.
Measured on a source-frame ramp with a 19-frame seam: 4.0 at `xf = 4`, 1.5 at 8, 0.25 at 16
(`sampler_core_tests`). It is proportional and therefore inaudible at any musically useful
setting; a claim that the crossfade makes the seam *exactly* continuous is wrong in the
discrete domain and a test asserting it will fail.
### Linear, not equal-power
The two taps are one loop length apart in the same material and are usually well correlated,
where an equal-power pair bulges. Linear also costs a subtract and a multiply on a path that
forbids a transcendental. The filter's morph crossfade is equal-power for a reason specific
to quadrature taps (`engine/filter/CLAUDE.md`) — that reasoning does not transfer here.
### An invalid span is refused, never repaired
An inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield
`active == false`, so the note plays straight through. Repairing a corrupt span into a
plausible one would make a wrong loop audible and a bug invisible; the crossfade length is
the one field that IS clamped rather than refused, because its bound is a property of the
loop it sits in rather than of the user's intent.
## Gotchas
- **`crossfadeWeight` assumes its argument is already wrapped** into `[start, end)`. The
ceiling at 1.0 is a belt against an unwrapped caller, not permission to skip the wrap —
an unwrapped position would otherwise extrapolate past the incoming tap.
- **`defaultLoopBounds` is a UI default living in an engine module** on purpose: the span the
user is offered and the span `resolveLoop` will accept have to be one definition, and the
previous frame-0 default put the loop-start handle underneath the start marker where no
grab could reach it.
@@ -0,0 +1,7 @@
# The loop's validity rule and crossfade geometry. Links peaks only (via play_params' own
# SampleLoop) — deliberately not the voice engine: the resolve is a fold over plain values,
# which is what lets the editor share it without pulling the engine in.
reasampler_pure_library(loop_span
SOURCES loop_span.cpp
LINK PUBLIC peaks filter velocity_curve curve_law)
reasampler_test(loop_span LINK loop_span)
@@ -0,0 +1,37 @@
// loop_span.cpp — see loop_span.h. Pure math; no host types.
#include "core/instrument/engine/loop/loop_span.h"
namespace reasampler::instrument::engine::loop {
ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
std::int64_t frameCount, bool gateMode) {
ResolvedLoop out;
// Trigger is a one-shot by definition, so the loop is not merely unused there — it is
// absent, and the read path branches on this one flag.
if (!gateMode || !loop.hasLoop) return out;
if (loop.start < 0 || loop.end <= loop.start || loop.end > frameCount) return out;
out.active = true;
out.start = loop.start;
out.end = loop.end;
out.length = loop.end - loop.start;
// The incoming tap reads at `pos - length`, i.e. over [start - crossfade, start) — so the
// fade cannot outrun the material ahead of the loop, nor the loop itself.
std::int64_t xf = crossfadeFrames;
if (xf < 0) xf = 0;
if (xf > out.start) xf = out.start;
if (xf > out.length) xf = out.length;
out.crossfade = xf;
out.fadeBegin = static_cast<double>(out.end - xf);
out.fadeInv = xf > 0 ? 1.0 / static_cast<double>(xf) : 0.0;
return out;
}
LoopBounds defaultLoopBounds(std::int64_t frameCount) {
if (frameCount <= 0) return LoopBounds{};
return LoopBounds{frameCount - frameCount / 4, frameCount};
}
} // namespace reasampler::instrument::engine::loop
@@ -0,0 +1,54 @@
#pragma once
// loop_span.h — the sustain loop's ONE validity/clamp rule plus its pre-seam crossfade
// geometry. The resolve is cold (note-on, editor); crossfadeWeight is header-inline because
// it sits on the per-voice-per-sample read.
#include <cstdint>
#include "core/instrument/engine/play_params.h" // SampleLoop
namespace reasampler::instrument::engine::loop {
// A sustain loop folded against one capture: validity, geometry, and the clamped crossfade.
// `active == false` leaves every other field zero, so a caller can wrap on the flag alone.
//
// The fade is PRE-SEAM and its incoming tap is the same read head one loop length earlier —
// `pos - length`, no second position to advance. See CLAUDE.md for why that shape, and for
// the `crossfade <= start` bound it forces.
struct ResolvedLoop {
bool active = false;
std::int64_t start = 0;
std::int64_t end = 0; // half-open
std::int64_t length = 0; // end - start
std::int64_t crossfade = 0; // source frames; 0 = hard seam
double fadeBegin = 0.0; // end - crossfade
double fadeInv = 0.0; // 1 / crossfade; 0 when crossfade == 0
};
// Folds a stored loop + crossfade against the decoded sample. Refuses anything the read path
// could not honour — a non-Gate mode, an unset loop, an inverted or empty span, a span
// reaching outside the PCM — by returning an inactive result rather than a repaired one, so a
// corrupt span silently plays through instead of reading out of bounds.
ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
std::int64_t frameCount, bool gateMode);
// Weight of the INCOMING (pre-loop-start) tap at source position `pos`: 0 before the fade
// region, rising linearly to 1 at `end`. `pos` must already be wrapped into [start, end) —
// the ceiling is a belt for a caller that has not wrapped yet, not a licence to skip it.
inline double crossfadeWeight(const ResolvedLoop& lp, double pos) {
if (lp.crossfade <= 0) return 0.0;
const double d = pos - lp.fadeBegin;
if (d <= 0.0) return 0.0;
return d < static_cast<double>(lp.crossfade) ? d * lp.fadeInv : 1.0;
}
// Where the editor parks the loop handles for a capture that has none — the last quarter,
// where a sustain loop actually goes. Lives here, next to the validity rule, so the span a
// user is offered and the span the engine will accept are one definition.
struct LoopBounds {
std::int64_t start = 0;
std::int64_t end = 0;
};
LoopBounds defaultLoopBounds(std::int64_t frameCount);
} // namespace reasampler::instrument::engine::loop
+6
View File
@@ -170,6 +170,12 @@ struct SampleData {
int rootNote = 60;
SampleLoop loop;
// Pre-seam crossfade at the loop reset, in SOURCE frames — a source-timeline quantity
// like the loop points it belongs to, so no rate resolves it. 0 (the default) is the
// hard seam every instance predating the field plays. engine/loop/loop_span.h owns what
// the fade actually does and how it clamps.
std::int64_t loopCrossfadeFrames = 0;
// Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
std::int64_t startFrame = 0;
+14 -6
View File
@@ -66,6 +66,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
readPos_ = static_cast<double>(start);
startFrame_ = start; // the span-offset origin: readPos - startFrame
// The one fold of the stored span + crossfade into what the read path wraps on.
loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
frameCount, playMode_ == PlayMode::Gate);
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
@@ -162,9 +166,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// 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 loopWrap = loop_.active;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
// 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
@@ -189,12 +191,18 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
std::int64_t q = start;
for (std::int64_t i = 0; i < primeCount; ++i) {
if (loopWrap) {
while (q >= loop.end) q -= loopLen;
while (q >= loop_.end) q -= loop_.length;
}
// q < frameCount holds by construction on the non-loop path (primeCount is
// bounded); the guard stays as a belt for the loop-wrap walk.
// bounded); the guard stays as a belt for the loop-wrap walk. The prime runs
// the SAME crossfade the per-frame feed does — a ring primed with an un-faded
// seam would put the click back one window into the note.
primeBuf_[static_cast<std::size_t>(i)] =
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
(q < frameCount)
? crossfadedSource(pcmCh, q,
instrument::engine::loop::crossfadeWeight(
loop_, static_cast<double>(q)))
: 0.0f;
++q;
}
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
+58 -22
View File
@@ -16,6 +16,7 @@
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/velocity_curve.h"
@@ -26,6 +27,8 @@ using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
using instrument::engine::loop::ResolvedLoop;
using instrument::engine::loop::crossfadeWeight;
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
// reference-frequency needed.
@@ -151,14 +154,30 @@ public:
}
private:
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
// by the output anchor, the Preserve feed, and the start()-time ring prime.
bool 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());
// Linear-interpolated read at a plain (non-wrapping) fractional source position. The
// crossfade tap sits one loop length behind the head, i.e. BEFORE the loop start, so it
// never needs the wrap partner the main read uses.
static double lerpSource(const std::vector<AudioSample>& pcm, std::int64_t frameCount,
double pos) {
const std::int64_t i0 = static_cast<std::int64_t>(pos);
const std::int64_t i1 = i0 + 1;
const double frac = pos - static_cast<double>(i0);
const double a = (i0 >= 0 && i0 < frameCount) ? static_cast<double>(pcm[i0]) : 0.0;
const double b = (i1 >= 0 && i1 < frameCount) ? static_cast<double>(pcm[i1]) : 0.0;
return a + (b - a) * frac;
}
// One integer source frame with the loop crossfade already blended in — the Preserve
// path's read, and the start()-time ring prime's. `pos` must be a valid index; `xw` is
// crossfadeWeight at that position (0 blends nothing).
AudioSample crossfadedSource(const std::vector<AudioSample>& pcm, std::int64_t pos,
double xw) const {
const double v = static_cast<double>(pcm[static_cast<std::size_t>(pos)]);
if (xw <= 0.0) return static_cast<AudioSample>(v);
const std::int64_t tap = pos - loop_.length;
if (tap < 0) return static_cast<AudioSample>(v);
const double in = static_cast<double>(pcm[static_cast<std::size_t>(tap)]);
return static_cast<AudioSample>(v + xw * (in - v));
}
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
@@ -325,14 +344,13 @@ private:
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). A
// valid, non-zero-length loop wraps the read head back into [start, end); a
// zero-length loop is "no loop". Under Preserve the loop is over the source read
// (loop the source, shift the output).
const SampleLoop& loop = sample_->loop;
const bool loopUsable = sustainLoopUsable();
if (loopUsable) {
const double loopLen = static_cast<double>(loop.end - loop.start);
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). The
// span was folded once at note-on (loop_span.h); an invalid or absent loop leaves
// loop_.active false and this whole path off. Under Preserve the loop is over the
// source read (loop the source, shift the output).
const ResolvedLoop& loop = loop_;
if (loop.active) {
const double loopLen = static_cast<double>(loop.length);
while (readPos_ >= static_cast<double>(loop.end)) {
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
}
@@ -410,9 +428,8 @@ private:
// rings were primed with that window at start()), under the same sustain-loop wrap
// rule, reading integer source frames (nothing to interpolate). Past the last real
// frame the shifter's writer is frozen — 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;
if (loop.active) {
while (feedPos_ >= loop.end) feedPos_ -= loop.length;
}
// feedPos_ runs one window ahead of readPos_; the last real source frame is
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
@@ -428,7 +445,11 @@ private:
const bool exhausted = feedPos_ >= feedBound;
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
const AudioSample feedL =
feedOk ? crossfadedSource(pcm, feedPos_, feedXw) : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
@@ -447,7 +468,7 @@ private:
// not leak a previous note.
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR =
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
feedOk ? crossfadedSource(pcmR, feedPos_, feedXw) : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
@@ -471,7 +492,7 @@ private:
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) {
if (loop.active && i1 >= loop.end) {
i1 = loop.start; // seamless wrap for the interpolation partner.
}
const bool i0ok = (i0 >= 0 && i0 < frameCount);
@@ -486,6 +507,16 @@ private:
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
outRlocal = srcR;
}
// Loop crossfade: blend toward the same read head one loop length earlier, which
// is the material the wrap is about to hand over to. Zero outside the fade region
// (and always, with no fade dialled), so the un-crossfaded read stays exactly the
// shape it was.
const double xw = crossfadeWeight(loop, readPos_);
if (xw > 0.0) {
const double tap = readPos_ - static_cast<double>(loop.length);
outL += xw * (lerpSource(pcm, frameCount, tap) - outL);
if (stereo) outRlocal += xw * (lerpSource(pcmR, frameCount, tap) - outRlocal);
}
ratio_ = baseRatio_ * envFactor;
}
@@ -569,6 +600,11 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span
// are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing.
// Shared by the output anchor, the Preserve feed, and the start()-time ring prime.
ResolvedLoop loop_;
// The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
// different envelope phases are filtered independently. filterCutoffNorm_ keeps the
// unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants
@@ -1,5 +1,5 @@
// component_state_io — the ComponentState ENVELOPE codec. See component_state_io.h for both
// format ladders (envelope v1..v11, params payload v1..v10); the payload half lives in
// format ladders (envelope v1..v11, params payload v1..v11); the payload half lives in
// params_payload, which grows on its own version axis. Every wire format is FROZEN —
// byte-identical across revisions.
+12 -5
View File
@@ -8,7 +8,7 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v10) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v11) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint>
@@ -78,17 +78,21 @@ namespace reasampler::instrument::map {
// the filter's OWN velocity curve (count + points, same shape as v7's). A v8 blob is a strict
// prefix, so it lifts to the off/neutral filter default and plays bit-identically.
//
// v10 (CURRENT WRITE FORMAT) is v9 PLUS the staged-curve tail, appended after the filter's
// velocity curve, all 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve
// v10 is v9 PLUS the staged-curve tail, appended after the filter's velocity curve, all
// 8-byte LE doubles in this order: amp AHDSR attack/decay/release curve
// exponents; the Trigger amp AHD (attack SECONDS, decay SECONDS, hold FRACTION, attack curve,
// decay curve); the pitch envelope's hold FRACTION + attack/decay curve exponents; the filter
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
// the amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
//
// v11 (CURRENT WRITE FORMAT) is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE
// frames (a source-timeline quantity like the loop points, so no rate resolves it). A v10-or-
// older blob is a strict prefix and lifts to 0 — the hard seam it always played.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
// seconds at the project rate the reader is handed. v10 writes ZERO into both — the values
// seconds at the project rate the reader is handed. v10+ writes ZERO into both — the values
// live in the AHD now, so a DOWNGRADE to a pre-v10 binary loses the Trigger amp shape.
//
// LOSSY UNDER A RATE MISMATCH. The fades were SOURCE frames and the AHD stores wall-clock
@@ -116,7 +120,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 10; // v9 + the staged-curve tail
inline constexpr std::uint32_t kParamsPayloadVersion = 11; // v10 + the loop-crossfade tail
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -132,6 +136,9 @@ inline constexpr std::uint32_t kParamsFilterVersion = 9;
// v9 + the staged-curve tail (curve exponents, the Trigger AHDs, the pitch Hold fraction).
inline constexpr std::uint32_t kParamsCurveVersion = 10;
// v10 + the loop-crossfade frame count.
inline constexpr std::uint32_t kParamsLoopVersion = 11;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
@@ -319,6 +319,8 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, doubleToBits(f.env.decayCurve));
putLE(out, doubleToBits(f.env.releaseCurve));
putAhd(out, f.trigEnv);
// v11: the loop crossfade, in SOURCE frames.
putLE(out, asU64(p.loopCrossfadeFrames));
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -351,6 +353,12 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
readCurveTail(r, p.velocityCurve);
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
if (pv >= kParamsLoopVersion) {
// A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing
// it here keeps the parameter set itself sane for the editor that reads it back.
const std::int64_t xf = r.i64();
p.loopCrossfadeFrames = xf > 0 ? xf : 0;
}
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
+1 -1
View File
@@ -5,7 +5,7 @@
// responsibilities. An INTERNAL seam of `component_state_io` — the public entry points stay
// serialize/deserializeComponentState; nothing outside the codec calls these.
//
// The format ladder (payload v1..v10) is documented in component_state_io.h, which stays its
// The format ladder (payload v1..v11) is documented in component_state_io.h, which stays its
// one home. EVERY wire format is FROZEN.
#include <cstdint>
+2
View File
@@ -270,6 +270,7 @@ ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
// The bank is never mutated.
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
rs.loopCrossfadeFrames = params.loopCrossfadeFrames;
rs.startFrame = params.startPoint ? *params.startPoint : 0;
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
return rs;
@@ -306,6 +307,7 @@ SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded)
data.sampleRate = decoded.sampleRate;
data.rootNote = resolved.rootNote;
data.loop = resolved.loop;
data.loopCrossfadeFrames = resolved.loopCrossfadeFrames;
data.startFrame = resolved.startFrame;
data.keyTrack = resolved.keyTrack;
data.velocityCurve = resolved.velocityCurve;
+7
View File
@@ -227,6 +227,12 @@ struct InstrumentParams {
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// Pre-seam crossfade at the loop reset, in SOURCE frames — a source-timeline quantity
// like the loop points, so it needs no rate to resolve and cannot be rescaled by a
// project/file rate mismatch. 0 is the hard seam a blob predating the field lifts to.
// Never a bank fact: the fade is a performance choice, the loop points are the file's.
std::int64_t loopCrossfadeFrames = 0;
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
// (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
@@ -259,6 +265,7 @@ struct ResolvedCapture {
double keyTrack = 1.0;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
PlaySeconds play; // stored SECONDS; resolved to frames at build
};
+12
View File
@@ -67,6 +67,18 @@ std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x) {
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
}
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame) {
const Rect& r = area.rect;
if (r.empty()) return Rect{};
const int mx = frameToX(area, frameCount, frame);
// Clip into the area: a marker at the last frame maps to right(), whose unclipped tab
// would claim pixels outside the band the caller already hit-tested.
const int left = std::max(r.x, mx - kMarkerHandleHalfWidth);
const int right = std::min(r.right(), mx + kMarkerHandleHalfWidth + 1);
if (right <= left) return Rect{};
return Rect{left, r.y, right - left, std::min(kMarkerHandleHeight, r.height)};
}
int markerAtPoint(const OverlayArea& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y) {
if (count <= 0 || frames == nullptr) return -1;
+9
View File
@@ -65,6 +65,15 @@ int frameToX(const OverlayArea& area, std::int64_t frameCount, std::int64_t fram
// area.x yields 0; right of area.right() yields frameCount.
std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x);
// A marker's grab HANDLE: a tab riding the top of the overlay, centred on the marker's x and
// clipped into the area. Distinct from the full-height grab COLUMN markerAtPoint answers, so
// two markers that share a frame stay independently grabbable — the handle owns the top
// strip, the column owns everything below it. Without that split, first-in-draw-order wins
// every coincident tie and the loser can never be dragged apart again.
inline constexpr int kMarkerHandleHeight = 10;
inline constexpr int kMarkerHandleHalfWidth = 5;
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame);
// Which marker (index into the caller's parallel `frames` array, in draw order) a grab at
// (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of
// its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.