loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone
This commit is contained in:
@@ -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 (1–32, 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 Fritsch–Carlson 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user